Introduce loading, error and success state for weather forecast loading scenario.

This commit is contained in:
Nebojsa Vuksic 2025-08-07 15:15:15 +02:00
parent 4824b28177
commit d62e8e9594
3 changed files with 435 additions and 158 deletions

View File

@ -1,11 +1,8 @@
package org.jetbrains.plugins.template.weatherApp.services
import com.intellij.openapi.Disposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.jetbrains.plugins.template.weatherApp.model.Location
import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation
import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData
@ -33,13 +30,50 @@ interface MyLocationsViewModelApi : Disposable {
* and user interactions.
*/
interface WeatherViewModelApi : Disposable {
val weatherForecast: Flow<WeatherForecastData>
val weatherForecastUIState: Flow<WeatherForecastUIState>
fun onLoadWeatherForecast(location: Location)
fun onReloadWeatherForecast()
}
/**
* Represents the state of a weather data fetching process.
*
* This class is sealed, meaning it can have a fixed set of subclasses
* that represent each possible state of the process. It is designed to
* handle and encapsulate the different phases or outcomes when fetching
* weather information, such as loading, success, error, or empty state.
*
* Subclasses:
* - `Loading`: Indicates that the weather data is currently being fetched.
* - `Success`: Indicates that the weather data was successfully fetched and
* contains the loaded data of type `WeatherForecastData`.
* - `Error`: Indicates a failure in fetching the weather data, carrying the
* error message and optional cause details.
* - `Empty`: Indicates that no weather data is available, typically because
* no location has been selected.
*/
sealed class WeatherForecastUIState {
data class Loading(val location: Location) : WeatherForecastUIState()
data class Success(val weatherForecastData: WeatherForecastData) : WeatherForecastUIState()
data class Error(val message: String, val location: Location, val cause: Throwable? = null) :
WeatherForecastUIState()
object Empty : WeatherForecastUIState() // When no location is selected
fun getLocationOrNull(): Location? {
return when (this) {
Empty -> null
is Error -> location
is Loading -> location
is Success -> weatherForecastData.location
}
}
val isLoading: Boolean get() = this is Loading
}
/**
* A ViewModel responsible for managing the user's locations and corresponding weather data.
*
@ -64,15 +98,19 @@ class WeatherAppViewModel(
private val selectedLocationIndex = MutableStateFlow(myLocations.value.lastIndex)
private val _weatherForecast = MutableStateFlow(WeatherForecastData.EMPTY)
private val _weatherState = MutableStateFlow<WeatherForecastUIState>(WeatherForecastUIState.Empty)
/**
* A stream of weather forecast data that emits updates whenever the forecast changes.
* A flow representing the current UI state of the weather forecast.
*
* This property exposes a Flow of [WeatherForecastData], which allows consumers to observe
* the weather forecast information for a selected location.
* This flow emits instances of [WeatherForecastUIState], which encapsulate information
* about the state of weather data loading and processing. The emitted states can represent
* scenarios such as the data being loaded, successfully fetched, an error occurring, or
* the absence of data when no location is selected.
*
* Observers of this flow can react to these state changes to update the UI accordingly.
*/
override val weatherForecast: Flow<WeatherForecastData> = _weatherForecast.asStateFlow()
override val weatherForecastUIState: Flow<WeatherForecastUIState> = _weatherState.asStateFlow()
/**
* A [StateFlow] that emits a list of [SelectableLocation] objects representing the user's
@ -93,7 +131,7 @@ class WeatherAppViewModel(
selectedLocationIndex.value = myLocations.value.lastIndex
}
if (_weatherForecast.value.location != locationToAdd) {
if (_weatherState.value.getLocationOrNull() != locationToAdd) {
onReloadWeatherForecast()
}
}
@ -128,9 +166,18 @@ class WeatherAppViewModel(
currentWeatherJob?.cancel()
currentWeatherJob = viewModelScope.launch {
val weatherForecastData = weatherService.loadWeatherForecastFor(location).getOrNull() ?: return@launch
_weatherState.value = WeatherForecastUIState.Loading(location)
_weatherForecast.value = weatherForecastData
weatherService.loadWeatherForecastFor(location)
.onSuccess { weatherData ->
_weatherState.value = WeatherForecastUIState.Success(weatherData)
}.onFailure { error ->
if (error is CancellationException) {
throw error
}
_weatherState.value = errorStateFor(location, error)
}
}
}
@ -143,4 +190,13 @@ class WeatherAppViewModel(
override fun dispose() {
viewModelScope.cancel()
}
private fun errorStateFor(
location: Location,
error: Throwable
): WeatherForecastUIState.Error = WeatherForecastUIState.Error(
"Failed to load weather forecast for ${location.label}",
location,
error
)
}

View File

@ -24,9 +24,9 @@ import org.jetbrains.jewel.ui.icons.AllIconsKeys
import org.jetbrains.plugins.template.ComposeTemplateBundle
import org.jetbrains.plugins.template.weatherApp.model.Location
import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation
import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData
import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi
import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider
import org.jetbrains.plugins.template.weatherApp.services.WeatherForecastUIState
import org.jetbrains.plugins.template.weatherApp.services.WeatherViewModelApi
import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu
import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard
@ -201,7 +201,7 @@ private fun RightColumn(
searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider<Location>,
modifier: Modifier = Modifier,
) {
val weatherForecastData = weatherViewModelApi.weatherForecast.collectAsState(WeatherForecastData.EMPTY).value
val weatherForecastData = weatherViewModelApi.weatherForecastUIState.collectAsState(WeatherForecastUIState.Empty).value
Column(modifier) {
SearchToolbarMenu(

View File

@ -1,5 +1,6 @@
package org.jetbrains.plugins.template.weatherApp.ui.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -12,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -21,35 +23,53 @@ import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.icons.AllIconsKeys
import org.jetbrains.plugins.template.ComposeTemplateBundle
import org.jetbrains.plugins.template.components.PulsingText
import org.jetbrains.plugins.template.weatherApp.WeatherAppColors
import org.jetbrains.plugins.template.weatherApp.model.DailyForecast
import org.jetbrains.plugins.template.weatherApp.model.Location
import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData
import org.jetbrains.plugins.template.weatherApp.services.WeatherForecastUIState
import org.jetbrains.plugins.template.weatherApp.ui.WeatherIcons
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.*
/**
* A composable function that displays a weather card with Jewel theme.
* The card displays city name, temperature, current time, wind information,
* humidity, and a background icon representing the weather state.
* The card and text color change based on temperature and time of day.
* Displays the weather details in a styled card format. This card presents various weather information
* including the current time, temperature, city name, wind details, humidity, and a 7-day forecast.
* The appearance and content dynamically change based on the given weather state.
*
* @param weatherForecastData The weather data to display
* @param modifier Additional modifier for the card
* @param modifier Modifier to be applied to the card layout.
* @param weatherForecastState The current state of the weather forecast, which dictates the displayed content
* and appearance. It can represent loading, success, error, or empty states.
* @param onReloadWeatherData Callback invoked to reload weather data when the refresh action is triggered. It
* provides the location for which the weather data should be fetched.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun WeatherDetailsCard(
modifier: Modifier = Modifier,
weatherForecastData: WeatherForecastData,
weatherForecastState: WeatherForecastUIState,
onReloadWeatherData: (Location) -> Unit
) {
val currentWeatherForecast = weatherForecastData.currentWeatherForecast
val isNightTime = isNightTime(currentWeatherForecast.date)
val cardColor = getCardColorByTemperature(currentWeatherForecast.temperature, isNightTime)
val textColor = Color.White
val (cardColor, textColor) = when (weatherForecastState) {
is WeatherForecastUIState.Success -> {
val isNightTime = isNightTime(weatherForecastState.weatherForecastData.currentWeatherForecast.date)
val color =
getCardColorByTemperature(
weatherForecastState.weatherForecastData.currentWeatherForecast.temperature,
isNightTime
)
color to Color.White
}
is WeatherForecastUIState.Loading -> WeatherAppColors.mildWeatherColor to Color.White
is WeatherForecastUIState.Error -> WeatherAppColors.hotWeatherColor to Color.White // Brown for errors
is WeatherForecastUIState.Empty -> WeatherAppColors.coolWeatherColor to Color.White
}
VerticallyScrollableContainer(modifier = modifier.safeContentPadding()) {
Box(
@ -69,42 +89,18 @@ fun WeatherDetailsCard(
horizontalArrangement = Arrangement.SpaceBetween
) {
// Current Time
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = ComposeTemplateBundle.message(
"weather.app.time.text",
formatDateTime(currentWeatherForecast.date)
),
color = textColor,
fontSize = JewelTheme.defaultTextStyle.fontSize,
fontWeight = FontWeight.Bold
)
TimeDisplay(weatherForecastState, textColor)
/**
* Jewel org.jetbrains.jewel.ui.component.ActionButton
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Button.kt
*/
ActionButton(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.Transparent)
.padding(8.dp),
tooltip = {
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text("Refresh weather data")
tooltip = { Text("Refresh weather data") },
onClick = {
weatherForecastState.getLocationOrNull()?.let { onReloadWeatherData(it) }
},
onClick = { onReloadWeatherData(weatherForecastData.location) },
) {
/**
* Jewel org.jetbrains.jewel.ui.component.Icon
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt
*/
Icon(
key = AllIconsKeys.Actions.Refresh,
contentDescription = "Refresh",
@ -121,98 +117,33 @@ fun WeatherDetailsCard(
horizontalAlignment = Alignment.CenterHorizontally
) {
/**
* Jewel org.jetbrains.jewel.ui.component.Icon
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt
*/
Icon(
key = when {
isNightTime -> currentWeatherForecast.weatherType.nightIconKey
else -> currentWeatherForecast.weatherType.dayIconKey
},
contentDescription = currentWeatherForecast.weatherType.label,
hint = EmbeddedToInlineCssSvgTransformerHint
)
WeatherIconDisplay(weatherForecastState)
Spacer(modifier = Modifier.height(8.dp))
// Temperature (emphasized)
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = ComposeTemplateBundle.message(
"weather.app.temperature.text",
currentWeatherForecast.temperature.toInt()
),
color = textColor,
fontSize = 32.sp,
fontWeight = FontWeight.ExtraBold
)
TemperatureDisplay(weatherForecastState, textColor)
Spacer(modifier = Modifier.height(8.dp))
// City name
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = weatherForecastData.location.label,
color = textColor,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
CityNameDisplay(weatherForecastState, textColor)
}
Spacer(modifier = Modifier.height(16.dp))
// Wind and humidity info
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Wind info
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = ComposeTemplateBundle.message(
"weather.app.wind.direction.text",
currentWeatherForecast.windSpeed.toInt(),
currentWeatherForecast.windDirection.label
),
color = textColor,
fontSize = 18.sp,
)
// Humidity info
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = ComposeTemplateBundle.message(
"weather.app.humidity.text",
currentWeatherForecast.humidity
),
color = textColor,
fontSize = 18.sp,
)
}
WeatherDetailsRow(Modifier.fillMaxWidth(), weatherForecastState, textColor)
Spacer(modifier = Modifier.height(24.dp))
// 7-day forecast section
SevenDaysForecastWidget(
weatherForecastData,
weatherForecastState,
textColor,
Modifier
.fillMaxWidth()
.wrapContentHeight()
.align(Alignment.CenterHorizontally),
textColor
.align(Alignment.CenterHorizontally)
)
}
}
@ -227,10 +158,6 @@ private fun SevenDaysForecastWidget(
) {
if (weatherForecastData.dailyForecasts.isNotEmpty()) {
Column(modifier) {
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = ComposeTemplateBundle.message("weather.app.7days.forecast.title.text"),
color = textColor,
@ -242,10 +169,7 @@ private fun SevenDaysForecastWidget(
Spacer(modifier = Modifier.height(8.dp))
val scrollState = rememberLazyListState()
/**
* Jewel org.jetbrains.jewel.ui.component.HorizontallyScrollableContainer
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ScrollableContainer.kt
*/
HorizontallyScrollableContainer(
modifier = Modifier.fillMaxWidth().safeContentPadding(),
scrollState = scrollState,
@ -292,10 +216,6 @@ private fun DayForecastItem(
.padding(8.dp)
) {
// Day name
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = dayName,
color = textColor,
@ -304,10 +224,6 @@ private fun DayForecastItem(
textAlign = TextAlign.Center
)
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = date,
color = textColor,
@ -319,10 +235,6 @@ private fun DayForecastItem(
Spacer(modifier = Modifier.height(8.dp))
// Weather icon
/**
* Jewel org.jetbrains.jewel.ui.component.Icon
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt
*/
Icon(
key = if (isNightTime(forecast.date)) forecast.weatherType.nightIconKey else forecast.weatherType.dayIconKey,
contentDescription = forecast.weatherType.label,
@ -333,10 +245,6 @@ private fun DayForecastItem(
Spacer(modifier = Modifier.height(8.dp))
// Temperature
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = ComposeTemplateBundle.message(
"weather.app.temperature.text",
@ -350,10 +258,6 @@ private fun DayForecastItem(
Spacer(modifier = Modifier.height(8.dp))
// Humidity
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = ComposeTemplateBundle.message(
"weather.app.humidity.text",
@ -366,10 +270,6 @@ private fun DayForecastItem(
Spacer(modifier = Modifier.height(8.dp))
// Wind direction
/**
* Jewel org.jetbrains.jewel.ui.component.Text
* @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt
*/
Text(
text = ComposeTemplateBundle.message(
"weather.app.wind.direction.text",
@ -382,6 +282,327 @@ private fun DayForecastItem(
}
}
/**
* Time display component with loading state
*/
@Composable
private fun TimeDisplay(
weatherState: WeatherForecastUIState,
textColor: Color
) {
val text = when (weatherState) {
is WeatherForecastUIState.Success -> formatDateTime(weatherState.weatherForecastData.currentWeatherForecast.date)
else -> "-"
}.let { time -> ComposeTemplateBundle.message("weather.app.time.text", time) }
PulsingText(
text,
weatherState.isLoading,
color = textColor,
fontSize = JewelTheme.defaultTextStyle.fontSize,
fontWeight = FontWeight.Bold
)
}
/**
* Weather icon that shows spinning progress during loading
*/
@Composable
private fun WeatherIconDisplay(
weatherState: WeatherForecastUIState,
modifier: Modifier = Modifier
) {
when (weatherState) {
is WeatherForecastUIState.Loading -> {
val infiniteTransition = rememberInfiniteTransition(label = "rotating_weather_icon")
val rotation = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 3000, // 3 seconds per rotation
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
),
label = "icon_rotation"
).value
Icon(
key = WeatherIcons.dayClear,
contentDescription = null,
modifier = modifier.rotate(rotation),
hint = EmbeddedToInlineCssSvgTransformerHint
)
}
is WeatherForecastUIState.Success -> {
val currentForecast = weatherState.weatherForecastData.currentWeatherForecast
val isNightTime = isNightTime(currentForecast.date)
Icon(
key = if (isNightTime) {
currentForecast.weatherType.nightIconKey
} else {
currentForecast.weatherType.dayIconKey
},
contentDescription = currentForecast.weatherType.label,
hint = EmbeddedToInlineCssSvgTransformerHint,
modifier = modifier
)
}
is WeatherForecastUIState.Error -> {
Icon(
key = AllIconsKeys.General.Warning,
contentDescription = "Weather data error",
tint = Color.White.copy(alpha = 0.8f),
modifier = modifier
)
}
is WeatherForecastUIState.Empty -> {
Icon(
key = AllIconsKeys.Actions.Find,
contentDescription = "No location selected",
tint = Color.White.copy(alpha = 0.6f),
modifier = modifier
)
}
}
}
/**
* Temperature display with loading animation
*/
@Composable
private fun TemperatureDisplay(
weatherState: WeatherForecastUIState,
textColor: Color
) {
val temperatureText = when (weatherState) {
is WeatherForecastUIState.Success -> ComposeTemplateBundle.message(
"weather.app.temperature.text",
weatherState.weatherForecastData.currentWeatherForecast.temperature.toInt()
)
is WeatherForecastUIState.Loading -> "--°"
is WeatherForecastUIState.Error -> "N/A°"
is WeatherForecastUIState.Empty -> "--°"
}
PulsingText(
text = temperatureText,
isLoading = weatherState.isLoading,
color = textColor,
fontSize = 32.sp,
fontWeight = FontWeight.ExtraBold
)
}
/**
* City name display that shows "Loading..." during loading state
*/
@Composable
private fun CityNameDisplay(
weatherState: WeatherForecastUIState,
textColor: Color
) {
val loadingText = when (weatherState) {
is WeatherForecastUIState.Success -> weatherState.weatherForecastData.location.label
is WeatherForecastUIState.Loading -> weatherState.location.label
is WeatherForecastUIState.Error -> "weatherState.location.label} - Error"
is WeatherForecastUIState.Empty -> "Select a location"
}
PulsingText(
text = loadingText,
isLoading = weatherState.isLoading,
color = textColor,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
}
/**
* Composable function to display a row of weather details including wind and humidity information.
*
* @param modifier A [Modifier] that can be used to customize the layout or add behavior to the composable.
* @param weatherState The current state of the weather forecast, represented by [WeatherForecastUIState].
* This determines the display of wind and humidity information based on state.
* @param textColor The color to be applied to the text of the weather details.
*/
@Composable
private fun WeatherDetailsRow(
modifier: Modifier,
weatherState: WeatherForecastUIState,
textColor: Color
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Wind info
val windText = when (weatherState) {
is WeatherForecastUIState.Success -> {
val forecast = weatherState.weatherForecastData.currentWeatherForecast
ComposeTemplateBundle.message(
"weather.app.wind.direction.text",
forecast.windSpeed.toInt(),
forecast.windDirection.label
)
}
is WeatherForecastUIState.Loading -> "Wind: --"
is WeatherForecastUIState.Error -> "Wind: N/A"
is WeatherForecastUIState.Empty -> "Wind: --"
}
PulsingText(
windText,
weatherState.isLoading,
color = textColor,
fontSize = 18.sp
)
// Humidity info
val humidityText = when (weatherState) {
is WeatherForecastUIState.Success -> ComposeTemplateBundle.message(
"weather.app.humidity.text",
weatherState.weatherForecastData.currentWeatherForecast.humidity
)
is WeatherForecastUIState.Loading -> "Humidity: -- %"
is WeatherForecastUIState.Error -> "Humidity: N/A"
is WeatherForecastUIState.Empty -> "Humidity: -- %"
}
PulsingText(
text = humidityText,
weatherState.isLoading,
color = textColor,
fontSize = 18.sp
)
}
}
/**
* Forecast section that shows skeleton during loading
*/
@Composable
private fun SevenDaysForecastWidget(
weatherState: WeatherForecastUIState,
textColor: Color,
modifier: Modifier
) {
when (weatherState) {
is WeatherForecastUIState.Success -> {
if (weatherState.weatherForecastData.dailyForecasts.isNotEmpty()) {
SevenDaysForecastWidget(
weatherState.weatherForecastData,
modifier,
textColor
)
}
}
is WeatherForecastUIState.Loading -> LoadingForecastSkeleton(textColor)
is WeatherForecastUIState.Error -> ErrorForecastMessage(textColor)
is WeatherForecastUIState.Empty -> EmptyForecastMessage(textColor)
}
}
/**
* Loading skeleton for forecast section
*/
@Composable
private fun LoadingForecastSkeleton(textColor: Color) {
Column {
Text(
text = ComposeTemplateBundle.message("weather.app.7days.forecast.title.text"),
color = textColor.copy(alpha = 0.7f),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
val scrollState = rememberLazyListState()
HorizontallyScrollableContainer(
modifier = Modifier.fillMaxWidth().safeContentPadding(),
scrollState = scrollState,
) {
LazyRow(
state = scrollState,
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(count = 7) { LoadingForecastItem(textColor) }
}
}
}
}
@Composable
private fun LoadingForecastItem(textColor: Color) {
val infiniteTransition = rememberInfiniteTransition()
val alpha = infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 0.6f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
)
).value
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(120.dp)
.border(1.dp, textColor.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
.padding(8.dp)
) {
Text("--", color = textColor.copy(alpha = alpha), fontSize = 14.sp)
Text("", color = textColor.copy(alpha = alpha), fontSize = 14.sp)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.size(48.dp)
.background(textColor.copy(alpha = alpha), RoundedCornerShape(4.dp))
)
Spacer(modifier = Modifier.height(8.dp))
Text("--°", color = textColor.copy(alpha = alpha), fontSize = 16.sp)
Text("", color = textColor.copy(alpha = alpha), fontSize = 14.sp)
}
}
@Composable
private fun ErrorForecastMessage(textColor: Color) {
Text(
text = "Forecast unavailable",
color = textColor.copy(alpha = 0.7f),
fontSize = 16.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
@Composable
private fun EmptyForecastMessage(textColor: Color) {
Text(
text = "Select a location to view forecast",
color = textColor.copy(alpha = 0.7f),
fontSize = 16.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
/**
* Returns the day name for a given date relative to the current date.
* Returns "Today" for the current date, "Tomorrow" for the next day,