diff --git a/build.gradle.kts b/build.gradle.kts index b83f98b..b9e3bf8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,11 +25,16 @@ repositories { intellijPlatform { defaultRepositories() } + // Needed for tests + google() } dependencies { testImplementation(libs.junit) testImplementation(libs.opentest4j) + testImplementation(libs.hamcrest) + testImplementation(libs.composeuitest) + testImplementation(libs.jewelstandalone) intellijPlatform { create(providers.gradleProperty("platformType"), providers.gradleProperty("platformVersion")) diff --git a/gradle.properties b/gradle.properties index 998508e..61ff860 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ pluginSinceBuild = 251 # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformType = IC -platformVersion = 2025.1.1 +platformVersion = 2025.1.4.1 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 037d782..51238e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,15 +2,22 @@ # libraries junit = "4.13.2" opentest4j = "1.3.0" +hamcrest = "2.2" +# Has to be in sync with IntelliJ Platform +composeuitest="1.8.0-alpha04" +jewelstandalone="0.29.0-251.27828" # plugins changelog = "2.2.1" -intelliJPlatform = "2.5.0" +intelliJPlatform = "2.7.0" kotlin = "2.1.20" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } +hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" } +composeuitest = { group = "org.jetbrains.compose.ui", name ="ui-test-junit4-desktop", version.ref="composeuitest" } +jewelstandalone = { group = "org.jetbrains.jewel", name ="jewel-int-ui-standalone", version.ref="jewelstandalone" } [plugins] changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } diff --git a/src/main/kotlin/org/jetbrains/plugins/template/CoroutineScopeHolder.kt b/src/main/kotlin/org/jetbrains/plugins/template/CoroutineScopeHolder.kt new file mode 100644 index 0000000..d178e89 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/CoroutineScopeHolder.kt @@ -0,0 +1,31 @@ +package org.jetbrains.plugins.template + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.Service.Level +import com.intellij.platform.util.coroutines.childScope +import kotlinx.coroutines.CoroutineScope + + +/** + * A service-level class that provides and manages coroutine scopes for a given project. + * + * @constructor Initializes the [CoroutineScopeHolder] with a project-wide coroutine scope. + * @param projectWideCoroutineScope A [CoroutineScope] defining the lifecycle of project-wide coroutines. + */ +@Service(Level.PROJECT) +class CoroutineScopeHolder(private val projectWideCoroutineScope: CoroutineScope) { + /** + * Creates a new coroutine scope as a child of the project-wide coroutine scope with the specified name. + * + * @param name The name for the newly created coroutine scope. + * @return a scope with a [Job] which parent is the [Job] of [projectWideCoroutineScope] scope. + * + * The returned scope can be completed only by cancellation. + * [projectWideCoroutineScope] scope will cancel the returned scope when canceled. + * If the child scope has a narrower lifecycle than [projectWideCoroutineScope] scope, + * then it should be canceled explicitly when not needed, + * otherwise, it will continue to live in the Job hierarchy until termination of the [CoroutineScopeHolder] service. + */ + @Suppress("UnstableApiUsage") + fun createScope(name: String): CoroutineScope = projectWideCoroutineScope.childScope(name) +} diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/PulsingText.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/PulsingText.kt new file mode 100644 index 0000000..3d8e8ff --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/PulsingText.kt @@ -0,0 +1,46 @@ +package org.jetbrains.plugins.template.components + +import androidx.compose.animation.core.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun PulsingText( + text: String, + isLoading: Boolean, + modifier: Modifier = Modifier, + color: Color = Color.White, + fontSize: TextUnit = JewelTheme.defaultTextStyle.fontSize, + fontWeight: FontWeight? = JewelTheme.defaultTextStyle.fontWeight +) { + val alpha = if (isLoading) { + val infiniteTransition = rememberInfiniteTransition(label = "pulsing_text") + infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1000, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "text_alpha" + ).value + } else { + 1f + } + + Text( + text = text, + color = color.copy(alpha = alpha), + fontSize = fontSize, + fontWeight = fontWeight, + modifier = modifier + ) +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt new file mode 100644 index 0000000..3fe49b8 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -0,0 +1,310 @@ +package org.jetbrains.plugins.template.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.HoverInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.PopupMenu +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.icons.AllIconsKeys +import org.jetbrains.plugins.template.ComposeTemplateBundle +import org.jetbrains.plugins.template.weatherApp.model.PreviewableItem +import org.jetbrains.plugins.template.weatherApp.model.Searchable +import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider + +@OptIn(ExperimentalJewelApi::class) +@Composable +fun SearchBarWithAutoCompletion( + modifier: Modifier = Modifier, + searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, + textFieldState: TextFieldState, + searchFieldPlaceholder: String = "Type a place name...", + onClear: () -> Unit = {}, + onSelectCompletion: (T) -> Unit = {}, +) where T : Searchable, T : PreviewableItem { + val focusRequester = remember { FocusRequester() } + + val popupController = remember { + CompletionPopupController(searchAutoCompletionItemProvider) { completionItem -> + textFieldState.setTextAndPlaceCursorAtEnd(completionItem.item.label) + onSelectCompletion(completionItem.item) + } + } + val isInputFieldEmpty by remember { derivedStateOf { textFieldState.text.isBlank() } } + + LaunchedEffect(Unit) { + snapshotFlow { textFieldState.text.toString() } + .distinctUntilChanged() + .collect { searchTerm -> + if (searchTerm.isEmpty()) { + onClear() + } + + popupController.onQueryChanged(searchTerm) + } + } + + Box( + modifier = modifier + .padding(8.dp) + ) { + var textFieldWidth by remember { mutableIntStateOf(-1) } + TextField( + state = textFieldState, + modifier = Modifier + .onSizeChanged { coordinates -> textFieldWidth = coordinates.width } + .fillMaxWidth() + .handlePopupCompletionKeyEvents(popupController) + .focusRequester(focusRequester), + placeholder = { Text(searchFieldPlaceholder) }, + leadingIcon = { + Icon(AllIconsKeys.Actions.Find, contentDescription = null, Modifier.padding(end = 8.dp)) + }, + trailingIcon = { + if (!isInputFieldEmpty) { + CloseIconButton { + textFieldState.setTextAndPlaceCursorAtEnd("") + } + } + }, + ) + + if (popupController.isVisible) { + PopupMenu( + onDismissRequest = { + popupController.reset() + true + }, + horizontalAlignment = Alignment.Start, + modifier = Modifier + // Aligns PopupMenu with TextField + .width(with(LocalDensity.current) { textFieldWidth.toDp() }), + popupProperties = PopupProperties(focusable = false), + ) { + popupController.completionItems.forEach { completionItem -> + selectableItem( + completionItem.isSelected, + onClick = { + popupController.onItemClicked(completionItem) + textFieldState.setTextAndPlaceCursorAtEnd(completionItem.item.label) + }, + ) { + Text(completionItem.item.label) + } + } + } + } + } +} + +@Composable +fun CloseIconButton(onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + var hovered by remember { mutableStateOf(false) } + + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { + when (it) { + is HoverInteraction.Enter -> hovered = true + is HoverInteraction.Exit -> hovered = false + } + } + } + + Icon( + key = if (hovered) AllIconsKeys.Actions.CloseHovered else AllIconsKeys.Actions.Close, + contentDescription = ComposeTemplateBundle.message("weather.app.clear.button.content.description"), + modifier = Modifier + .pointerHoverIcon(PointerIcon.Default) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + ) { onClick() }, + ) +} + + +internal data class CompletionItem( + val item: T, + val isSelected: Boolean, +) + +internal class CompletionPopupController( + private val itemsProvider: SearchAutoCompletionItemProvider, + private val onSelectCompletion: (CompletionItem) -> Unit = {}, +) { + private var selectedItemIndex by mutableIntStateOf(-1) + + /** + * Ensures a popup is not shown when the user autocompletes an item. + * Suppresses making popup once onQueryChanged is called after text to TextField is set after autocompletion. + */ + private var skipPopupShowing by mutableStateOf(false) + + private val _filteredCompletionItems = mutableStateListOf>() + + val completionItems: List> get() = _filteredCompletionItems + + val selectedItem: CompletionItem + get() = _filteredCompletionItems[selectedItemIndex] + + var isVisible by mutableStateOf(false) + private set + + fun onSelectionMovedDown() { + moveSelectionTo(normalizeIndex(selectedItemIndex + 1)) + } + + fun onSelectionMovedUp() { + moveSelectionTo(normalizeIndex(selectedItemIndex - 1)) + } + + fun onQueryChanged(searchTerm: String) { + if (skipPopupShowing) { + skipPopupShowing = false + return + } + + if (searchTerm.isEmpty()) { + hidePopup() + + return + } + + val newItems = itemsProvider.provideSearchableItems(searchTerm) + .map { CompletionItem(it, false) } + + updateFilteredItems(newItems) + + moveSelectionToFirstItem() + + if (completionItems.isNotEmpty()) { + showPopup() + } else { + hidePopup() + } + } + + private fun showPopup() { + isVisible = true + } + + private fun hidePopup() { + isVisible = false + } + + fun reset() { + hidePopup() + moveSelectionToFirstItem() + clearFilteredItems() + } + + fun onItemClicked(clickedItem: CompletionItem) { + doCompleteSelection(clickedItem) + } + + fun onSelectionConfirmed() { + doCompleteSelection(this.selectedItem) + } + + private fun doCompleteSelection(selectedItem: CompletionItem) { + if (!isVisible) return + + skipPopupShowing = true + + reset() + + onSelectCompletion(selectedItem) + } + + private fun updateFilteredItems(newItems: List>) { + // TODO Can be done in a more efficient way + clearFilteredItems() + _filteredCompletionItems.addAll(newItems) + } + + private fun clearFilteredItems() { + _filteredCompletionItems.clear() + } + + private fun moveSelectionToFirstItem() { + moveSelectionTo(0) + } + + private fun moveSelectionTo(index: Int) { + if (index == selectedItemIndex) return + + // Deselect previous item + val previousIndex = selectedItemIndex + if (previousIndex in _filteredCompletionItems.indices) { + _filteredCompletionItems[previousIndex] = _filteredCompletionItems[previousIndex].copy(isSelected = false) + } + + // Select a new item + if (index in _filteredCompletionItems.indices) { + _filteredCompletionItems[index] = _filteredCompletionItems[index].copy(isSelected = true) + } + + selectedItemIndex = index + } + + private fun normalizeIndex(index: Int) = index.coerceIn(0..completionItems.lastIndex) +} + +/** + * Handles navigation keyboard key events for the completion popup. + */ +private fun Modifier.handlePopupCompletionKeyEvents( + popupController: CompletionPopupController +): Modifier { + return onPreviewKeyEvent { keyEvent -> + if (keyEvent.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + + return@onPreviewKeyEvent when (keyEvent.key) { + Key.Tab, Key.Enter, Key.NumPadEnter -> { + popupController.onSelectionConfirmed() + true + } + + Key.DirectionUp -> { + popupController.onSelectionMovedUp() + true + } + + Key.DirectionDown -> { + popupController.onSelectionMovedDown() + true + } + + Key.Escape -> { + popupController.reset() + true + } + + else -> false + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/services/MyProjectService.kt b/src/main/kotlin/org/jetbrains/plugins/template/services/MyProjectService.kt deleted file mode 100644 index 44a57d3..0000000 --- a/src/main/kotlin/org/jetbrains/plugins/template/services/MyProjectService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.jetbrains.plugins.template.services - -import com.intellij.openapi.components.Service - -@Service -class MyProjectService() { - fun getRandomNumber() = (1..100).random() -} diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt new file mode 100644 index 0000000..8de6428 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt @@ -0,0 +1,54 @@ +package org.jetbrains.plugins.template.toolWindow + +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import kotlinx.coroutines.Dispatchers +import org.jetbrains.jewel.bridge.addComposeTab +import org.jetbrains.plugins.template.CoroutineScopeHolder +import org.jetbrains.plugins.template.ui.ChatAppSample +import org.jetbrains.plugins.template.weatherApp.model.Location +import org.jetbrains.plugins.template.weatherApp.services.LocationsProvider +import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppViewModel +import org.jetbrains.plugins.template.weatherApp.services.WeatherForecastService +import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppSample + +class ComposeSamplesToolWindowFactory : ToolWindowFactory, DumbAware { + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val coroutineScopeHolder = project.service() + + toolWindow.addComposeTab("Weather App") { + val locationProviderApi = remember { service() } + val viewModel = remember { + val weatherForecastServiceApi = WeatherForecastService(Dispatchers.IO) + WeatherAppViewModel( + listOf(Location("Munich", "Germany")), + coroutineScopeHolder + .createScope(WeatherAppViewModel::class.java.simpleName), + weatherForecastServiceApi + ) + } + + DisposableEffect(Unit) { + viewModel.onReloadWeatherForecast() + + onDispose { viewModel.dispose() } + } + + WeatherAppSample( + viewModel, + viewModel, + locationProviderApi + ) + } + + toolWindow.addComposeTab("Chat App") { ChatAppSample() } + } + + override fun shouldBeAvailable(project: Project) = true +} diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt deleted file mode 100644 index e1ade10..0000000 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.jetbrains.plugins.template.toolWindow - -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.intellij.openapi.components.service -import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.project.Project -import com.intellij.openapi.wm.ToolWindow -import com.intellij.openapi.wm.ToolWindowFactory -import com.intellij.ui.content.ContentFactory -import org.jetbrains.jewel.bridge.JewelComposePanel -import org.jetbrains.jewel.ui.component.DefaultButton -import org.jetbrains.jewel.ui.component.Text -import org.jetbrains.plugins.template.ComposeTemplateBundle -import org.jetbrains.plugins.template.services.MyProjectService - -class MyToolWindowFactory : ToolWindowFactory, DumbAware { - - override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val myToolWindow = MyToolWindow() - val content = ContentFactory.getInstance().createContent(myToolWindow.getContent(), null, false) - toolWindow.contentManager.addContent(content) - } - - override fun shouldBeAvailable(project: Project) = true - - class MyToolWindow() { - private val service = service() - - fun getContent() = JewelComposePanel { - Column(Modifier.fillMaxWidth().padding(16.dp)) { - var param by remember { mutableStateOf("?") } - Text(ComposeTemplateBundle.message("randomLabel", param)) - Spacer(Modifier.height(8.dp)) - DefaultButton(onClick = { - param = service.getRandomNumber().toString() - }) { - Text(ComposeTemplateBundle.message("shuffle")) - } - } - } - } -} diff --git a/src/main/kotlin/org/jetbrains/plugins/template/ui/ChatAppSample.kt b/src/main/kotlin/org/jetbrains/plugins/template/ui/ChatAppSample.kt new file mode 100644 index 0000000..5df65db --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/ui/ChatAppSample.kt @@ -0,0 +1,23 @@ +package org.jetbrains.plugins.template.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun ChatAppSample() { + Column( + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.Center + ) { + Text("Not yet implemented.", style = JewelTheme.defaultTextStyle) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/WeatherAppColors.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/WeatherAppColors.kt new file mode 100644 index 0000000..4346803 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/WeatherAppColors.kt @@ -0,0 +1,12 @@ +package org.jetbrains.plugins.template.weatherApp + +import androidx.compose.ui.graphics.Color + +object WeatherAppColors { + val nightWeatherColor = Color(0xFF1A237E) + val coldWeatherColor = Color(0xFF3F51B5) + val coolWeatherColor = Color(0xFF5E35B1) + val mildWeatherColor = Color(0xFF039BE5) + val warmWeatherColor = Color(0xFFFF9800) + val hotWeatherColor = Color(0xFFE91E63) +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt new file mode 100644 index 0000000..6216bb7 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt @@ -0,0 +1,43 @@ +package org.jetbrains.plugins.template.weatherApp.model + +import androidx.compose.runtime.Immutable + +/** + * Represents a location with a name and associated country. + * + * @property name The name of the location. + * @property country The associated country of the location. + * @property label A textual representation of the location. + */ +@Immutable +data class Location(val name: String, val country: String) : PreviewableItem, Searchable { + + override val label: String + get() { + if (country.isBlank()) { + return name.takeIf { it.isNotBlank() } ?: "-" + } + + if (name.isBlank()) { + return country.takeIf { it.isNotBlank() } ?: "-" + } + + return "$name, $country" + } + + override fun matches(query: String): Boolean { + val applicableCandidates = listOf( + label, + name, + country, + name.split(" ").map { it.first() }.joinToString(""), + "${name.first()}${country.first()}", + "${country.first()}${name.first()}" + ) + + return applicableCandidates.any { it.contains(query, ignoreCase = true) } + } +} + +@Immutable +data class SelectableLocation(val location: Location, val isSelected: Boolean) \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/PreviewableItem.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/PreviewableItem.kt new file mode 100644 index 0000000..05a44e4 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/PreviewableItem.kt @@ -0,0 +1,10 @@ +package org.jetbrains.plugins.template.weatherApp.model + +/** + * Represents an item that can be previewed with a label. + * + * @property label A textual representation of the item, often used for display purposes. + */ +interface PreviewableItem { + val label: String +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Searchable.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Searchable.kt new file mode 100644 index 0000000..78b088b --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Searchable.kt @@ -0,0 +1,8 @@ +package org.jetbrains.plugins.template.weatherApp.model + +/** + * Represents an entity that can be filtered by a search query. + */ +interface Searchable { + fun matches(query: String): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt new file mode 100644 index 0000000..e40ac5a --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt @@ -0,0 +1,88 @@ +package org.jetbrains.plugins.template.weatherApp.model + +import org.jetbrains.jewel.ui.icon.IconKey +import org.jetbrains.plugins.template.weatherApp.ui.WeatherIcons +import java.time.LocalDateTime + +/** + * Data class representing a daily weather forecast. + */ +data class DailyForecast( + val date: LocalDateTime, + val temperature: Float, + val weatherType: WeatherType, + val humidity: Int, + val windSpeed: Float, + val windDirection: WindDirection +) + +/** + * Data class representing weather information to be displayed in the Weather Card. + */ +data class WeatherForecastData( + val location: Location, + val currentWeatherForecast: DailyForecast, + val dailyForecasts: List = emptyList() +) { + companion object Companion { + val EMPTY: WeatherForecastData = WeatherForecastData( + Location("", ""), + DailyForecast( + LocalDateTime.now(), + 0f, + WeatherType.CLEAR, + 0, + 0f, + WindDirection.NORTH, + ), + emptyList() + ) + } +} + +/** + * Enum representing different weather types. + */ +enum class WeatherType(val label: String, val dayIconKey: IconKey, val nightIconKey: IconKey) { + CLEAR("Sunny", WeatherIcons.dayClear, WeatherIcons.nightHalfMoonClear), + CLOUDY("Cloudy", dayIconKey = WeatherIcons.cloudy, nightIconKey = WeatherIcons.cloudy), + PARTLY_CLOUDY( + "Partly Cloudy", + dayIconKey = WeatherIcons.dayPartialCloud, + nightIconKey = WeatherIcons.nightHalfMoonPartialCloud + ), + RAINY("Rainy", dayIconKey = WeatherIcons.dayRain, nightIconKey = WeatherIcons.nightHalfMoonRain), + RAINY_AND_THUNDER( + "Rainy and Thunder", + dayIconKey = WeatherIcons.dayRainThunder, + nightIconKey = WeatherIcons.nightHalfMoonRainThunder + ), + THUNDER("Thunder", dayIconKey = WeatherIcons.thunder, nightIconKey = WeatherIcons.thunder), + + SNOWY("Snowy", dayIconKey = WeatherIcons.daySnow, nightIconKey = WeatherIcons.nightHalfMoonSnow), + TORNADO("Stormy", dayIconKey = WeatherIcons.tornado, nightIconKey = WeatherIcons.tornado), + FOG("Fog", dayIconKey = WeatherIcons.fog, nightIconKey = WeatherIcons.fog), + MIST("Mist", dayIconKey = WeatherIcons.mist, nightIconKey = WeatherIcons.mist); + + companion object { + fun random(): WeatherType = entries.toTypedArray().random() + } +} + +/** + * Enum representing wind directions. + */ +enum class WindDirection(val label: String) { + NORTH("↑"), + NORTH_EAST("↗"), + EAST("→"), + SOUTH_EAST("↘"), + SOUTH("↓"), + SOUTH_WEST("↙"), + WEST("←"), + NORTH_WEST("↖"); + + companion object { + fun random(): WindDirection = entries.toTypedArray().random() + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsProvider.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsProvider.kt new file mode 100644 index 0000000..699977f --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsProvider.kt @@ -0,0 +1,32 @@ +package org.jetbrains.plugins.template.weatherApp.services + +import com.intellij.openapi.components.Service +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.jetbrains.plugins.template.weatherApp.model.Location + +@Service +class LocationsProvider : SearchAutoCompletionItemProvider { + private val locationStateFlow = MutableStateFlow( + listOf( + Location("Munich", "Germany"), + Location("Belgrade", "Serbia"), + Location("Berlin", "Germany"), + Location("Rome", "Italy"), + Location("Paris", "France"), + Location("Sydney", "Australia"), + Location("Moscow", "Russia"), + Location("Tokyo", "Japan"), + Location("New York", "USA"), + ) + ) + + fun provideLocations(): StateFlow> = locationStateFlow.asStateFlow() + + override fun provideSearchableItems(searchTerm: String): List { + return locationStateFlow + .value + .filter { it.matches(searchTerm) } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/SearchAutoCompletionItemProvider.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/SearchAutoCompletionItemProvider.kt new file mode 100644 index 0000000..2f47622 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/SearchAutoCompletionItemProvider.kt @@ -0,0 +1,16 @@ +package org.jetbrains.plugins.template.weatherApp.services + +import org.jetbrains.plugins.template.weatherApp.model.Searchable + +/** + * Defines a provider interface for auto-completion items based on a search query. + * + * Implementations of this interface are responsible for supplying a filtered list of + * items that match the given search term. These items must conform to the [Searchable] interface, + * which ensures that they can be evaluated against the query. + * + * @param T The type of items provided by this interface, which must extend [Searchable]. + */ +interface SearchAutoCompletionItemProvider { + fun provideSearchableItems(searchTerm: String): List +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt new file mode 100644 index 0000000..3d7ff70 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt @@ -0,0 +1,100 @@ +package org.jetbrains.plugins.template.weatherApp.services + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.jetbrains.plugins.template.weatherApp.model.* +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import kotlin.coroutines.CoroutineContext +import kotlin.random.Random + + +interface WeatherForecastServiceApi { + /** + * Suspending function that returns Result. + * This allows callers to handle success/failure explicitly. + * + * @param location The location to get weather data for + * @return Result containing WeatherForecastData on success or exception on failure + */ + suspend fun loadWeatherForecastFor(location: Location): Result +} + +class WeatherForecastService( + private val networkCoroutineContext: CoroutineContext = Dispatchers.IO, +) : WeatherForecastServiceApi { + + /** + * Function that returns a weather forecast for provided [location] param. + * + * @param location The location to get weather data for + * @return Result containing WeatherForecastData on success or exception on failure + */ + override suspend fun loadWeatherForecastFor(location: Location): Result { + return withContext(networkCoroutineContext) { + runCatching { getWeatherData(location) } + } + } + + /** + * Provides mock weather data for demonstration purposes. + * In a real application, this would fetch data from a weather API. + */ + private suspend fun getWeatherData(location: Location): WeatherForecastData { + val currentTime = LocalDateTime.of(LocalDate.now(), getRandomTime()) + + yield() // Check cancellation + + // Generate 7-day forecast data + val dailyForecasts = generateDailyForecasts(currentTime) + + // Simulates a network request and stops the execution in case the coroutine + // that launched the getWeatherData task is canceled + delay(3000) + + return WeatherForecastData( + location = location, + dailyForecasts.first(), + dailyForecasts = dailyForecasts.drop(1) + ) + } + + /** + * Generates mock daily forecasts for 7 days starting from the given date. + */ + private fun generateDailyForecasts(startDate: LocalDateTime): List { + val forecasts = mutableListOf() + + for (i in 0 until 8) { + val forecastDate = startDate.plusDays(i.toLong()) + val temperature = (-10..40).random().toFloat() + val windSpeed = (0..30).random().toFloat() + val humidity = (30..90).random() + val weatherType = WeatherType.random() + val windDirection = WindDirection.random() + + forecasts.add( + DailyForecast( + date = forecastDate, + temperature = temperature, + weatherType = weatherType, + humidity = humidity, + windSpeed = windSpeed, + windDirection = windDirection + ) + ) + } + + return forecasts + } + + private fun getRandomTime(): LocalTime { + val hour = Random.nextInt(0, 24) + val minute = Random.nextInt(0, 60) + val second = Random.nextInt(0, 60) + return LocalTime.of(hour, minute, second) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt new file mode 100644 index 0000000..43b6a6d --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -0,0 +1,200 @@ +package org.jetbrains.plugins.template.weatherApp.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn +import org.jetbrains.jewel.foundation.lazy.SelectionMode +import org.jetbrains.jewel.foundation.lazy.itemsIndexed +import org.jetbrains.jewel.foundation.lazy.rememberSelectableLazyListState +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.icon.IconKey +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.services.* +import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu +import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard + +@Composable +fun WeatherAppSample( + myLocationViewModel: MyLocationsViewModelApi, + weatherViewModelApi: WeatherViewModelApi, + searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider +) { + HorizontalSplitLayout( + first = { + LeftColumn( + myLocationViewModel, + modifier = Modifier + .fillMaxSize() + .padding(start = 8.dp, end = 8.dp) + ) + }, + second = { + RightColumn( + myLocationViewModel, + weatherViewModelApi, + searchAutoCompletionItemProvider, + modifier = Modifier + .fillMaxSize() + .padding(start = 8.dp, end = 8.dp) + ) + }, + modifier = Modifier + .fillMaxSize() + .padding(all = 8.dp), + firstPaneMinWidth = 100.dp, + secondPaneMinWidth = 300.dp, + state = rememberSplitLayoutState(.2f) + ) +} + +@Composable +private fun LeftColumn( + myLocationsViewModelApi: MyLocationsViewModelApi, + modifier: Modifier = Modifier, +) { + Column(modifier) { + GroupHeader( + ComposeTemplateBundle.message("weather.app.my.locations.header.text"), + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(10.dp)) + + MyLocationsListWithEmptyListPlaceholder(Modifier.fillMaxSize(), myLocationsViewModelApi) + } +} + +@Composable +fun MyLocationsListWithEmptyListPlaceholder( + modifier: Modifier = Modifier, + myLocationsViewModelApi: MyLocationsViewModelApi +) { + val myLocationsUIState = + myLocationsViewModelApi.myLocationsUIStateFlow.collectAsState(LocationsUIState.empty()).value + + if (!myLocationsUIState.isEmpty) { + MyLocationList(myLocationsUIState, modifier, myLocationsViewModelApi) + } else { + EmptyListPlaceholder(modifier) + } +} + +@Composable +private fun EmptyListPlaceholder( + modifier: Modifier, + placeholderText: String = ComposeTemplateBundle.message("weather.app.my.locations.empty.list.placeholder.text"), + placeholderIcon: IconKey = AllIconsKeys.Actions.AddList +) { + Column( + modifier = modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + placeholderIcon, + contentDescription = ComposeTemplateBundle.message("weather.app.my.locations.empty.list.placeholder.icon.content.description"), + Modifier.size(32.dp), + tint = Color.White + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = placeholderText, + style = JewelTheme.defaultTextStyle, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun MyLocationList( + myLocationsUIState: LocationsUIState, + modifier: Modifier, + myLocationsViewModelApi: MyLocationsViewModelApi +) { + val listState = rememberSelectableLazyListState() + // JEWEL-938 This will trigger on SelectableLazyColum's `onSelectedIndexesChange` callback + LaunchedEffect(myLocationsUIState) { + var lastActiveItemIndex = -1 + val selectedItemKeys = mutableSetOf() + myLocationsUIState.locations.forEachIndexed { index, location -> + if (index == myLocationsUIState.selectedIndex) { + if (lastActiveItemIndex == -1) { + // Only the first selected item should be active + lastActiveItemIndex = index + } + // Must match the key used in the `items()` call's `key` parameter to ensure correct item identity. + selectedItemKeys.add(location.label) + } + } + // Sets the first selected item as an active item to avoid triggering on click event when user clocks on it + listState.lastActiveItemIndex = lastActiveItemIndex + // Sets keys of selected items + listState.selectedKeys = selectedItemKeys + } + + SelectableLazyColumn( + modifier = modifier, + selectionMode = SelectionMode.Single, + state = listState, + onSelectedIndexesChange = { indices -> + val selectedLocationIndex = indices.firstOrNull() ?: return@SelectableLazyColumn + myLocationsViewModelApi.onLocationSelected(selectedLocationIndex) + }, + ) { + itemsIndexed( + items = myLocationsUIState.locations, + key = { _, item -> item.label }, + ) { index, item -> + + SimpleListItem(text = item.label, isSelected = myLocationsUIState.selectedIndex == index, isActive = isActive) + } + } +} + +@Composable +private fun RightColumn( + myLocationViewModel: MyLocationsViewModelApi, + weatherViewModelApi: WeatherViewModelApi, + searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, + modifier: Modifier = Modifier, +) { + val weatherForecastData = weatherViewModelApi + .weatherForecastUIState + .collectAsState(WeatherForecastUIState.Empty).value + + Column(modifier) { + SearchToolbarMenu( + searchAutoCompletionItemProvider = searchAutoCompletionItemProvider, + confirmButtonText = ComposeTemplateBundle.message("weather.app.search.toolbar.menu.add.button.text"), + onSearchPerformed = { place -> + weatherViewModelApi.onLoadWeatherForecast(place) + }, + onSearchConfirmed = { place -> + myLocationViewModel.onAddLocation(place) + } + ) + + WeatherDetailsCard( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + weatherForecastData + ) { location -> + weatherViewModelApi.onLoadWeatherForecast(location) + } + } +} diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppViewModel.kt new file mode 100644 index 0000000..8f49818 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppViewModel.kt @@ -0,0 +1,326 @@ +package org.jetbrains.plugins.template.weatherApp.ui + +import com.intellij.openapi.Disposable +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +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.WeatherForecastServiceApi + + +/** + * Defines the contract for managing and observing location-related data in the application. + * + * This interface provides methods for adding, deleting, and selecting locations, as well as + * a flow to observe the list of selectable locations. Implementations are expected to handle + * location-related logic and state management. + */ +interface MyLocationsViewModelApi : Disposable { + fun onAddLocation(locationToAdd: Location) + + fun onDeleteLocation(locationToDelete: Location) + + fun onLocationSelected(selectedLocationIndex: Int) + + val myLocationsUIStateFlow: Flow +} + +/** + * Interface representing a ViewModel for managing weather-related data + * and user interactions. + */ +interface WeatherViewModelApi : Disposable { + val weatherForecastUIState: Flow + + 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 +} + +/** + * UI state object for locations and selection + */ +class LocationsUIState private constructor( + val locations: List, + val selectedIndex: Int +) { + + private constructor(locations: List) : this(locations, if (locations.isEmpty()) -1 else 0) + + init { + if (locations.isEmpty()) { + require(selectedIndex == -1) { + "For an empty list, selected index has to be -1." + } + } else { + require(selectedIndex in locations.indices) { + "Selected index has to be in range from 0 to ${locations.lastIndex}." + } + } + } + + /** + * Get the currently selected location + */ + val selectedLocation: Location? + get() = locations.getOrNull(selectedIndex) + + val isEmpty: Boolean get() = locations.isEmpty() + + /** + * Convert to UI representation with selection state + */ + fun toSelectableLocations(): List { + return locations.mapIndexed { index, location -> + SelectableLocation(location, index == selectedIndex) + } + } + + /** + * Create new state with a location added + */ + fun withLocationAdded(locationToAdd: Location): LocationsUIState { + val existingIndex = locations.indexOf(locationToAdd) + return if (existingIndex >= 0) { + // Location exists, just select it + LocationsUIState(locations = locations, selectedIndex = existingIndex) + } else { + // Add a new location and select it + val newLocations = locations + locationToAdd + LocationsUIState( + locations = newLocations, + selectedIndex = newLocations.lastIndex + ) + } + } + + /** + * Create a new state with a location removed + */ + fun withLocationDeleted(locationToRemove: Location): LocationsUIState { + val indexToDelete = locations.indexOf(locationToRemove) + if (indexToDelete < 0) return this // Location not found + + val newLocations = locations - locationToRemove + + val newSelectedIndex = when { + newLocations.isEmpty() -> -1 + indexToDelete <= selectedIndex -> (selectedIndex - 1).coerceIn(0, newLocations.lastIndex) + else -> selectedIndex // Deleted item after selected, no change + } + + return LocationsUIState( + locations = newLocations, + selectedIndex = newSelectedIndex + ) + } + + /** + * Create new state with different selection + */ + fun withItemAtIndexSelected(newIndex: Int): LocationsUIState { + if (newIndex == selectedIndex) return this + + return LocationsUIState(locations = locations, selectedIndex = newIndex) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocationsUIState + + if (selectedIndex != other.selectedIndex) return false + if (locations != other.locations) return false + + return true + } + + override fun hashCode(): Int { + var result = selectedIndex + result = 31 * result + locations.hashCode() + return result + } + + companion object { + /** + * Initializes a `LocationsUIState` object with the given list of locations. + * The initial selection index is set to `-1` if the list is empty or `0` if it contains locations. + * + * @param locations The list of locations to initialize the state with. + * @return The initialized `LocationsUIState` containing the provided locations and selection state. + */ + fun initial(locations: List): LocationsUIState = LocationsUIState(locations = locations) + + /** + * Creates an empty instance of `LocationsUIState` with no locations and a default selection state. + * + * @return A `LocationsUIState` initialized with an empty list of locations. + */ + fun empty(): LocationsUIState = initial(emptyList()) + } +} + + +/** + * A ViewModel responsible for managing the user's locations and corresponding weather data. + * + * This class coordinates the interaction between the UI, locations, and weather data. It provides + * functionality to add, delete, select locations, and reload weather forecasts. Additionally, it + * supplies observable state flows for the list of selectable locations and the currently selected + * location's weather forecast. + * + * @property myInitialLocations The initial list of user-defined locations. + * @property viewModelScope The coroutine scope in which this ViewModel operates. + * @property weatherService The service responsible for fetching weather forecasts for given locations. + */ +class WeatherAppViewModel( + myInitialLocations: List, + private val viewModelScope: CoroutineScope, + private val weatherService: WeatherForecastServiceApi, +) : MyLocationsViewModelApi, WeatherViewModelApi { + + private var currentWeatherJob: Job? = null + + private val _myLocationsUIStateFlow = MutableStateFlow(LocationsUIState.initial(myInitialLocations)) + + /** + * A state flow representing the current UI state of locations, including the list of locations + * and the selected location index. This is observed by the UI layer to render location data and + * selection state dynamically. + * + * This ensures that the state is safely encapsulated and modifications only occur through + * authorized ViewModel methods. + * + * @see LocationsUIState + */ + override val myLocationsUIStateFlow: Flow = _myLocationsUIStateFlow.asStateFlow() + + private val _weatherState = MutableStateFlow(WeatherForecastUIState.Empty) + + /** + * A flow representing the current UI state of the weather forecast. + * + * 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 weatherForecastUIState: Flow = _weatherState.asStateFlow() + + override fun onAddLocation(locationToAdd: Location) { + val newState = _myLocationsUIStateFlow.value.withLocationAdded(locationToAdd) + updateLocationsUIStateWith(newState) + + + if (_weatherState.value.getLocationOrNull() != locationToAdd) { + onReloadWeatherForecast() + } + } + + override fun onDeleteLocation(locationToDelete: Location) { + val newState = _myLocationsUIStateFlow.value.withLocationDeleted(locationToDelete) + updateLocationsUIStateWith(newState) + + onReloadWeatherForecast() + } + + override fun onLocationSelected(selectedLocationIndex: Int) { + val newState = _myLocationsUIStateFlow.value.withItemAtIndexSelected(selectedLocationIndex) + updateLocationsUIStateWith(newState) + + if (_weatherState.value.getLocationOrNull() != newState.selectedLocation) { + onReloadWeatherForecast() + } + } + + override fun onReloadWeatherForecast() { + _myLocationsUIStateFlow.value.selectedLocation?.let { location -> + onLoadWeatherForecast(location) + } + } + + override fun onLoadWeatherForecast(location: Location) { + currentWeatherJob?.cancel() + + currentWeatherJob = viewModelScope.launch { + _weatherState.value = WeatherForecastUIState.Loading(location) + + weatherService.loadWeatherForecastFor(location) + .onSuccess { weatherData -> + _weatherState.value = WeatherForecastUIState.Success(weatherData) + }.onFailure { error -> + if (error is CancellationException) { + throw error + } + + _weatherState.value = errorStateFor(location, error) + } + } + } + + /** + * Cancels all coroutines running within the context of the ViewModel's scope. + * + * This method is used to release resources and stop ongoing tasks when the ViewModel + * is no longer needed, ensuring proper cleanup of coroutine-based operations. + */ + override fun dispose() { + viewModelScope.cancel() + } + + private fun updateLocationsUIStateWith(newState: LocationsUIState) { + _myLocationsUIStateFlow.value = newState + } + + private fun errorStateFor( + location: Location, + error: Throwable + ): WeatherForecastUIState.Error = WeatherForecastUIState.Error( + "Failed to load weather forecast for ${location.label}", + location, + error + ) +} diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherIcons.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherIcons.kt new file mode 100644 index 0000000..3ec7b59 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherIcons.kt @@ -0,0 +1,63 @@ +package org.jetbrains.plugins.template.weatherApp.ui + +import org.jetbrains.jewel.ui.icon.IconKey +import org.jetbrains.jewel.ui.icon.PathIconKey + +object WeatherIcons { + + // Precipitation + val rain: IconKey = PathIconKey("icons/weather/rain.svg", WeatherIcons::class.java) + val rainThunder: IconKey = PathIconKey("icons/weather/rain_thunder.svg", WeatherIcons::class.java) + val sleet: IconKey = PathIconKey("icons/weather/sleet.svg", WeatherIcons::class.java) + val snow: IconKey = PathIconKey("icons/weather/snow.svg", WeatherIcons::class.java) + val snowThunder: IconKey = PathIconKey("icons/weather/snow_thunder.svg", WeatherIcons::class.java) + + // Severe weather + val thunder: IconKey = PathIconKey("icons/weather/thunder.svg", WeatherIcons::class.java) + val tornado: IconKey = PathIconKey("icons/weather/tornado.svg", WeatherIcons::class.java) + + // Angry/Storm conditions + val angryClouds: IconKey = PathIconKey("icons/weather/angry_clouds.svg", WeatherIcons::class.java) + + // Basic cloud conditions + val cloudy: IconKey = PathIconKey("icons/weather/cloudy.svg", WeatherIcons::class.java) + val overcast: IconKey = PathIconKey("icons/weather/overcast.svg", WeatherIcons::class.java) + + // Day conditions + val dayClear: IconKey = PathIconKey("icons/weather/day_clear.svg", WeatherIcons::class.java) + val dayPartialCloud: IconKey = PathIconKey("icons/weather/day_partial_cloud.svg", WeatherIcons::class.java) + val dayRain: IconKey = PathIconKey("icons/weather/day_rain.svg", WeatherIcons::class.java) + val dayRainThunder: IconKey = PathIconKey("icons/weather/day_rain_thunder.svg", WeatherIcons::class.java) + val daySleet: IconKey = PathIconKey("icons/weather/day_sleet.svg", WeatherIcons::class.java) + val daySnow: IconKey = PathIconKey("icons/weather/day_snow.svg", WeatherIcons::class.java) + val daySnowThunder: IconKey = PathIconKey("icons/weather/day_snow_thunder.svg", WeatherIcons::class.java) + + // Atmospheric conditions + val fog: IconKey = PathIconKey("icons/weather/fog.svg", WeatherIcons::class.java) + val mist: IconKey = PathIconKey("icons/weather/mist.svg", WeatherIcons::class.java) + val wind: IconKey = PathIconKey("icons/weather/wind.svg", WeatherIcons::class.java) + + // Night - Full Moon conditions + val nightFullMoonClear: IconKey = PathIconKey("icons/weather/night_full_moon_clear.svg", WeatherIcons::class.java) + val nightFullMoonPartialCloud: IconKey = + PathIconKey("icons/weather/night_full_moon_partial_cloud.svg", WeatherIcons::class.java) + val nightFullMoonRain: IconKey = PathIconKey("icons/weather/night_full_moon_rain.svg", WeatherIcons::class.java) + val nightFullMoonRainThunder: IconKey = + PathIconKey("icons/weather/night_full_moon_rain_thunder.svg", WeatherIcons::class.java) + val nightFullMoonSleet: IconKey = PathIconKey("icons/weather/night_full_moon_sleet.svg", WeatherIcons::class.java) + val nightFullMoonSnow: IconKey = PathIconKey("icons/weather/night_full_moon_snow.svg", WeatherIcons::class.java) + val nightFullMoonSnowThunder: IconKey = + PathIconKey("icons/weather/night_full_moon_snow_thunder.svg", WeatherIcons::class.java) + + // Night - Half Moon conditions + val nightHalfMoonClear: IconKey = PathIconKey("icons/weather/night_half_moon_clear.svg", WeatherIcons::class.java) + val nightHalfMoonPartialCloud: IconKey = + PathIconKey("icons/weather/night_half_moon_partial_cloud.svg", WeatherIcons::class.java) + val nightHalfMoonRain: IconKey = PathIconKey("icons/weather/night_half_moon_rain.svg", WeatherIcons::class.java) + val nightHalfMoonRainThunder: IconKey = + PathIconKey("icons/weather/night_half_moon_rain_thunder.svg", WeatherIcons::class.java) + val nightHalfMoonSleet: IconKey = PathIconKey("icons/weather/night_half_moon_sleet.svg", WeatherIcons::class.java) + val nightHalfMoonSnow: IconKey = PathIconKey("icons/weather/night_half_moon_snow.svg", WeatherIcons::class.java) + val nightHalfMoonSnowThunder: IconKey = + PathIconKey("icons/weather/night_half_moon_snow_thunder.svg", WeatherIcons::class.java) +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/EmbeddedToInlineCssSvgTransformerHint.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/EmbeddedToInlineCssSvgTransformerHint.kt new file mode 100644 index 0000000..c6015e1 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/EmbeddedToInlineCssSvgTransformerHint.kt @@ -0,0 +1,177 @@ +package org.jetbrains.plugins.template.weatherApp.ui.components + +import org.jetbrains.jewel.ui.painter.PainterProviderScope +import org.jetbrains.jewel.ui.painter.PainterSvgPatchHint +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import java.io.IOException +import java.io.StringWriter +import javax.xml.transform.OutputKeys +import javax.xml.transform.Transformer +import javax.xml.transform.TransformerException +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory + +object EmbeddedToInlineCssSvgTransformerHint : PainterSvgPatchHint { + private val CSS_STYLEABLE_TAGS = listOf( + "linearGradient", "radialGradient", "pattern", + "filter", "clipPath", "mask", "symbol", + "marker", "font", "image" + ) + + override fun PainterProviderScope.patch(element: Element) { + element.inlineEmbeddedStylesCSS() + } + + private fun Element.inlineEmbeddedStylesCSS(): Element { + val svgElement = this + + svgElement.moveStyleableElementsToDefsNode(CSS_STYLEABLE_TAGS) + + val cache = svgElement.parseCssDefinitionsInStylesElement() + + svgElement.inlineStyleDeclarations(cache) + + return svgElement + } +} + +private fun Element.getElementsWithAttributeXPath(attributeName: String): List { + val xPath = XPathFactory.newInstance().newXPath() + + val eligibleNodes = xPath.evaluate( + "//*[@$attributeName]", + this, + XPathConstants.NODESET + ) as NodeList + + return buildList { + for (i in 0 until eligibleNodes.length) { + eligibleNodes.item(i) + .let { node -> if (node is Element) add(node) } + } + } +} + + +private fun Element.inlineStyleDeclarations(cache: Map>) { + val classAttributeName = "class" + val styleElementName = "style" + + for (element in getElementsWithAttributeXPath(classAttributeName)) { + if (element.hasAttribute(classAttributeName)) { + val cssClassId = element.getAttribute(classAttributeName) + if (cssClassId.isBlank()) continue + + element.removeAttribute(classAttributeName) + + // Set a new "style" attribute (example value) + val styleAttributesCache = cache[cssClassId] ?: continue + val styleAttributes = styleAttributesCache.entries.joinToString(";") { "${it.key}:${it.value}" } + element.setAttribute(styleElementName, styleAttributes) + } + } + + this.getSingleChildElement(styleElementName) + ?.let { styleNode -> this.removeChild(styleNode) } +} + +private fun Element.moveStyleableElementsToDefsNode(stylableElementTags: List) { + // Find or create element + val defs = ensureDefsNodeExists() + + // For each tag, find all elements and move those not already inside defs + stylableElementTags.forEach { tag -> + val nodes = getElementsByTagName(tag) + (0.. + if (nodeToMove.parentNode != newParentNode) { + newParentNode.appendChild(nodeToMove) + } + } + } +} + +/** + * See: https://www.w3.org/TR/2018/CR-SVG2-20181004/struct.html#DefsElement + */ +private fun Element.ensureDefsNodeExists(): Node { + var defsNode = getElementsByTagName("defs").item(0) + + if (defsNode == null) { + defsNode = this.ownerDocument.createElement("defs") + insertBefore(defsNode, this.firstChild) + } + return defsNode +} + +private fun Element.parseCssDefinitionsInStylesElement(): Map> { + val styleNode = this.getChildElements("style") + .firstOrNull() ?: return emptyMap() + + val cssClassIdRegex = Regex("""\.([^\s{]+)\s*\{\s*([^}]+)\s*}""") + + return buildMap { + cssClassIdRegex.findAll(styleNode.textContent).forEach { match -> + val styleId = match.groups[1]?.value ?: return@forEach + val styleAttributes = match.groups[2]?.value ?: return@forEach + + val styleAttributesMap = styleAttributes + .split(";") + .filter { it.isNotBlank() } + .associate { attributeKeyValue -> + val (key, value) = attributeKeyValue.trim().split(":") + key.trim() to value.trim() + } + + this[styleId] = styleAttributesMap + } + } +} + +private fun Element.getChildElements(tagName: String): List { + val childNodes = childNodes + val result = ArrayList() + for (i in 0 until childNodes.length) { + val node = childNodes.item(i) + if (node is Element && tagName == node.tagName) { + result.add(node) + } + } + return result +} + +private fun Element.getSingleChildElement(tagName: String): Element? { + return getChildElements(tagName).getOrNull(0) +} + +private class PrintableElement(private val element: Element) { + + fun writeToString(): String { + return element.ownerDocument.writeToString() + } + + private fun Document.writeToString(): String { + val tf = TransformerFactory.newInstance() + val transformer: Transformer + + try { + transformer = tf.newTransformer() + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes") + + val writer = StringWriter() + transformer.transform(DOMSource(this), StreamResult(writer)) + return writer.buffer.toString() + } catch (e: TransformerException) { + error("Unable to render XML document to string: ${e.message}") + } catch (e: IOException) { + error("Unable to render XML document to string: ${e.message}") + } + } +} diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/SearchToolbarMenu.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/SearchToolbarMenu.kt new file mode 100644 index 0000000..0316221 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/SearchToolbarMenu.kt @@ -0,0 +1,84 @@ +package org.jetbrains.plugins.template.weatherApp.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.OutlinedButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys +import org.jetbrains.plugins.template.ComposeTemplateBundle +import org.jetbrains.plugins.template.components.SearchBarWithAutoCompletion +import org.jetbrains.plugins.template.weatherApp.model.PreviewableItem +import org.jetbrains.plugins.template.weatherApp.model.Searchable +import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider + +@Composable +fun SearchToolbarMenu( + searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, + confirmButtonText: String = "Confirm", + onSearchPerformed: (T) -> Unit = {}, + onSearchConfirmed: (T) -> Unit = {}, +) where T : Searchable, T : PreviewableItem { + val isConfirmButtonVisible = remember { mutableStateOf(false) } + val previewItem = remember { mutableStateOf(null) } + val searchTextFieldState = rememberTextFieldState("") + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp, vertical = 4.dp) + .wrapContentHeight(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + + if (isConfirmButtonVisible.value) { + OutlinedButton( + onClick = { + previewItem.value?.let { onSearchConfirmed(it) } + + searchTextFieldState.setTextAndPlaceCursorAtEnd("") + isConfirmButtonVisible.value = false + previewItem.value = null + }, + modifier = Modifier + .align(Alignment.CenterVertically), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon( + AllIconsKeys.Actions.AddList, + contentDescription = ComposeTemplateBundle.message("weather.app.search.toolbar.menu.add.button.content.description") + ) + Text(confirmButtonText) + } + } + } + + + Spacer(modifier = Modifier.width(4.dp)) + + SearchBarWithAutoCompletion( + modifier = Modifier + .fillMaxWidth(0.4f) + .align(Alignment.CenterVertically), + searchAutoCompletionItemProvider = searchAutoCompletionItemProvider, + textFieldState = searchTextFieldState, + onClear = { + isConfirmButtonVisible.value = false + previewItem.value = null + }, + onSelectCompletion = { autocompletedItem -> + isConfirmButtonVisible.value = true + previewItem.value = autocompletedItem + onSearchPerformed(autocompletedItem) + }, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt new file mode 100644 index 0000000..e790892 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt @@ -0,0 +1,667 @@ +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 +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +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 +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.ui.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.* + + +/** + * 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 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, + weatherForecastState: WeatherForecastUIState, + onReloadWeatherData: (Location) -> Unit +) { + + 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( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(cardColor) + .padding(16.dp) + ) { + + // Card content + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Current Time + TimeDisplay(weatherForecastState, textColor) + + ActionButton( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color.Transparent) + .padding(8.dp), + tooltip = { Text("Refresh weather data") }, + onClick = { + weatherForecastState.getLocationOrNull()?.let { onReloadWeatherData(it) } + }, + ) { + Icon( + key = AllIconsKeys.Actions.Refresh, + contentDescription = "Refresh", + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Temperature and weather type column (vertically aligned) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + WeatherIconDisplay(weatherForecastState) + + Spacer(modifier = Modifier.height(8.dp)) + + TemperatureDisplay(weatherForecastState, textColor) + + Spacer(modifier = Modifier.height(8.dp)) + + // City name + CityNameDisplay(weatherForecastState, textColor) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Wind and humidity info + WeatherDetailsRow(Modifier.fillMaxWidth(), weatherForecastState, textColor) + + Spacer(modifier = Modifier.height(24.dp)) + + // 7-day forecast section + SevenDaysForecastWidget( + weatherForecastState, + textColor, + Modifier + .fillMaxWidth() + .wrapContentHeight() + .align(Alignment.CenterHorizontally) + ) + } + } + } +} + +@Composable +private fun SevenDaysForecastWidget( + weatherForecastData: WeatherForecastData, + modifier: Modifier, + textColor: Color +) { + if (weatherForecastData.dailyForecasts.isNotEmpty()) { + Column(modifier) { + Text( + text = ComposeTemplateBundle.message("weather.app.7days.forecast.title.text"), + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val scrollState = rememberLazyListState() + + HorizontallyScrollableContainer( + modifier = Modifier.fillMaxWidth().safeContentPadding(), + scrollState = scrollState, + ) { + LazyRow( + state = scrollState, + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(weatherForecastData.dailyForecasts) { forecast -> + DayForecastItem( + forecast = forecast, + currentDate = weatherForecastData.currentWeatherForecast.date, + textColor = textColor + ) + } + } + } + } + } +} + +/** + * A composable function that displays a single day's forecast. + * + * @param forecast The forecast data for a single day + * @param currentDate The current date for determining relative day names (Today, Tomorrow) + * @param textColor The color of the text + */ +@Composable +private fun DayForecastItem( + forecast: DailyForecast, + currentDate: LocalDateTime, + textColor: Color +) { + val dayName = getDayName(forecast.date, currentDate) + val date = formatDateTime(forecast.date, showYear = false, showTime = false) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(120.dp) + .border(1.dp, textColor.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + // Day name + Text( + text = dayName, + color = textColor, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Text( + text = date, + color = textColor, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Weather icon + Icon( + key = if (isNightTime(forecast.date)) forecast.weatherType.nightIconKey else forecast.weatherType.dayIconKey, + contentDescription = forecast.weatherType.label, + hint = EmbeddedToInlineCssSvgTransformerHint, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Temperature + Text( + text = ComposeTemplateBundle.message( + "weather.app.temperature.text", + forecast.temperature.toInt() + ), + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Humidity + Text( + text = ComposeTemplateBundle.message( + "weather.app.humidity.text", + forecast.humidity + ), + color = textColor, + fontSize = 12.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Wind direction + Text( + text = ComposeTemplateBundle.message( + "weather.app.wind.direction.text", + forecast.windSpeed.toInt(), + forecast.windDirection.label + ), + color = textColor, + fontSize = 12.sp + ) + } +} + +/** + * 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, + * and the day of week plus date for other days. + */ +private fun getDayName(date: LocalDateTime, currentDate: LocalDateTime): String { + val daysDifference = date.toLocalDate().toEpochDay() - currentDate.toLocalDate().toEpochDay() + + return when (daysDifference) { + 0L -> "Today" + 1L -> "Tomorrow" + else -> { + val dayOfWeek = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) + date.dayOfMonth + "$dayOfWeek" + } + } +} + +/** + * Determines if it's night time based on the current hour. + * Night time is considered to be between 7 PM (19:00) and 6 AM (6:00). + */ +fun isNightTime(dateTime: LocalDateTime): Boolean { + val hour = dateTime.hour + return hour !in 6..<19 +} + +/** + * Returns a color based on the temperature and whether it's night time. + * - Cold temperatures: blue/purple + * - Warm temperatures: red/pink + * - Night time: darker shades + */ +fun getCardColorByTemperature(temperature: Float, isNightTime: Boolean): Color { + return when { + isNightTime -> WeatherAppColors.nightWeatherColor + temperature < 0 -> WeatherAppColors.coldWeatherColor + temperature < 10 -> WeatherAppColors.coolWeatherColor + temperature < 20 -> WeatherAppColors.mildWeatherColor + temperature < 30 -> WeatherAppColors.warmWeatherColor + else -> WeatherAppColors.hotWeatherColor + } +} + +/** + * Formats the date time to a readable string. + */ +fun formatDateTime( + dateTime: LocalDateTime, + showYear: Boolean = true, + showTime: Boolean = true +): String { + val dateFormattingPattern = buildString { + append("dd MMM") + if (showYear) append(" yyyy") + if (showTime) append(", HH:mm") + } + + val formatter = DateTimeFormatter.ofPattern(dateFormattingPattern) + return dateTime.format(formatter) +} \ No newline at end of file diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index fe19c4d..f22a68d 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -14,6 +14,6 @@ messages.ComposeTemplate - + diff --git a/src/main/resources/icons/weather/angry_clouds.svg b/src/main/resources/icons/weather/angry_clouds.svg new file mode 100644 index 0000000..b137140 --- /dev/null +++ b/src/main/resources/icons/weather/angry_clouds.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/cloudy.svg b/src/main/resources/icons/weather/cloudy.svg new file mode 100644 index 0000000..98abfec --- /dev/null +++ b/src/main/resources/icons/weather/cloudy.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/day_clear.svg b/src/main/resources/icons/weather/day_clear.svg new file mode 100644 index 0000000..0274352 --- /dev/null +++ b/src/main/resources/icons/weather/day_clear.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/day_partial_cloud.svg b/src/main/resources/icons/weather/day_partial_cloud.svg new file mode 100644 index 0000000..88b7620 --- /dev/null +++ b/src/main/resources/icons/weather/day_partial_cloud.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/day_rain.svg b/src/main/resources/icons/weather/day_rain.svg new file mode 100644 index 0000000..7658ce2 --- /dev/null +++ b/src/main/resources/icons/weather/day_rain.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/day_rain_thunder.svg b/src/main/resources/icons/weather/day_rain_thunder.svg new file mode 100644 index 0000000..411fbfa --- /dev/null +++ b/src/main/resources/icons/weather/day_rain_thunder.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/day_sleet.svg b/src/main/resources/icons/weather/day_sleet.svg new file mode 100644 index 0000000..be107e3 --- /dev/null +++ b/src/main/resources/icons/weather/day_sleet.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/day_snow.svg b/src/main/resources/icons/weather/day_snow.svg new file mode 100644 index 0000000..749b54a --- /dev/null +++ b/src/main/resources/icons/weather/day_snow.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/day_snow_thunder.svg b/src/main/resources/icons/weather/day_snow_thunder.svg new file mode 100644 index 0000000..5fc2202 --- /dev/null +++ b/src/main/resources/icons/weather/day_snow_thunder.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/fog.svg b/src/main/resources/icons/weather/fog.svg new file mode 100644 index 0000000..220b087 --- /dev/null +++ b/src/main/resources/icons/weather/fog.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/mist.svg b/src/main/resources/icons/weather/mist.svg new file mode 100644 index 0000000..c159587 --- /dev/null +++ b/src/main/resources/icons/weather/mist.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_full_moon_clear.svg b/src/main/resources/icons/weather/night_full_moon_clear.svg new file mode 100644 index 0000000..d5d1454 --- /dev/null +++ b/src/main/resources/icons/weather/night_full_moon_clear.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_full_moon_partial_cloud.svg b/src/main/resources/icons/weather/night_full_moon_partial_cloud.svg new file mode 100644 index 0000000..fcd52bd --- /dev/null +++ b/src/main/resources/icons/weather/night_full_moon_partial_cloud.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_full_moon_rain.svg b/src/main/resources/icons/weather/night_full_moon_rain.svg new file mode 100644 index 0000000..a540c75 --- /dev/null +++ b/src/main/resources/icons/weather/night_full_moon_rain.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_full_moon_rain_thunder.svg b/src/main/resources/icons/weather/night_full_moon_rain_thunder.svg new file mode 100644 index 0000000..ce6e323 --- /dev/null +++ b/src/main/resources/icons/weather/night_full_moon_rain_thunder.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_full_moon_sleet.svg b/src/main/resources/icons/weather/night_full_moon_sleet.svg new file mode 100644 index 0000000..891e61b --- /dev/null +++ b/src/main/resources/icons/weather/night_full_moon_sleet.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_full_moon_snow.svg b/src/main/resources/icons/weather/night_full_moon_snow.svg new file mode 100644 index 0000000..40d335b --- /dev/null +++ b/src/main/resources/icons/weather/night_full_moon_snow.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_full_moon_snow_thunder.svg b/src/main/resources/icons/weather/night_full_moon_snow_thunder.svg new file mode 100644 index 0000000..b3eeaec --- /dev/null +++ b/src/main/resources/icons/weather/night_full_moon_snow_thunder.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_half_moon_clear.svg b/src/main/resources/icons/weather/night_half_moon_clear.svg new file mode 100644 index 0000000..8bf3ba8 --- /dev/null +++ b/src/main/resources/icons/weather/night_half_moon_clear.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_half_moon_partial_cloud.svg b/src/main/resources/icons/weather/night_half_moon_partial_cloud.svg new file mode 100644 index 0000000..fa732de --- /dev/null +++ b/src/main/resources/icons/weather/night_half_moon_partial_cloud.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_half_moon_rain.svg b/src/main/resources/icons/weather/night_half_moon_rain.svg new file mode 100644 index 0000000..c7dda16 --- /dev/null +++ b/src/main/resources/icons/weather/night_half_moon_rain.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_half_moon_rain_thunder.svg b/src/main/resources/icons/weather/night_half_moon_rain_thunder.svg new file mode 100644 index 0000000..aa3c815 --- /dev/null +++ b/src/main/resources/icons/weather/night_half_moon_rain_thunder.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_half_moon_sleet.svg b/src/main/resources/icons/weather/night_half_moon_sleet.svg new file mode 100644 index 0000000..b1a3220 --- /dev/null +++ b/src/main/resources/icons/weather/night_half_moon_sleet.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_half_moon_snow.svg b/src/main/resources/icons/weather/night_half_moon_snow.svg new file mode 100644 index 0000000..348c92e --- /dev/null +++ b/src/main/resources/icons/weather/night_half_moon_snow.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/night_half_moon_snow_thunder.svg b/src/main/resources/icons/weather/night_half_moon_snow_thunder.svg new file mode 100644 index 0000000..957d639 --- /dev/null +++ b/src/main/resources/icons/weather/night_half_moon_snow_thunder.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/overcast.svg b/src/main/resources/icons/weather/overcast.svg new file mode 100644 index 0000000..635a992 --- /dev/null +++ b/src/main/resources/icons/weather/overcast.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/rain.svg b/src/main/resources/icons/weather/rain.svg new file mode 100644 index 0000000..89bc5b6 --- /dev/null +++ b/src/main/resources/icons/weather/rain.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/rain_thunder.svg b/src/main/resources/icons/weather/rain_thunder.svg new file mode 100644 index 0000000..da560c6 --- /dev/null +++ b/src/main/resources/icons/weather/rain_thunder.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/sleet.svg b/src/main/resources/icons/weather/sleet.svg new file mode 100644 index 0000000..18cd894 --- /dev/null +++ b/src/main/resources/icons/weather/sleet.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/snow.svg b/src/main/resources/icons/weather/snow.svg new file mode 100644 index 0000000..048e915 --- /dev/null +++ b/src/main/resources/icons/weather/snow.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/snow_thunder.svg b/src/main/resources/icons/weather/snow_thunder.svg new file mode 100644 index 0000000..5f69c9d --- /dev/null +++ b/src/main/resources/icons/weather/snow_thunder.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/thunder.svg b/src/main/resources/icons/weather/thunder.svg new file mode 100644 index 0000000..9bb028d --- /dev/null +++ b/src/main/resources/icons/weather/thunder.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/tornado.svg b/src/main/resources/icons/weather/tornado.svg new file mode 100644 index 0000000..6547c49 --- /dev/null +++ b/src/main/resources/icons/weather/tornado.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/weather/wind.svg b/src/main/resources/icons/weather/wind.svg new file mode 100644 index 0000000..b11fcd9 --- /dev/null +++ b/src/main/resources/icons/weather/wind.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/messages/ComposeTemplate.properties b/src/main/resources/messages/ComposeTemplate.properties index cf30aa4..9ec96a2 100644 --- a/src/main/resources/messages/ComposeTemplate.properties +++ b/src/main/resources/messages/ComposeTemplate.properties @@ -1,6 +1,12 @@ -projectService=Project service: {0} -randomLabel=The random number is: {0} -shuffle=Shuffle -helloWorld=Hello world #{0} -increment=Moar -action.dev.sebastiano.jewel.ijplugin.demo.text=Jewel Demo Dialog \ No newline at end of file +weather.app.temperature.text={0}\u00B0C +weather.app.time.text=Time: {0} +weather.app.humidity.text=Humidity: {0}% +weather.app.wind.direction.text=Wind: {0} km/h {1} +weather.app.my.locations.header.text=My Locations +weather.app.my.locations.empty.list.placeholder.text=No locations added yet. Go and add the first location. +weather.app.my.locations.empty.list.placeholder.icon.content.description=Empty list icon. +weather.app.search.toolbar.menu.add.button.text=Add +weather.app.search.toolbar.menu.add.button.content.description=Add a place to a watch list. +weather.app.7days.forecast.title.text=7-day Forecast + +weather.app.clear.button.content.description=Clear \ No newline at end of file diff --git a/src/test/kotlin/org/jetbrains/plugins/template/ComposeBasedTestCase.kt b/src/test/kotlin/org/jetbrains/plugins/template/ComposeBasedTestCase.kt new file mode 100644 index 0000000..2524a49 --- /dev/null +++ b/src/test/kotlin/org/jetbrains/plugins/template/ComposeBasedTestCase.kt @@ -0,0 +1,46 @@ +package org.jetbrains.plugins.template + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import kotlinx.coroutines.test.runTest +import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme +import org.junit.Rule + + +/** + * An abstract base class for Compose-based test cases. + * + * This class provides a framework for running Compose UI tests. It includes a + * test rule for composing UI content and abstracts the content under test. + */ +internal abstract class ComposeBasedTestCase { + @get:Rule + val composableRule = createComposeRule() + + /** + * Provides the Composable Content under test. + */ + abstract val contentUnderTest: @Composable () -> Unit + + + /** + * Runs the given Compose test block in the context of a Compose content test rule. + */ + fun runComposeTest(block: suspend ComposeTestRule.() -> Unit) = runTest { + composableRule.setContentWrappedInTheme { + contentUnderTest() + } + + composableRule.block() + } + + private fun ComposeContentTestRule.setContentWrappedInTheme(content: @Composable () -> Unit) { + setContent { + IntUiTheme { + content() + } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt new file mode 100644 index 0000000..097cd11 --- /dev/null +++ b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt @@ -0,0 +1,209 @@ +package org.jetbrains.plugins.template + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.jetbrains.plugins.template.weatherApp.model.Location +import org.jetbrains.plugins.template.weatherApp.ui.LocationsUIState +import org.jetbrains.plugins.template.weatherApp.ui.MyLocationsViewModelApi +import org.jetbrains.plugins.template.weatherApp.ui.MyLocationsListWithEmptyListPlaceholder +import org.junit.Test + +internal class MyLocationListTest : ComposeBasedTestCase() { + private val noLocations = emptyList() + private val myLocationsViewModelApi = FakeMyLocationsViewModel(locations = noLocations) + + override val contentUnderTest: @Composable () -> Unit = { + MyLocationsListWithEmptyListPlaceholder( + modifier = Modifier.fillMaxWidth(), + myLocationsViewModelApi = myLocationsViewModelApi + ) + } + + @Test + fun `verify placeholder is shown when no locations is added`() = runComposeTest { + val myLocationsRobot = MyLocationListRobot(this) + + myLocationsRobot + .verifyNoLocationsPlaceHolderVisible() + } + + @Test + fun `verify location is selected when user adds location`() = runComposeTest { + val myLocationsRobot = MyLocationListRobot(this) + + myLocationsViewModelApi.onAddLocation(Location("Munich", "Germany")) + + myLocationsRobot + .verifyListItemWithTextIsSelected("Munich, Germany") + } + + @Test + fun `verify item selection when multiple items are present`() = runComposeTest { + val myLocationsRobot = MyLocationListRobot(this) + + // Add multiple locations + myLocationsViewModelApi.onAddLocation(Location("Munich", "Germany")) + myLocationsViewModelApi.onAddLocation(Location("Berlin", "Germany")) + myLocationsViewModelApi.onAddLocation(Location("Paris", "France")) + + // Initially, the last added location (Paris) should be selected + myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") + + // Select a different location + myLocationsRobot.clickOnItemWithText("Berlin, Germany") + + // Verify the clicked location is now selected + myLocationsRobot.verifyListItemWithTextIsSelected("Berlin, Germany") + } + + @Test + fun `verify item deletion when multiple items are present`() = runComposeTest { + val myLocationsRobot = MyLocationListRobot(this) + + // Add multiple locations + val munich = Location("Munich", "Germany") + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + + myLocationsViewModelApi.onAddLocation(munich) + myLocationsViewModelApi.onAddLocation(berlin) + myLocationsViewModelApi.onAddLocation(paris) + + // Initially, the last added location (Paris) should be selected + myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") + + // Delete the selected location (Paris) + myLocationsViewModelApi.onDeleteLocation(paris) + + // Verify Paris is no longer in the list and Berlin is now selected + // (as it's the last item in the list after deletion) + myLocationsRobot.verifyItemDoesNotExist("Paris, France") + myLocationsRobot.verifyListItemWithTextIsSelected("Berlin, Germany") + } + + @Test + fun `verify middle item deletion when three items are present`() = runComposeTest { + val myLocationsRobot = MyLocationListRobot(this) + + // Add three locations + val munich = Location("Munich", "Germany") + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + + myLocationsViewModelApi.onAddLocation(munich) + myLocationsViewModelApi.onAddLocation(berlin) + myLocationsViewModelApi.onAddLocation(paris) + + // Initially, the last added location (Paris) should be selected + myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") + + // Delete the middle location (Berlin) + myLocationsViewModelApi.onDeleteLocation(berlin) + + // Verify Berlin is no longer in the list + myLocationsRobot.verifyItemDoesNotExist("Berlin, Germany") + + // Verify Munich and Paris still exist + myLocationsRobot.verifyItemExists("Munich, Germany") + myLocationsRobot.verifyItemExists("Paris, France") + + // Paris should still be selected as it was the selected item before deletion + myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") + } + + @Test + fun `verify deletion of the only item in list`() = runComposeTest { + val myLocationsRobot = MyLocationListRobot(this) + + // Add one location + val munich = Location("Munich", "Germany") + myLocationsViewModelApi.onAddLocation(munich) + + // Verify the location is selected + myLocationsRobot.verifyListItemWithTextIsSelected("Munich, Germany") + + // Delete the only location + myLocationsViewModelApi.onDeleteLocation(munich) + + // Verify the location is no longer in the list + myLocationsRobot.verifyItemDoesNotExist("Munich, Germany") + + // Verify the empty list placeholder is shown + myLocationsRobot.verifyNoLocationsPlaceHolderVisible() + } + + private class FakeMyLocationsViewModel( + locations: List = emptyList() + ) : MyLocationsViewModelApi { + + private val _myLocationsUIStateFlow: MutableStateFlow = + MutableStateFlow(LocationsUIState.initial(locations)) + + + override fun onAddLocation(locationToAdd: Location) { + _myLocationsUIStateFlow.value = _myLocationsUIStateFlow.value.withLocationAdded(locationToAdd) + } + + override fun onDeleteLocation(locationToDelete: Location) { + _myLocationsUIStateFlow.value = _myLocationsUIStateFlow.value.withLocationDeleted(locationToDelete) + + } + + override fun onLocationSelected(selectedLocationIndex: Int) { + _myLocationsUIStateFlow.value = _myLocationsUIStateFlow.value.withItemAtIndexSelected(selectedLocationIndex) + } + + override val myLocationsUIStateFlow: Flow + get() = _myLocationsUIStateFlow.asStateFlow() + + override fun dispose() { + } + } + + private class MyLocationListRobot(private val composableRule: ComposeTestRule) { + + fun clickOnItemWithText(text: String) { + composableRule + .onNodeWithText(text) + .performClick() + } + + fun verifyNoLocationsPlaceHolderVisible() { + composableRule + .onNodeWithText("No locations added yet. Go and add the first location.") + .assertExists() + + composableRule + .onNodeWithContentDescription("Empty list icon.") + .assertExists() + } + + fun verifyListItemWithTextIsSelected(text: String) { + composableRule + .onNodeWithText(text) + .assertExists() + .assertIsSelected() + } + + fun verifyItemDoesNotExist(text: String) { + composableRule + .onNodeWithText(text) + .assertDoesNotExist() + } + + fun verifyItemExists(text: String) { + composableRule + .onNodeWithText(text) + .assertExists() + } + } +} diff --git a/src/test/kotlin/org/jetbrains/plugins/template/MyPluginTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/MyPluginTest.kt deleted file mode 100644 index 3945372..0000000 --- a/src/test/kotlin/org/jetbrains/plugins/template/MyPluginTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.jetbrains.plugins.template - -import com.intellij.ide.highlighter.XmlFileType -import com.intellij.openapi.components.service -import com.intellij.psi.xml.XmlFile -import com.intellij.testFramework.TestDataPath -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.util.PsiErrorElementUtil -import org.jetbrains.plugins.template.services.MyProjectService - -@TestDataPath("\$CONTENT_ROOT/src/test/testData") -class MyPluginTest : BasePlatformTestCase() { - - fun testXMLFile() { - val psiFile = myFixture.configureByText(XmlFileType.INSTANCE, "bar") - val xmlFile = assertInstanceOf(psiFile, XmlFile::class.java) - - assertFalse(PsiErrorElementUtil.hasErrors(project, xmlFile.virtualFile)) - - assertNotNull(xmlFile.rootTag) - - xmlFile.rootTag?.let { - assertEquals("foo", it.name) - assertEquals("bar", it.value.text) - } - } - - fun testRename() { - myFixture.testRename("foo.xml", "foo_after.xml", "a2") - } - - fun testProjectService() { - val projectService = project.service() - - assertNotSame(projectService.getRandomNumber(), projectService.getRandomNumber()) - } - - override fun getTestDataPath() = "src/test/testData/rename" -} diff --git a/src/test/kotlin/org/jetbrains/plugins/template/components/CompletionPopupControllerTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/components/CompletionPopupControllerTest.kt new file mode 100644 index 0000000..9bf5c8f --- /dev/null +++ b/src/test/kotlin/org/jetbrains/plugins/template/components/CompletionPopupControllerTest.kt @@ -0,0 +1,211 @@ +package org.jetbrains.plugins.template.components + +import org.jetbrains.plugins.template.weatherApp.model.PreviewableItem +import org.jetbrains.plugins.template.weatherApp.model.Searchable +import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +internal class CompletionPopupControllerTest { + + private lateinit var mockProvider: MockSearchProvider + private lateinit var controller: CompletionPopupController + + // Autocompleted items + private val selectedItems = mutableListOf>() + + @Before + fun setUp() { + val testItems = listOf( + TestItem("Paris, France"), + TestItem("Berlin, Germany"), + TestItem("Chicago, USA"), + TestItem("Rome, Italy") + ) + mockProvider = MockSearchProvider(testItems) + + controller = CompletionPopupController(mockProvider) { item -> + selectedItems.add(item) + } + } + + @After + fun tearDown() { + selectedItems.clear() + } + + @Test + fun `test query changes updates completion items`() { + // When + controller.onQueryChanged("a") + + // Then + assertTrue(controller.isVisible) + assertItemCount(4) + assertOnlyItemAtIndexIsSelected(0) + } + + @Test + fun `test keyboard selection with onSelectMovedDown`() { + // Given + controller.onQueryChanged("a") + assertEquals(4, controller.completionItems.size) + assertOnlyItemAtIndexIsSelected(0) // First item is initially selected + + // When + moveSelectionDown(1) + + // Then + assertOnlyItemAtIndexIsSelected(1) // Second item should now be selected + + // When moving down again + moveSelectionDown(1) + + // Then + assertOnlyItemAtIndexIsSelected(2) // Third item should now be selected + } + + @Test + fun `test keyboard selection with onSelectMovedUp`() { + // Given - Initialize with query and move to the last item + controller.onQueryChanged("a") + assertOnlyItemAtIndexIsSelected(0) + assertItemCount(4) + moveSelectionDown(3) // Move to the last item (index 3) + + // Test case 1: Move up from last item (index 3) to third item (index 2) + moveSelectionUp() + assertOnlyItemAtIndexIsSelected(2) + + // Test case 2: Move up from third item (index 2) to second item (index 1) + moveSelectionUp() + assertOnlyItemAtIndexIsSelected(1) + + // Test case 3: Move up from second item (index 1) to first item (index 0) + moveSelectionUp() + assertOnlyItemAtIndexIsSelected(0) + } + + @Test + fun `test keyboard selection stays within boundaries`() { + // Given + controller.onQueryChanged("a") + assertEquals(4, controller.completionItems.size) + assertOnlyItemAtIndexIsSelected(0) // First item is initially selected + + // When moving up from the first item + moveSelectionUp(1) + + // Then it should stay at the first item (no wrapping) + assertOnlyItemAtIndexIsSelected(0) // First item should still be selected + + // Move to the last item + moveSelectionDown(3) + assertOnlyItemAtIndexIsSelected(3) // Last item is selected + + // When moving down from the last item + moveSelectionDown(1) + + // Then it should stay at the last item (no wrapping) + assertOnlyItemAtIndexIsSelected(3) // Last item should still be selected + } + + @Test + fun `test selection confirmation with keyboard navigation`() { + // Given + controller.onQueryChanged("a") + moveSelectionDown(1) + + // When + controller.onSelectionConfirmed() + + // Then + assertItemSelected("Berlin, Germany") + } + + @Test + fun `test selection confirmation with explicit item (mouse click)`() { + // Given + controller.onQueryChanged("a") + val itemToSelect = controller.completionItems[2] // "Chicago, USA" + + controller.onItemClicked(itemToSelect) + + // Then + assertItemSelected("Chicago, USA") + } + + /** + * Asserts that the given selected item label matches the expected label and verifies the state + * of the selected items list and popup visibility after a selection operation. + * + * @param selectedItemLabel The expected label of the selected item. + */ + private fun assertItemSelected(selectedItemLabel: String) { + assertEquals(1, selectedItems.size) + assertEquals(selectedItemLabel, selectedItems[0].item.label) + assertFalse(controller.isVisible) // Popup should be hidden after selection + } + + /** + * Helper method to move selection down by the specified number of steps + * + * @param step Number of steps to move down + */ + private fun moveSelectionDown(step: Int) { + repeat(step) { + controller.onSelectionMovedDown() + } + } + + /** + * Helper method to move selection up by the specified number of steps + * + * @param step Number of steps to move up + */ + private fun moveSelectionUp(step: Int = 1) { + repeat(step) { + controller.onSelectionMovedUp() + } + } + + /** + * Asserts that the current number of completion items matches the expected count. + * + * @param count The expected number of completion items. + */ + private fun assertItemCount(count: Int) { + assertEquals(count, controller.completionItems.size) + } + + /** + * Helper method to assert that only the item at the specified index is selected + */ + private fun assertOnlyItemAtIndexIsSelected(selectedIndex: Int) { + for (i in controller.completionItems.indices) { + if (i == selectedIndex) { + assertTrue( + "Item at index $selectedIndex should be selected", + controller.completionItems[i].isSelected + ) + } else { + assertFalse( + "Item at index $i should not be selected", + controller.completionItems[i].isSelected + ) + } + } + } + + private data class TestItem(override val label: String) : Searchable, PreviewableItem { + override fun matches(query: String): Boolean = label.contains(query, ignoreCase = true) + } + + private class MockSearchProvider(private val items: List) : SearchAutoCompletionItemProvider { + override fun provideSearchableItems(searchTerm: String): List { + return items.filter { it.matches(searchTerm) } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt new file mode 100644 index 0000000..f4a76cd --- /dev/null +++ b/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt @@ -0,0 +1,209 @@ +package org.jetbrains.plugins.template.weatherApp.services + +import org.jetbrains.plugins.template.weatherApp.model.Location +import org.jetbrains.plugins.template.weatherApp.ui.LocationsUIState +import org.junit.Assert.* +import org.junit.Test + +internal class LocationsUIStateTest { + + @Test + fun `test initialization with empty locations`() { + val state = LocationsUIState.initial(emptyList()) + + assertTrue(state.locations.isEmpty()) + assertEquals(-1, state.selectedIndex) + assertNull(state.selectedLocation) + } + + @Test + fun `test initialization with locations and valid selection`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France") + ) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) + + assertEquals(2, state.locations.size) + assertEquals(1, state.selectedIndex) + assertEquals(locations[1], state.selectedLocation) + } + + @Test + fun `test selecting item out of range throws exception`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France") + ) + + assertThrows(IllegalArgumentException::class.java) { + LocationsUIState.initial(locations).withItemAtIndexSelected(5) + } + } + + @Test + fun `test toSelectableLocations with selection`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France"), + Location("Rome", "Italy") + ) + val state = LocationsUIState + .initial(locations) + .withItemAtIndexSelected(1) + + val selectableLocations = state.toSelectableLocations() + + assertEquals(3, selectableLocations.size) + assertFalse(selectableLocations[0].isSelected) + assertTrue(selectableLocations[1].isSelected) + assertFalse(selectableLocations[2].isSelected) + + assertEquals("Berlin, Germany", selectableLocations[0].location.label) + assertEquals("Paris, France", selectableLocations[1].location.label) + assertEquals("Rome, Italy", selectableLocations[2].location.label) + } + + @Test + fun `test withLocationAdded adds location when location doesn't exist`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France") + ) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(0) + + val newLocation = Location("Rome", "Italy") + val newState = state.withLocationAdded(newLocation) + + assertEquals(3, newState.locations.size) + assertEquals(2, newState.selectedIndex) // New location should be selected + assertEquals(newLocation, newState.selectedLocation) + } + + @Test + fun `test withLocationAdded selects existing location when location already exists in list`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val locations = listOf(berlin, paris) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(0) + + // Add Berlin again (already exists) + val newState = state.withLocationAdded(berlin) + + assertEquals(2, newState.locations.size) // No new location added + assertEquals(0, newState.selectedIndex) // Berlin is selected + assertEquals(berlin, newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved removes location when location exists`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val rome = Location("Rome", "Italy") + val locations = listOf(berlin, paris, rome) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) // Paris selected + + // Remove Paris (the selected location) + val newState = state.withLocationDeleted(paris) + + assertEquals(2, newState.locations.size) + assertEquals(0, newState.selectedIndex) // Selection should move to Berlin + assertEquals(berlin, newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved does nothing when location to remove doesn't exist in list`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val locations = listOf(berlin, paris) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) // Paris selected + + // Try to remove a location that doesn't exist + val newState = state.withLocationDeleted(Location("Rome", "Italy")) + + // State should remain unchanged + assertEquals(2, newState.locations.size) + assertEquals(1, newState.selectedIndex) + assertEquals(paris, newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved when location to remove is currently selected location`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val locations = listOf(berlin, paris) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) // Paris selected + + // Remove Paris (the selected and last location) + val newState = state.withLocationDeleted(paris) + + assertEquals(1, newState.locations.size) + assertEquals(0, newState.selectedIndex) // Selection should move to Berlin + assertEquals(berlin, newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved when removing the only location`() { + val berlin = Location("Berlin", "Germany") + val locations = listOf(berlin) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(0) // Berlin selected + + // Remove Berlin (the only location) + val newState = state.withLocationDeleted(berlin) + + assertTrue(newState.locations.isEmpty()) + assertEquals(-1, newState.selectedIndex) // No selection + assertNull(newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved moves selection when location to remove is between first location and selected location`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val rome = Location("Rome", "Italy") + val madrid = Location("Madrid", "Spain") + val locations = listOf(berlin, paris, rome, madrid) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(2) // Rome selected (index 3) + + // Remove Paris (index 1) which is between 0 and selectedIndex (3) + val newState = state.withLocationDeleted(paris) + + assertEquals(3, newState.locations.size) + assertEquals(1, newState.selectedIndex) // Selection should be decremented by 1 + assertEquals(rome, newState.selectedLocation) // Still Rome, but at index 2 now + } + + @Test + fun `test withItemAtIndexSelected with valid index`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France"), + Location("Rome", "Italy") + ) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(0) // Berlin selected + + // Select Rome + val newState = state.withItemAtIndexSelected(2) + + assertEquals(3, newState.locations.size) + assertEquals(2, newState.selectedIndex) + assertEquals(locations[2], newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved when removed location is after the selected location in list`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val rome = Location("Rome", "Italy") + val madrid = Location("Madrid", "Spain") + val locations = listOf(berlin, paris, rome, madrid) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) // Paris selected (index 1) + + // Remove Madrid (index 3) which is greater than selectedIndex (1) + val newState = state.withLocationDeleted(madrid) + + assertEquals(3, newState.locations.size) + assertEquals(1, newState.selectedIndex) // Selection should remain unchanged + assertEquals(paris, newState.selectedLocation) // Still Paris at index 1 + } +} \ No newline at end of file