|
| 1 | +import React, { useState, useEffect } from 'react'; |
| 2 | +import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native'; |
| 3 | +import { Ionicons } from '@expo/vector-icons'; |
| 4 | +import { useTheme } from '../theme/ThemeProvider'; |
| 5 | + |
| 6 | +// Get weather icon based on WMO weather code |
| 7 | +const getWeatherIcon = (weatherCode) => { |
| 8 | + const iconMap = { |
| 9 | + 0: 'sunny', // Clear sky |
| 10 | + 1: 'partly-sunny', // Mainly clear |
| 11 | + 2: 'partly-sunny', // Partly cloudy |
| 12 | + 3: 'cloudy', // Overcast |
| 13 | + 45: 'cloudy', // Fog |
| 14 | + 48: 'cloudy', // Fog |
| 15 | + 51: 'rainy', // Drizzle |
| 16 | + 53: 'rainy', // Drizzle |
| 17 | + 55: 'rainy', // Dense drizzle |
| 18 | + 61: 'rainy', // Rain |
| 19 | + 63: 'rainy', // Rain |
| 20 | + 65: 'rainy', // Heavy rain |
| 21 | + 71: 'snow', // Snow |
| 22 | + 73: 'snow', // Snow |
| 23 | + 75: 'snow', // Heavy snow |
| 24 | + 95: 'thunderstorm', // Thunderstorm |
| 25 | + }; |
| 26 | + return iconMap[weatherCode] || 'cloudy'; |
| 27 | +}; |
| 28 | + |
| 29 | +const getDayName = (dateStr) => { |
| 30 | + const date = new Date(dateStr); |
| 31 | + const today = new Date(); |
| 32 | + if (date.getUTCDay() === today.getDay() && date.getUTCDate() === today.getDate()) { |
| 33 | + return 'Today'; |
| 34 | + } |
| 35 | + return date.toLocaleDateString('en-US', { weekday: 'short', timeZone: 'UTC' }); |
| 36 | +}; |
| 37 | + |
| 38 | +const formatTime = (timeStr) => { |
| 39 | + const date = new Date(timeStr); |
| 40 | + let hours = date.getHours(); |
| 41 | + const ampm = hours >= 12 ? 'PM' : 'AM'; |
| 42 | + hours = hours % 12; |
| 43 | + hours = hours ? hours : 12; // the hour '0' should be '12' |
| 44 | + return `${hours} ${ampm}`; |
| 45 | +}; |
| 46 | + |
| 47 | +export default function WeatherForecastUI({ latitude, longitude, useImperialUnits = true }) { |
| 48 | + const { theme } = useTheme(); |
| 49 | + const [data, setData] = useState(null); |
| 50 | + const [loading, setLoading] = useState(true); |
| 51 | + const [error, setError] = useState(null); |
| 52 | + |
| 53 | + useEffect(() => { |
| 54 | + if (!latitude || !longitude) return; |
| 55 | + |
| 56 | + const fetchForecast = async () => { |
| 57 | + setLoading(true); |
| 58 | + setError(null); |
| 59 | + try { |
| 60 | + const tempUnits = useImperialUnits ? 'fahrenheit' : 'celsius'; |
| 61 | + // Open-Meteo free API for hourly (24h) and daily (7 days) |
| 62 | + const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&hourly=temperature_2m,weathercode&daily=weathercode,temperature_2m_max,temperature_2m_min&timezone=auto&temperature_unit=${tempUnits}`; |
| 63 | + const res = await fetch(url); |
| 64 | + if (!res.ok) throw new Error('Failed to fetch forecast'); |
| 65 | + const json = await res.json(); |
| 66 | + setData(json); |
| 67 | + } catch (err) { |
| 68 | + setError('Forecast unavailable'); |
| 69 | + } finally { |
| 70 | + setLoading(false); |
| 71 | + } |
| 72 | + }; |
| 73 | + |
| 74 | + fetchForecast(); |
| 75 | + }, [latitude, longitude, useImperialUnits]); |
| 76 | + |
| 77 | + if (loading) { |
| 78 | + return ( |
| 79 | + <View style={[styles.loadingContainer, { backgroundColor: theme.colors.card }]}> |
| 80 | + <ActivityIndicator color={theme.colors.primary} /> |
| 81 | + </View> |
| 82 | + ); |
| 83 | + } |
| 84 | + |
| 85 | + if (error || !data) { |
| 86 | + return ( |
| 87 | + <View style={[styles.errorContainer, { backgroundColor: theme.colors.card }]}> |
| 88 | + <Text style={[styles.errorText, { color: theme.colors.muted }]}>{error || 'Weather forecast unavailable'}</Text> |
| 89 | + </View> |
| 90 | + ); |
| 91 | + } |
| 92 | + |
| 93 | + // Parse Hourly (Next 24 hours) |
| 94 | + const now = new Date(); |
| 95 | + const currentHourUTCStr = now.toISOString().slice(0, 13) + ':00'; // Match open-meteo basic ISO pattern to find index natively |
| 96 | + let startIndex = 0; |
| 97 | + for (let i = 0; i < data.hourly.time.length; i++) { |
| 98 | + if (new Date(data.hourly.time[i]).getTime() >= now.getTime()) { |
| 99 | + startIndex = i; |
| 100 | + break; |
| 101 | + } |
| 102 | + } |
| 103 | + // Take next 24 hours |
| 104 | + const hourlyData = data.hourly.time.slice(startIndex, startIndex + 24).map((time, i) => ({ |
| 105 | + time, |
| 106 | + temp: Math.round(data.hourly.temperature_2m[startIndex + i]), |
| 107 | + weathercode: data.hourly.weathercode[startIndex + i] |
| 108 | + })); |
| 109 | + |
| 110 | + // Parse Daily (7 Days) |
| 111 | + const dailyData = data.daily.time.map((time, i) => ({ |
| 112 | + time, |
| 113 | + min: Math.round(data.daily.temperature_2m_min[i]), |
| 114 | + max: Math.round(data.daily.temperature_2m_max[i]), |
| 115 | + weathercode: data.daily.weathercode[i] |
| 116 | + })); |
| 117 | + |
| 118 | + // Overall min/max for the week bar scaling |
| 119 | + const absoluteMin = Math.min(...dailyData.map(d => d.min)); |
| 120 | + const absoluteMax = Math.max(...dailyData.map(d => d.max)); |
| 121 | + const rangeTotal = absoluteMax - absoluteMin || 1; |
| 122 | + |
| 123 | + const tempSym = useImperialUnits ? '°F' : '°C'; |
| 124 | + |
| 125 | + return ( |
| 126 | + <View style={styles.container}> |
| 127 | + {/* 24-Hour Forecast Scroll */} |
| 128 | + <View style={[styles.box, { backgroundColor: theme.colors.card }]}> |
| 129 | + <View style={styles.boxHeader}> |
| 130 | + <Ionicons name="time-outline" size={14} color={theme.colors.muted} /> |
| 131 | + <Text style={[styles.boxTitle, { color: theme.colors.muted }]}>24-HOUR FORECAST</Text> |
| 132 | + </View> |
| 133 | + <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hourlyScroll}> |
| 134 | + {hourlyData.map((hour, i) => ( |
| 135 | + <View key={i} style={styles.hourlyItem}> |
| 136 | + <Text style={[styles.hourlyTime, { color: theme.colors.text }]}> |
| 137 | + {i === 0 ? 'Now' : formatTime(hour.time)} |
| 138 | + </Text> |
| 139 | + <Ionicons name={getWeatherIcon(hour.weathercode)} size={22} color={theme.colors.primary} style={styles.hourlyIcon} /> |
| 140 | + <Text style={[styles.hourlyTemp, { color: theme.colors.text }]}>{hour.temp}°</Text> |
| 141 | + </View> |
| 142 | + ))} |
| 143 | + </ScrollView> |
| 144 | + </View> |
| 145 | + |
| 146 | + {/* 7-Day Forecast Box */} |
| 147 | + <View style={[styles.box, { backgroundColor: theme.colors.card }]}> |
| 148 | + <View style={styles.boxHeader}> |
| 149 | + <Ionicons name="calendar-outline" size={14} color={theme.colors.muted} /> |
| 150 | + <Text style={[styles.boxTitle, { color: theme.colors.muted }]}>7-DAY FORECAST</Text> |
| 151 | + </View> |
| 152 | + <View style={styles.dailyList}> |
| 153 | + {dailyData.map((day, i) => { |
| 154 | + const leftOffset = ((day.min - absoluteMin) / rangeTotal) * 100; |
| 155 | + const barWidth = ((day.max - day.min) / rangeTotal) * 100; |
| 156 | + |
| 157 | + return ( |
| 158 | + <View key={i} style={styles.dailyRow}> |
| 159 | + <Text style={[styles.dailyDayName, { color: theme.colors.text }]}>{getDayName(day.time)}</Text> |
| 160 | + <View style={styles.dailyIconBox}> |
| 161 | + <Ionicons name={getWeatherIcon(day.weathercode)} size={20} color={theme.colors.primary} /> |
| 162 | + </View> |
| 163 | + <Text style={[styles.dailyMin, { color: theme.colors.muted }]}>{day.min}°</Text> |
| 164 | + |
| 165 | + {/* Visual Temperature Distribution Bar */} |
| 166 | + <View style={[styles.dailyBarWrapper, { backgroundColor: 'rgba(156, 163, 175, 0.2)' }]}> |
| 167 | + <View |
| 168 | + style={[ |
| 169 | + styles.dailyBar, |
| 170 | + { |
| 171 | + left: `${leftOffset}%`, |
| 172 | + width: `${barWidth}%`, |
| 173 | + backgroundColor: theme.colors.primary |
| 174 | + } |
| 175 | + ]} |
| 176 | + /> |
| 177 | + </View> |
| 178 | + |
| 179 | + <Text style={[styles.dailyMax, { color: theme.colors.text }]}>{day.max}°</Text> |
| 180 | + </View> |
| 181 | + ); |
| 182 | + })} |
| 183 | + </View> |
| 184 | + </View> |
| 185 | + </View> |
| 186 | + ); |
| 187 | +} |
| 188 | + |
| 189 | +const styles = StyleSheet.create({ |
| 190 | + container: { |
| 191 | + paddingHorizontal: 20, |
| 192 | + marginBottom: 16, |
| 193 | + gap: 12, |
| 194 | + }, |
| 195 | + loadingContainer: { |
| 196 | + height: 120, |
| 197 | + marginHorizontal: 20, |
| 198 | + marginBottom: 16, |
| 199 | + borderRadius: 14, |
| 200 | + justifyContent: 'center', |
| 201 | + alignItems: 'center', |
| 202 | + }, |
| 203 | + errorContainer: { |
| 204 | + height: 80, |
| 205 | + marginHorizontal: 20, |
| 206 | + marginBottom: 16, |
| 207 | + borderRadius: 14, |
| 208 | + justifyContent: 'center', |
| 209 | + alignItems: 'center', |
| 210 | + }, |
| 211 | + errorText: { |
| 212 | + fontSize: 13, |
| 213 | + }, |
| 214 | + box: { |
| 215 | + borderRadius: 14, |
| 216 | + overflow: 'hidden', |
| 217 | + padding: 14, |
| 218 | + }, |
| 219 | + boxHeader: { |
| 220 | + flexDirection: 'row', |
| 221 | + alignItems: 'center', |
| 222 | + marginBottom: 12, |
| 223 | + gap: 6, |
| 224 | + }, |
| 225 | + boxTitle: { |
| 226 | + fontSize: 11, |
| 227 | + fontWeight: '700', |
| 228 | + letterSpacing: 0.5, |
| 229 | + }, |
| 230 | + hourlyScroll: { |
| 231 | + paddingBottom: 4, |
| 232 | + }, |
| 233 | + hourlyItem: { |
| 234 | + alignItems: 'center', |
| 235 | + marginRight: 22, |
| 236 | + }, |
| 237 | + hourlyTime: { |
| 238 | + fontSize: 13, |
| 239 | + fontWeight: '500', |
| 240 | + marginBottom: 8, |
| 241 | + }, |
| 242 | + hourlyIcon: { |
| 243 | + marginBottom: 8, |
| 244 | + }, |
| 245 | + hourlyTemp: { |
| 246 | + fontSize: 15, |
| 247 | + fontWeight: '600', |
| 248 | + }, |
| 249 | + dailyList: { |
| 250 | + gap: 12, |
| 251 | + }, |
| 252 | + dailyRow: { |
| 253 | + flexDirection: 'row', |
| 254 | + alignItems: 'center', |
| 255 | + }, |
| 256 | + dailyDayName: { |
| 257 | + width: 48, |
| 258 | + fontSize: 14, |
| 259 | + fontWeight: '600', |
| 260 | + }, |
| 261 | + dailyIconBox: { |
| 262 | + width: 32, |
| 263 | + alignItems: 'center', |
| 264 | + }, |
| 265 | + dailyMin: { |
| 266 | + width: 32, |
| 267 | + textAlign: 'right', |
| 268 | + fontSize: 14, |
| 269 | + fontWeight: '500', |
| 270 | + marginRight: 8, |
| 271 | + }, |
| 272 | + dailyBarWrapper: { |
| 273 | + flex: 1, |
| 274 | + height: 6, |
| 275 | + borderRadius: 3, |
| 276 | + marginHorizontal: 4, |
| 277 | + overflow: 'hidden', |
| 278 | + position: 'relative', |
| 279 | + }, |
| 280 | + dailyBar: { |
| 281 | + position: 'absolute', |
| 282 | + height: '100%', |
| 283 | + borderRadius: 3, |
| 284 | + }, |
| 285 | + dailyMax: { |
| 286 | + width: 32, |
| 287 | + textAlign: 'right', |
| 288 | + fontSize: 14, |
| 289 | + fontWeight: '600', |
| 290 | + marginLeft: 8, |
| 291 | + }, |
| 292 | +}); |
0 commit comments