diff --git a/android/wear/src/main/kotlin/com/climaai/wear/data/WearWeatherRepository.kt b/android/wear/src/main/kotlin/com/climaai/wear/data/WearWeatherRepository.kt index ff11d5b..79bc17c 100644 --- a/android/wear/src/main/kotlin/com/climaai/wear/data/WearWeatherRepository.kt +++ b/android/wear/src/main/kotlin/com/climaai/wear/data/WearWeatherRepository.kt @@ -1,5 +1,12 @@ package com.climaai.wear.data +import android.util.Log +import com.climaai.wear.data.api.NominatimApi +import com.climaai.wear.data.api.OpenMeteoApi +import com.climaai.wear.data.api.WeatherCodeMapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + data class WearWeatherData( val temperature: Int, val condition: String, @@ -13,20 +20,74 @@ data class WearWeatherData( /** * Repository for fetching weather data on Wear OS. - * In production, syncs with phone app via Wearable Data Layer API. + * Fetches directly from Open-Meteo API (standalone mode). */ object WearWeatherRepository { + private const val TAG = "WearWeatherRepository" + + // Default location: San Francisco + private const val DEFAULT_LAT = 37.7749 + private const val DEFAULT_LON = -122.4194 + private const val DEFAULT_LOCATION_NAME = "San Francisco" + + private val weatherApi = OpenMeteoApi.create() + private val locationApi = NominatimApi.create() + // Cached weather data private var cachedWeather: WearWeatherData? = null - suspend fun getWeather(): WearWeatherData { - // In production: - // 1. Try to get from phone via DataClient - // 2. Fall back to direct API call - // 3. Fall back to cached data + suspend fun getWeather( + lat: Double = DEFAULT_LAT, + lon: Double = DEFAULT_LON + ): WearWeatherData = withContext(Dispatchers.IO) { + try { + // 1. Fetch Weather + val weatherResponse = weatherApi.getWeather(lat, lon) + + if (weatherResponse.isSuccessful && weatherResponse.body() != null) { + val data = weatherResponse.body()!! + val current = data.current + val daily = data.daily + + if (current != null) { + // 2. Fetch Location Name (optional, use default if fails) + var locationName = DEFAULT_LOCATION_NAME + // Only fetch location name if we are not using default coordinates or if we want to be accurate + try { + val locationResponse = locationApi.reverseGeocode(lat, lon) + if (locationResponse.isSuccessful && locationResponse.body() != null) { + val locationData = locationResponse.body()!! + locationData.address?.getLocationName()?.let { + locationName = it + } + } + } catch (e: Exception) { + Log.w(TAG, "Reverse geocoding failed", e) + } + + // Map to WearWeatherData + val newWeather = WearWeatherData( + temperature = current.temperature.toInt(), + condition = WeatherCodeMapper.getDescription(current.weatherCode), + conditionIcon = WeatherCodeMapper.getIcon(current.weatherCode, current.isDay == 1), + high = daily?.tempMax?.firstOrNull()?.toInt() ?: current.temperature.toInt(), + low = daily?.tempMin?.firstOrNull()?.toInt() ?: current.temperature.toInt(), + humidity = current.humidity, + windSpeed = current.windSpeed.toInt(), + location = locationName + ) + + cachedWeather = newWeather + return@withContext newWeather + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch weather", e) + } - return cachedWeather ?: WearWeatherData( + // Return cached or default if fetch fails + return@withContext cachedWeather ?: WearWeatherData( temperature = 72, condition = "Partly Cloudy", conditionIcon = "⛅", diff --git a/android/wear/src/main/kotlin/com/climaai/wear/data/api/OpenMeteoApi.kt b/android/wear/src/main/kotlin/com/climaai/wear/data/api/OpenMeteoApi.kt new file mode 100644 index 0000000..5eabb19 --- /dev/null +++ b/android/wear/src/main/kotlin/com/climaai/wear/data/api/OpenMeteoApi.kt @@ -0,0 +1,68 @@ +package com.climaai.wear.data.api + +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Query + +/** + * Open-Meteo API - Free weather data. + */ +interface OpenMeteoApi { + + @Headers("User-Agent: ClimaAI-WearOS/1.0") + @GET("v1/forecast") + suspend fun getWeather( + @Query("latitude") latitude: Double, + @Query("longitude") longitude: Double, + @Query("current") current: String = CURRENT_PARAMS, + @Query("daily") daily: String = DAILY_PARAMS, + @Query("timezone") timezone: String = "auto", + @Query("forecast_days") forecastDays: Int = 1 + ): Response + + companion object { + const val BASE_URL = "https://api.open-meteo.com/" + + const val CURRENT_PARAMS = "temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,is_day" + + const val DAILY_PARAMS = "temperature_2m_max,temperature_2m_min" + + fun create(): OpenMeteoApi { + return Retrofit.Builder() + .baseUrl(BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(OpenMeteoApi::class.java) + } + } +} + +/** + * Nominatim Geocoding API - Free location search. + */ +interface NominatimApi { + + @Headers("User-Agent: ClimaAI-WearOS/1.0") + @GET("reverse") + suspend fun reverseGeocode( + @Query("lat") latitude: Double, + @Query("lon") longitude: Double, + @Query("format") format: String = "json", + @Query("addressdetails") addressDetails: Int = 1 + ): Response + + companion object { + const val BASE_URL = "https://nominatim.openstreetmap.org/" + + fun create(): NominatimApi { + return Retrofit.Builder() + .baseUrl(BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(NominatimApi::class.java) + } + } +} diff --git a/android/wear/src/main/kotlin/com/climaai/wear/data/api/OpenMeteoModels.kt b/android/wear/src/main/kotlin/com/climaai/wear/data/api/OpenMeteoModels.kt new file mode 100644 index 0000000..c1b80cf --- /dev/null +++ b/android/wear/src/main/kotlin/com/climaai/wear/data/api/OpenMeteoModels.kt @@ -0,0 +1,86 @@ +package com.climaai.wear.data.api + +import com.google.gson.annotations.SerializedName + +// ============================================================ +// Open-Meteo Weather Response Models +// ============================================================ + +data class OpenMeteoWeatherResponse( + val timezone: String, + val current: OpenMeteoCurrentWeather?, + val daily: OpenMeteoDaily? +) + +data class OpenMeteoCurrentWeather( + @SerializedName("temperature_2m") val temperature: Double, + @SerializedName("relative_humidity_2m") val humidity: Int, + @SerializedName("weather_code") val weatherCode: Int, + @SerializedName("wind_speed_10m") val windSpeed: Double, + @SerializedName("is_day") val isDay: Int +) + +data class OpenMeteoDaily( + @SerializedName("temperature_2m_max") val tempMax: List, + @SerializedName("temperature_2m_min") val tempMin: List +) + +// ============================================================ +// Nominatim Geocoding Models +// ============================================================ + +data class NominatimResult( + @SerializedName("display_name") val displayName: String, + val address: NominatimAddress? +) + +data class NominatimAddress( + val city: String?, + val town: String?, + val village: String?, + val county: String?, + val state: String? +) { + fun getLocationName(): String { + return city ?: town ?: village ?: county ?: state ?: "Unknown" + } +} + +// ============================================================ +// Weather Code Mapping (WMO) +// ============================================================ + +object WeatherCodeMapper { + + fun getDescription(code: Int): String = when (code) { + 0 -> "Clear sky" + 1 -> "Mainly clear" + 2 -> "Partly cloudy" + 3 -> "Overcast" + 45, 48 -> "Foggy" + 51, 53, 55 -> "Drizzle" + 56, 57 -> "Freezing drizzle" + 61, 63, 65 -> "Rain" + 66, 67 -> "Freezing rain" + 71, 73, 75 -> "Snow" + 77 -> "Snow grains" + 80, 81, 82 -> "Rain showers" + 85, 86 -> "Snow showers" + 95 -> "Thunderstorm" + 96, 99 -> "Thunderstorm with hail" + else -> "Unknown" + } + + fun getIcon(code: Int, isDay: Boolean = true): String = when (code) { + 0 -> if (isDay) "☀️" else "🌙" + 1, 2 -> if (isDay) "⛅" else "☁️" + 3 -> "☁️" + 45, 48 -> "🌫️" + 51, 53, 55, 61, 63, 65 -> "🌧️" + 56, 57, 66, 67 -> "🌨️" + 71, 73, 75, 77, 85, 86 -> "❄️" + 80, 81, 82 -> "🌦️" + 95, 96, 99 -> "⛈️" + else -> "🌤️" + } +} diff --git a/android/wear/src/main/kotlin/com/climaai/wear/presentation/screens/WeatherScreen.kt b/android/wear/src/main/kotlin/com/climaai/wear/presentation/screens/WeatherScreen.kt index fad5716..6038fd1 100644 --- a/android/wear/src/main/kotlin/com/climaai/wear/presentation/screens/WeatherScreen.kt +++ b/android/wear/src/main/kotlin/com/climaai/wear/presentation/screens/WeatherScreen.kt @@ -23,17 +23,8 @@ fun WeatherScreen( // Fetch weather on launch LaunchedEffect(Unit) { - // Demo data - in production, fetch from repository - weather = WearWeatherData( - temperature = 72, - condition = "Partly Cloudy", - conditionIcon = "⛅", - high = 78, - low = 65, - humidity = 45, - windSpeed = 8, - location = "San Francisco" - ) + // Fetch real data from repository + weather = WearWeatherRepository.getWeather() isLoading = false }