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..35dba2c 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,13 @@ 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 +import kotlin.math.roundToInt + data class WearWeatherData( val temperature: Int, val condition: String, @@ -17,18 +25,71 @@ data class WearWeatherData( */ object WearWeatherRepository { + private const val TAG = "WearWeatherRepository" + + // API instances + private val weatherApi by lazy { OpenMeteoApi.create() } + private val nominatimApi by lazy { NominatimApi.create() } + // Cached weather data private var cachedWeather: WearWeatherData? = null - suspend fun getWeather(): WearWeatherData { + suspend fun getWeather(lat: Double = 37.7749, lon: Double = -122.4194): WearWeatherData = withContext(Dispatchers.IO) { // In production: - // 1. Try to get from phone via DataClient + // 1. Try to get from phone via DataClient (skipped for this task) // 2. Fall back to direct API call // 3. Fall back to cached data - return cachedWeather ?: WearWeatherData( + try { + // Fetch weather + val weatherResponse = weatherApi.getWeather(lat, lon) + + if (weatherResponse.isSuccessful && weatherResponse.body() != null) { + val data = weatherResponse.body()!! + val current = data.current ?: throw Exception("No current weather data") + + // Fetch location name (optional, best effort) + val locationName = try { + val locationResponse = nominatimApi.reverseGeocode(lat, lon) + if (locationResponse.isSuccessful && locationResponse.body() != null) { + locationResponse.body()!!.address?.getLocationName() ?: "Unknown" + } else { + "Unknown Location" + } + } catch (e: Exception) { + Log.w(TAG, "Failed to fetch location name", e) + "Unknown Location" + } + + // Extract daily high/low (assuming first element is today) + val daily = data.daily + val high = daily?.tempMax?.firstOrNull() ?: current.temperature + val low = daily?.tempMin?.firstOrNull() ?: current.temperature + + val newData = WearWeatherData( + temperature = current.temperature.roundToInt(), + condition = WeatherCodeMapper.getDescription(current.weatherCode), + conditionIcon = WeatherCodeMapper.getIcon(current.weatherCode, current.isDay == 1), + high = high.roundToInt(), + low = low.roundToInt(), + humidity = current.humidity, + windSpeed = current.windSpeed.roundToInt(), + location = if (locationName != "Unknown Location" && locationName != "Unknown") locationName else "Lat: %.2f, Lon: %.2f".format(lat, lon) + ) + + cachedWeather = newData + return@withContext newData + } else { + Log.e(TAG, "Weather API error: ${weatherResponse.code()}") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch weather", e) + } + + // Return cached or default on error + return@withContext cachedWeather ?: WearWeatherData( temperature = 72, - condition = "Partly Cloudy", + condition = "Partly Cloudy (Demo)", conditionIcon = "⛅", high = 78, low = 65, 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..61f2bfb --- /dev/null +++ b/android/wear/src/main/kotlin/com/climaai/wear/data/api/OpenMeteoApi.kt @@ -0,0 +1,79 @@ +package com.climaai.wear.data.api + +import okhttp3.OkHttpClient +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Open-Meteo API - Free weather data, no API key required. + * https://open-meteo.com/ + */ +interface OpenMeteoApi { + + @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" + ): Response + + companion object { + const val BASE_URL = "https://api.open-meteo.com/" + + const val CURRENT_PARAMS = "temperature_2m,relative_humidity_2m,apparent_temperature," + + "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, no API key. + * https://nominatim.openstreetmap.org/ + */ +interface NominatimApi { + + @GET("reverse") + suspend fun reverseGeocode( + @Query("lat") latitude: Double, + @Query("lon") longitude: Double, + @Query("format") format: String = "json", + @Query("addressdetails") addressDetails: Int = 1, + @Query("zoom") zoom: Int = 10 + ): Response + + companion object { + const val BASE_URL = "https://nominatim.openstreetmap.org/" + + fun create(): NominatimApi { + val client = OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("User-Agent", "ClimaAI-WearOS/1.0") + .build() + chain.proceed(request) + } + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .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..c6c4f41 --- /dev/null +++ b/android/wear/src/main/kotlin/com/climaai/wear/data/api/OpenMeteoModels.kt @@ -0,0 +1,96 @@ +package com.climaai.wear.data.api + +import com.google.gson.annotations.SerializedName + +// ============================================================ +// Open-Meteo Weather Response Models +// ============================================================ + +data class OpenMeteoWeatherResponse( + val latitude: Double, + val longitude: Double, + val timezone: String, + val current: OpenMeteoCurrentWeather?, + val daily: OpenMeteoDaily? +) + +data class OpenMeteoCurrentWeather( + val time: String, + @SerializedName("temperature_2m") val temperature: Double, + @SerializedName("relative_humidity_2m") val humidity: Int, + @SerializedName("apparent_temperature") val feelsLike: Double, + @SerializedName("weather_code") val weatherCode: Int, + @SerializedName("wind_speed_10m") val windSpeed: Double, + @SerializedName("is_day") val isDay: Int +) + +data class OpenMeteoDaily( + val time: List, + @SerializedName("temperature_2m_max") val tempMax: List, + @SerializedName("temperature_2m_min") val tempMin: List +) + +// ============================================================ +// Nominatim Geocoding Models +// ============================================================ + +data class NominatimResult( + @SerializedName("place_id") val placeId: Long, + val lat: String, + val lon: String, + @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?, + val country: String?, + @SerializedName("country_code") val countryCode: String? +) { + fun getLocationName(): String { + return city ?: town ?: village ?: county ?: state ?: "Unknown" + } +} + +// ============================================================ +// Weather Code Mapping +// ============================================================ + +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..3c150f7 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 from repository + weather = WearWeatherRepository.getWeather() isLoading = false }