package com.gigaprojects.gigaweather
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.gigaprojects.gigaweather.data.LocationDatabase
import com.gigaprojects.gigaweather.data.LocationEntity
import com.gigaprojects.gigaweather.ui.theme.GigaWeatherTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.text.SimpleDateFormat
import java.util.*
private const val FUTURE_FORECAST_DAYS = 7
private const val FORECAST_REQUEST_DAYS = 8
class WeatherDetailActivity : ComponentActivity() {
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ -> }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
hideSystemBars()
checkNotificationPermission()
val name = intent.getStringExtra("name") ?: getString(R.string.unknown_location)
val lat = intent.getDoubleExtra("lat", 0.0)
val lon = intent.getDoubleExtra("lon", 0.0)
setContent {
val sharedPreferences = remember { getSharedPreferences("geo_weather_prefs", Context.MODE_PRIVATE) }
val useSystemTheme = sharedPreferences.collectAsState(key = "use_system_theme", defaultValue = true)
val darkModeEnabled = sharedPreferences.collectAsState(key = "dark_mode_enabled", defaultValue = false)
val dynamicColor = sharedPreferences.collectAsState(key = "dynamic_color", defaultValue = true)
val darkTheme = if (useSystemTheme.value) isSystemInDarkTheme() else darkModeEnabled.value
GigaWeatherTheme(darkTheme = darkTheme, dynamicColor = dynamicColor.value) {
WeatherDetailScreen(
name = name,
lat = lat,
lon = lon,
onBack = { finish() }
)
}
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
hideSystemBars()
}
}
private fun hideSystemBars() {
val windowInsetsController =
WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
}
private fun checkNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeatherDetailScreen(
name: String,
lat: Double,
lon: Double,
onBack: () -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val db = remember { LocationDatabase.getDatabase(context) }
val sharedPreferences = remember { context.getSharedPreferences("geo_weather_prefs", Context.MODE_PRIVATE) }
val tempUnit by sharedPreferences.collectStringAsState("temp_unit", "celsius")
val windUnit by sharedPreferences.collectStringAsState("wind_unit", "kmh")
val weatherProvider by sharedPreferences.collectStringAsState("weather_provider", "open_meteo")
val weatherApiKey by sharedPreferences.collectStringAsState("weather_api_key", "")
val qweatherApiKey by sharedPreferences.collectStringAsState("qweather_api_key", "")
var weatherJson by remember { mutableStateOf<String?>(null) }
var aqiJson by remember { mutableStateOf<String?>(null) }
var moonPhaseName by remember { mutableStateOf<String?>(null) }
var forecastList by remember { mutableStateOf<List<DailyForecast>>(emptyList()) }
var hourlyForecastList by remember { mutableStateOf<List<HourlyForecast>>(emptyList()) }
var historicalData by remember { mutableStateOf<List<DailyForecast>>(emptyList()) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isRefreshing by remember { mutableStateOf(false) }
var selectedDayIndex by remember { mutableStateOf(-1) }
suspend fun refreshWeatherData(forceRefresh: Boolean = false) {
try {
isRefreshing = true
val entity = withContext(Dispatchers.IO) { db.locationDao().findByCoordinates(lat, lon) }
val currentTime = System.currentTimeMillis()
val lastUpdated = entity?.lastUpdated
val dataAgeMinutes = if (lastUpdated != null) (currentTime - lastUpdated) / (1000 * 60) else Long.MAX_VALUE
val cachedWeatherData = entity?.weatherData
val cacheHasPrecipitation = cachedWeatherData?.let { hasPrecipitationData(it) } ?: false
val cacheHasFutureForecast = cachedWeatherData?.let {
hasFutureForecastDays(it, FUTURE_FORECAST_DAYS)
} ?: false
var json: String? = null
var aqiJsonResponse: String? = null
val aqiUrl = "https://air-quality-api.open-meteo.com/v1/air-quality?latitude=$lat&longitude=$lon&hourly=european_aqi&timezone=auto"
if (!forceRefresh && cachedWeatherData != null && cacheHasPrecipitation && cacheHasFutureForecast && dataAgeMinutes < 30) {
json = cachedWeatherData
weatherJson = json
} else {
val url = if (weatherProvider == "weatherapi" && weatherApiKey.isNotEmpty()) {
"https://api.weatherapi.com/v1/forecast.json?key=$weatherApiKey&q=$lat,$lon&days=$FORECAST_REQUEST_DAYS&aqi=yes"
} else {
"https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon¤t=temperature_2m,weather_code,wind_speed_10m&hourly=temperature_2m,weather_code,relative_humidity_2m,pressure_msl,apparent_temperature,precipitation,precipitation_probability&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_sum,precipitation_probability_max,wind_speed_10m_max&timezone=auto&forecast_days=$FORECAST_REQUEST_DAYS"
}
val histUrl = "https://archive-api.open-meteo.com/v1/archive?latitude=$lat&longitude=$lon&start_date=${getYesterdayDate(-7)}&end_date=${getYesterdayDate(0)}&daily=temperature_2m_max,temperature_2m_min&timezone=auto"
json = withContext(Dispatchers.IO) { httpGet(url) }
try {
val histJson = withContext(Dispatchers.IO) { httpGet(histUrl) }
historicalData = parseForecastData(histJson)
} catch (_: Exception) {}
if (qweatherApiKey.isNotEmpty()) {
try {
val moonUrl = "https://devapi.qweather.com/v7/astronomy/moon?location=$lon,$lat&key=$qweatherApiKey"
val mq = withContext(Dispatchers.IO) { httpGet(moonUrl) }
val obj = JSONObject(mq).getJSONArray("moonPhase").getJSONObject(0)
moonPhaseName = obj.optString("name", null)
} catch (_: Exception) {}
}
}
aqiJsonResponse = withContext(Dispatchers.IO) { try { httpGet(aqiUrl) } catch (e: Exception) { null } }
if (weatherProvider == "open_meteo") {
entity?.copy(weatherData = json, lastUpdated = currentTime)?.let {
withContext(Dispatchers.IO) { db.locationDao().updateLocation(it) }
}
}
weatherJson = json
aqiJson = aqiJsonResponse
if (json != null) {
if (weatherProvider == "weatherapi") {
parseWeatherApiData(json).let {
forecastList = it.first
hourlyForecastList = it.second
}
} else {
forecastList = parseForecastData(
json = json,
futureOnly = true,
limit = FUTURE_FORECAST_DAYS
)
hourlyForecastList = parseHourlyForecastData(json)
}
}
errorMessage = null
} catch (e: Exception) {
errorMessage = e.message ?: context.getString(R.string.error_loading_weather)
} finally {
isRefreshing = false
}
}
LaunchedEffect(Unit) { refreshWeatherData() }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text(name) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back_nav_desc)) }
},
actions = {
IconButton(onClick = { scope.launch { refreshWeatherData(true) } }, enabled = !isRefreshing) {
if (isRefreshing) CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
else Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.refresh_nav_desc))
}
},
scrollBehavior = scrollBehavior
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = innerPadding,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (errorMessage != null) {
item {
Text(errorMessage!!, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp))
}
}
weatherJson?.let { json ->
val (temp, weatherCode) = if (weatherProvider == "weatherapi") {
val current = JSONObject(json).getJSONObject("current")
current.getDouble("temp_c") to 0
} else {
val weatherObj = JSONObject(json)
val current = weatherObj.optJSONObject("current")
val legacyCurrent = weatherObj.optJSONObject("current_weather")
if (current != null) {
current.getDouble("temperature_2m") to current.getInt("weather_code")
} else {
val fallbackCurrent = legacyCurrent ?: JSONObject()
fallbackCurrent.getDouble("temperature") to fallbackCurrent.getInt("weathercode")
}
}
val displayTemp = if (tempUnit == "fahrenheit") (temp * 9/5 + 32).toInt() else temp.toInt()
item {
val tempSuffixC = stringResource(R.string.temp_c_suffix)
val tempSuffixF = stringResource(R.string.temp_f_suffix)
val tempSuffix = if (tempUnit == "fahrenheit") tempSuffixF else tempSuffixC
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = WeatherIconMapper.getWeatherIcon(weatherCode)),
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = Color.Unspecified
)
Text("$displayTemp$tempSuffix", style = MaterialTheme.typography.displayLarge, fontWeight = FontWeight.Bold)
Text(WeatherCodes.getDescription(weatherCode, context), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
item {
WeatherDetailsGrid(JSONObject(json), aqiJson, tempUnit, windUnit, weatherProvider)
}
item {
HourlyForecastSection(hourlyForecastList, tempUnit)
}
item {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(stringResource(R.string.forecast_7day_label), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
forecastList.forEachIndexed { index, forecast ->
ForecastItemRow(
forecast = forecast,
tempUnit = tempUnit,
isSelected = selectedDayIndex == index,
onClick = { selectedDayIndex = if (selectedDayIndex == index) -1 else index }
)
}
}
}
if (moonPhaseName != null) {
item {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "๐", fontSize = 24.sp)
Spacer(Modifier.width(16.dp))
Column {
Text(text = stringResource(R.string.MoonPhaseTXT), style = MaterialTheme.typography.labelMedium)
Text(text = moonPhaseName ?: "", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
}
}
}
if (historicalData.isNotEmpty()) {
item {
HistoricalTrendsSection(historicalData, tempUnit)
}
}
item { Spacer(Modifier.height(32.dp)) }
}
if (weatherJson == null && !isRefreshing) {
item { Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } }
}
}
}
}
@Composable
fun ForecastItemRow(
forecast: DailyForecast,
tempUnit: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val tempSuffix = stringResource(R.string.temp_deg_suffix)
val displayMax = if (tempUnit == "fahrenheit") (forecast.tempMax * 9/5 + 32).toInt() else forecast.tempMax.toInt()
val displayMin = if (tempUnit == "fahrenheit") (forecast.tempMin * 9/5 + 32).toInt() else forecast.tempMin.toInt()
val rainText = formatRainAmount(forecast.precipitationMm)
val precipitationText = if (forecast.precipitationChance > 0) {
"$rainText ยท ${forecast.precipitationChance}%"
} else {
rainText
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onClick() },
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent
)
) {
Column {
ListItem(
headlineContent = { Text(forecast.date) },
supportingContent = {
Column {
Text(WeatherCodes.getDescription(forecast.weatherCode, LocalContext.current))
Text(
text = stringResource(R.string.rain_amount_label, precipitationText),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
trailingContent = { Text("$displayMax$tempSuffix / $displayMin$tempSuffix", fontWeight = FontWeight.Bold) },
leadingContent = {
Icon(
painter = painterResource(WeatherIconMapper.getWeatherIcon(forecast.weatherCode)),
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = Color.Unspecified
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent)
)
AnimatedVisibility(visible = isSelected) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 64.dp, end = 16.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
DetailItemSmall(label = stringResource(R.string.trend_max), value = "$displayMax$tempSuffix")
DetailItemSmall(label = stringResource(R.string.trend_min), value = "$displayMin$tempSuffix")
DetailItemSmall(label = stringResource(R.string.rain_label), value = precipitationText)
}
}
}
}
}
@Composable
fun DetailItemSmall(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
@Composable
fun HistoricalTrendsSection(data: List<DailyForecast>, tempUnit: String) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(stringResource(R.string.historical_trends_label), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
) {
Column(modifier = Modifier.padding(16.dp)) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.padding(top = 8.dp)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val width = size.width
val height = size.height
val points = data.size
if (points < 2) return@Canvas
val maxTemp = data.maxOf { it.tempMax }.toFloat()
val minTemp = data.minOf { it.tempMin }.toFloat()
val range = (maxTemp - minTemp).coerceAtLeast(1f)
val pathMax = Path()
val pathMin = Path()
data.forEachIndexed { index, forecast ->
val x = index * (width / (points - 1))
val yMax = height - ((forecast.tempMax.toFloat() - minTemp) / range * height)
val yMin = height - ((forecast.tempMin.toFloat() - minTemp) / range * height)
if (index == 0) {
pathMax.moveTo(x, yMax)
pathMin.moveTo(x, yMin)
} else {
pathMax.lineTo(x, yMax)
pathMin.lineTo(x, yMin)
}
}
drawPath(pathMax, color = Color.Red, style = Stroke(width = 3.dp.toPx()))
drawPath(pathMin, color = Color.Blue, style = Stroke(width = 3.dp.toPx()))
}
}
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(R.string.trend_min), color = Color.Blue, style = MaterialTheme.typography.labelSmall)
Text(stringResource(R.string.trend_max), color = Color.Red, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
@Composable
fun WeatherDetailsGrid(weatherObj: JSONObject, aqiJson: String?, tempUnit: String, windUnit: String, provider: String) {
val context = LocalContext.current
val (wind, feelsLike, humidity) = if (provider == "weatherapi") {
val current = weatherObj.getJSONObject("current")
Triple(current.getDouble("wind_kph"), current.getDouble("feelslike_c"), current.getInt("humidity"))
} else {
val current = weatherObj.optJSONObject("current")
val legacyCurrent = weatherObj.optJSONObject("current_weather")
val hourly = weatherObj.optJSONObject("hourly")
val currentIndex = if (hourly != null) getCurrentHourIndex(hourly.getJSONArray("time")) else -1
val currentTemp = current?.optDouble("temperature_2m")
?: legacyCurrent?.getDouble("temperature")
?: 0.0
val windVal = current?.optDouble("wind_speed_10m")
?: legacyCurrent?.getDouble("windspeed")
?: 0.0
val humidityValues = hourly?.optJSONArray("relative_humidity_2m")
?: hourly?.optJSONArray("relativehumidity_2m")
val feelsVal = if (currentIndex >= 0) {
hourly?.optJSONArray("apparent_temperature")?.optDouble(currentIndex, currentTemp) ?: currentTemp
} else {
currentTemp
}
val humVal = if (currentIndex >= 0) humidityValues?.optInt(currentIndex, 0) ?: 0 else 0
Triple(windVal, feelsVal, humVal)
}
val displayWind = if (windUnit == "mph") (wind * 0.621371).toInt() else wind.toInt()
val windSuffix = if (windUnit == "mph") stringResource(R.string.wind_mph_suffix) else stringResource(R.string.wind_kmh_suffix)
val displayFeelsLike = if (tempUnit == "fahrenheit") (feelsLike * 9/5 + 32).toInt() else feelsLike.toInt()
val tempSuffix = if (tempUnit == "fahrenheit") stringResource(R.string.temp_f_suffix) else stringResource(R.string.temp_c_suffix)
val humiditySuffix = stringResource(R.string.humidity_suffix)
val europeanAqi = parseCurrentEuropeanAqi(aqiJson)
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
DetailItem(label = stringResource(R.string.wind_label), value = "$displayWind$windSuffix")
DetailItem(label = stringResource(R.string.feels_like_label), value = "$displayFeelsLike$tempSuffix")
DetailItem(label = stringResource(R.string.humidity_label), value = "$humidity$humiditySuffix")
}
if (weatherObj.has("daily")) {
val daily = weatherObj.getJSONObject("daily")
val sunrise = formatTime(daily.getJSONArray("sunrise").getString(0))
val sunset = formatTime(daily.getJSONArray("sunset").getString(0))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
DetailItem(label = stringResource(R.string.sunrise_label), value = sunrise)
DetailItem(label = stringResource(R.string.sunset_label), value = sunset)
if (europeanAqi != null) {
DetailItem(
label = stringResource(R.string.air_label),
value = europeanAqi.toInt().toString(),
valueColor = getEuropeanAqiColor(europeanAqi)
)
}
}
}
}
}
}
@Composable
fun DetailItem(label: String, value: String, valueColor: Color = MaterialTheme.colorScheme.onSurface) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = valueColor)
}
}
@Composable
fun HourlyForecastSection(list: List<HourlyForecast>, tempUnit: String) {
val tempSuffix = if (tempUnit == "fahrenheit") stringResource(R.string.temp_f_suffix) else stringResource(R.string.temp_c_suffix)
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(stringResource(R.string.hourly_label), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(list) { forecast ->
val displayTemp = if (tempUnit == "fahrenheit") (forecast.temp * 9/5 + 32).toInt() else forecast.temp.toInt()
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest)) {
Column(modifier = Modifier.padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(forecast.time, style = MaterialTheme.typography.labelMedium)
Icon(painter = painterResource(WeatherIconMapper.getWeatherIcon(forecast.weatherCode)), contentDescription = null, modifier = Modifier.size(32.dp), tint = Color.Unspecified)
Text("$displayTemp$tempSuffix", style = MaterialTheme.typography.titleSmall)
Text(
text = stringResource(R.string.rain_amount_label, formatRainAmount(forecast.precipitationMm)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
private fun httpGet(urlString: String): String {
val url = URL(urlString)
val c = url.openConnection() as HttpURLConnection
c.setRequestProperty("User-Agent", "GigaWeatherApp")
c.connectTimeout = 12000
c.readTimeout = 12000
BufferedReader(InputStreamReader(c.inputStream, StandardCharsets.UTF_8)).use { reader ->
val sb = StringBuilder()
var line: String?
while (reader.readLine().also { line = it } != null) sb.append(line)
return sb.toString()
}
}
fun hasPrecipitationData(json: String): Boolean {
return try {
val obj = JSONObject(json)
val hourly = obj.optJSONObject("hourly")
val daily = obj.optJSONObject("daily")
hourly?.has("precipitation") == true && daily?.has("precipitation_sum") == true
} catch (_: Exception) {
false
}
}
fun hasFutureForecastDays(json: String, requiredDays: Int): Boolean {
return try {
val obj = JSONObject(json)
val daily = obj.optJSONObject("daily") ?: return false
val times = daily.optJSONArray("time") ?: return false
val todayKey = getTodayKey(getResponseTimeZone(obj))
var futureDays = 0
for (i in 0 until times.length()) {
if (times.getString(i) > todayKey) {
futureDays += 1
}
}
futureDays >= requiredDays
} catch (_: Exception) {
false
}
}
fun getResponseTimeZone(obj: JSONObject): TimeZone {
val timeZoneName = obj.optString("timezone", "")
if (timeZoneName.isBlank()) return TimeZone.getDefault()
val timeZone = TimeZone.getTimeZone(timeZoneName)
if (timeZone.id == "GMT" && timeZoneName != "GMT") return TimeZone.getDefault()
return timeZone
}
fun getTodayKey(timeZone: TimeZone): String {
return SimpleDateFormat("yyyy-MM-dd", Locale.US).apply {
this.timeZone = timeZone
}.format(Date())
}
fun getYesterdayDate(daysAgo: Int): String {
val cal = Calendar.getInstance()
cal.add(Calendar.DAY_OF_YEAR, -daysAgo)
return SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(cal.time)
}
data class DailyForecast(
val date: String,
val tempMax: Double,
val tempMin: Double,
val weatherCode: Int,
val precipitationMm: Double = 0.0,
val precipitationChance: Int = 0
)
data class HourlyForecast(
val time: String,
val temp: Double,
val weatherCode: Int,
val precipitationMm: Double = 0.0,
val precipitationChance: Int = 0
)
fun parseForecastData(
json: String,
futureOnly: Boolean = false,
limit: Int? = null
): List<DailyForecast> {
val list = mutableListOf<DailyForecast>()
try {
val obj = JSONObject(json)
val daily = obj.getJSONObject("daily")
val times = daily.getJSONArray("time")
val tMax = daily.getJSONArray("temperature_2m_max")
val tMin = daily.getJSONArray("temperature_2m_min")
val codes = daily.optJSONArray("weather_code") ?: daily.optJSONArray("weathercode")
val precipitation = if (daily.has("precipitation_sum")) daily.getJSONArray("precipitation_sum") else null
val precipitationChance = if (daily.has("precipitation_probability_max")) daily.getJSONArray("precipitation_probability_max") else null
val responseTimeZone = getResponseTimeZone(obj)
val todayKey = getTodayKey(responseTimeZone)
val df = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).apply {
timeZone = responseTimeZone
}
val outF = SimpleDateFormat("EEE, dd. MMM", Locale.getDefault()).apply {
timeZone = responseTimeZone
}
for (i in 0 until times.length()) {
val dateText = times.getString(i)
if (futureOnly && dateText <= todayKey) continue
val date = df.parse(dateText)
list.add(
DailyForecast(
date = outF.format(date ?: Date()),
tempMax = tMax.getDouble(i),
tempMin = tMin.getDouble(i),
weatherCode = codes?.getInt(i) ?: 0,
precipitationMm = precipitation?.optDouble(i, 0.0) ?: 0.0,
precipitationChance = precipitationChance?.optInt(i, 0) ?: 0
)
)
if (limit != null && list.size >= limit) break
}
} catch (_: Exception) {}
return list
}
fun parseHourlyForecastData(json: String): List<HourlyForecast> {
val list = mutableListOf<HourlyForecast>()
try {
val hourly = JSONObject(json).getJSONObject("hourly")
val times = hourly.getJSONArray("time")
val temps = hourly.getJSONArray("temperature_2m")
val codes = hourly.optJSONArray("weather_code") ?: hourly.getJSONArray("weathercode")
val precipitation = if (hourly.has("precipitation")) hourly.getJSONArray("precipitation") else null
val precipitationChance = if (hourly.has("precipitation_probability")) hourly.getJSONArray("precipitation_probability") else null
val inF = SimpleDateFormat("yyyy-MM-dd'T'HH:mm", Locale.getDefault())
val outF = SimpleDateFormat("HH:mm", Locale.getDefault())
val now = Calendar.getInstance()
for (i in 0 until times.length()) {
val date = inF.parse(times.getString(i)) ?: continue
if (date.after(now.time) && list.size < 24) {
list.add(
HourlyForecast(
time = outF.format(date),
temp = temps.getDouble(i),
weatherCode = codes.getInt(i),
precipitationMm = precipitation?.optDouble(i, 0.0) ?: 0.0,
precipitationChance = precipitationChance?.optInt(i, 0) ?: 0
)
)
}
}
} catch (_: Exception) {}
return list
}
fun parseWeatherApiData(json: String): Pair<List<DailyForecast>, List<HourlyForecast>> {
val dailyList = mutableListOf<DailyForecast>()
val hourlyList = mutableListOf<HourlyForecast>()
try {
val obj = JSONObject(json)
val forecast = obj.getJSONObject("forecast").getJSONArray("forecastday")
val outF = SimpleDateFormat("EEE, dd. MMM", Locale.getDefault())
val df = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val todayKey = obj.optJSONObject("location")
?.optString("localtime", "")
?.takeIf { it.length >= 10 }
?.take(10)
?: SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
for (i in 0 until forecast.length()) {
val day = forecast.getJSONObject(i)
val astro = day.getJSONObject("day")
val dateText = day.getString("date")
if (i == 0) {
val hours = day.getJSONArray("hour")
val now = System.currentTimeMillis()
for (j in 0 until hours.length()) {
val h = hours.getJSONObject(j)
if (h.getLong("time_epoch") * 1000 > now && hourlyList.size < 24) {
val time = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(h.getLong("time_epoch") * 1000))
hourlyList.add(
HourlyForecast(
time = time,
temp = h.getDouble("temp_c"),
weatherCode = 0,
precipitationMm = h.optDouble("precip_mm", 0.0),
precipitationChance = h.optInt("chance_of_rain", 0)
)
)
}
}
}
if (dateText <= todayKey) continue
if (dailyList.size >= FUTURE_FORECAST_DAYS) continue
val date = df.parse(dateText)
dailyList.add(
DailyForecast(
date = outF.format(date ?: Date()),
tempMax = astro.getDouble("maxtemp_c"),
tempMin = astro.getDouble("mintemp_c"),
weatherCode = 0,
precipitationMm = astro.optDouble("totalprecip_mm", 0.0),
precipitationChance = astro.optInt("daily_chance_of_rain", 0)
)
)
}
} catch (_: Exception) {}
return dailyList to hourlyList
}
fun getCurrentHourIndex(timesArray: JSONArray): Int {
val now = Calendar.getInstance()
val currentHour = now.get(Calendar.HOUR_OF_DAY)
for (i in 0 until timesArray.length()) {
val date = SimpleDateFormat("yyyy-MM-dd'T'HH:mm", Locale.getDefault()).parse(timesArray.getString(i)) ?: continue
val cal = Calendar.getInstance().apply { time = date }
if (cal.get(Calendar.HOUR_OF_DAY) == currentHour && cal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR)) return i
}
return 0
}
fun parseCurrentEuropeanAqi(aqiJson: String?): Double? {
if (aqiJson == null) return null
return try {
val hourly = JSONObject(aqiJson).getJSONObject("hourly")
val times = hourly.getJSONArray("time")
val values = hourly.getJSONArray("european_aqi")
val currentIndex = getCurrentHourIndex(times)
values.optDouble(currentIndex).takeIf { !it.isNaN() }
} catch (_: Exception) {
null
}
}
fun getEuropeanAqiColor(aqi: Double): Color {
if (aqi <= 20.0) return Color(0xFF2E7D32)
if (aqi <= 40.0) return Color(0xFF7CB342)
if (aqi <= 60.0) return Color(0xFFF9A825)
if (aqi <= 80.0) return Color(0xFFEF6C00)
if (aqi <= 100.0) return Color(0xFFC62828)
return Color(0xFF6A1B9A)
}
fun formatTime(timeString: String): String {
return try {
val date = SimpleDateFormat("yyyy-MM-dd'T'HH:mm", Locale.getDefault()).parse(timeString)
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date ?: Date())
} catch (_: Exception) { timeString.takeLast(5) }
}
fun formatRainAmount(amountMm: Double): String {
return if (amountMm < 0.1) {
"0 mm"
} else {
String.format(Locale.getDefault(), "%.1f mm", amountMm)
}
}