From acc67cfca650d0fb20f510566b1cc242a42714f7 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Mon, 21 Jul 2025 09:45:43 +0200 Subject: [PATCH 01/71] Add Weather and Chat app sample tabs --- .../toolWindow/MyToolWindowFactory.kt | 38 +++---------------- .../plugins/template/ui/ChatAppSample.kt | 24 ++++++++++++ .../plugins/template/ui/WeatherAppSample.kt | 22 +++++++++++ 3 files changed, 51 insertions(+), 33 deletions(-) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/ui/ChatAppSample.kt create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/ui/WeatherAppSample.kt diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt index e1ade10..5450cd7 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt @@ -1,48 +1,20 @@ 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 +import org.jetbrains.jewel.bridge.addComposeTab +import org.jetbrains.plugins.template.ui.ChatAppSample +import org.jetbrains.plugins.template.ui.WeatherAppSample 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) + toolWindow.addComposeTab("Weather App") { WeatherAppSample() } + toolWindow.addComposeTab("Chat App") { ChatAppSample() } } 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..d83f9fb --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/ui/ChatAppSample.kt @@ -0,0 +1,24 @@ +package org.jetbrains.plugins.template.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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() + .heightIn(20.dp) + .padding(16.dp)) { + Text( + "Not yet implemented", + style = JewelTheme.defaultTextStyle + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jetbrains/plugins/template/ui/WeatherAppSample.kt b/src/main/kotlin/org/jetbrains/plugins/template/ui/WeatherAppSample.kt new file mode 100644 index 0000000..4dd70fa --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/ui/WeatherAppSample.kt @@ -0,0 +1,22 @@ +package org.jetbrains.plugins.template.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +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 WeatherAppSample() { + Column(Modifier + .fillMaxWidth() + .heightIn(20.dp) + .padding(16.dp)) { + Text( + "Not yet implemented", + style = JewelTheme.defaultTextStyle + ) + } +} \ No newline at end of file From 6a8969da5aac3064b374b0b0d5446e7cd4e48b70 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 14:47:45 +0200 Subject: [PATCH 02/71] Update IntelliJ Platform to 2025.1.3 --- gradle.properties | 2 +- .../plugins/template/MyPluginTest.kt | 39 ------------------- 2 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 src/test/kotlin/org/jetbrains/plugins/template/MyPluginTest.kt diff --git a/gradle.properties b/gradle.properties index b6553d5..1fc6ae4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ pluginUntilBuild = 252.* # 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.3 # 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/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" -} From d1c1569b350511115120fdc29f0569f97f7ffe62 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 14:51:38 +0200 Subject: [PATCH 03/71] Move WeatherAppSample.kt to weatherApp/ui package --- .../plugins/template/toolWindow/MyToolWindowFactory.kt | 3 +-- .../plugins/template/{ => weatherApp}/ui/WeatherAppSample.kt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) rename src/main/kotlin/org/jetbrains/plugins/template/{ => weatherApp}/ui/WeatherAppSample.kt (90%) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt index 5450cd7..e45a92b 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt @@ -6,7 +6,7 @@ import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import org.jetbrains.jewel.bridge.addComposeTab import org.jetbrains.plugins.template.ui.ChatAppSample -import org.jetbrains.plugins.template.ui.WeatherAppSample +import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppSample class MyToolWindowFactory : ToolWindowFactory, DumbAware { @@ -16,5 +16,4 @@ class MyToolWindowFactory : ToolWindowFactory, DumbAware { } override fun shouldBeAvailable(project: Project) = true - } diff --git a/src/main/kotlin/org/jetbrains/plugins/template/ui/WeatherAppSample.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt similarity index 90% rename from src/main/kotlin/org/jetbrains/plugins/template/ui/WeatherAppSample.kt rename to src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt index 4dd70fa..69fb0e9 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -1,4 +1,4 @@ -package org.jetbrains.plugins.template.ui +package org.jetbrains.plugins.template.weatherApp.ui import androidx.compose.foundation.layout.* import androidx.compose.runtime.* From ed0629a2adfd02fc2cd9b81f24b32586279ffd3d Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 14:52:12 +0200 Subject: [PATCH 04/71] Improve ChatAppSample placeholder UI --- .../plugins/template/ui/ChatAppSample.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/ui/ChatAppSample.kt b/src/main/kotlin/org/jetbrains/plugins/template/ui/ChatAppSample.kt index d83f9fb..5df65db 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/ui/ChatAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/ui/ChatAppSample.kt @@ -1,8 +1,8 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -12,13 +12,12 @@ import org.jetbrains.jewel.ui.component.Text @Composable fun ChatAppSample() { - Column(Modifier - .fillMaxWidth() - .heightIn(20.dp) - .padding(16.dp)) { - Text( - "Not yet implemented", - style = JewelTheme.defaultTextStyle - ) + Column( + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.Center + ) { + Text("Not yet implemented.", style = JewelTheme.defaultTextStyle) } } \ No newline at end of file From 9a6945d9e19e7bcfd5d2e3b8f8a83fc387195d97 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 14:52:52 +0200 Subject: [PATCH 05/71] Delete MyProjectService.kt --- .../plugins/template/services/MyProjectService.kt | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 src/main/kotlin/org/jetbrains/plugins/template/services/MyProjectService.kt 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() -} From 6b1deab59854f43a1a12fc1875a796679945d0fd Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 14:53:39 +0200 Subject: [PATCH 06/71] Add Location, WeatherForecastData, Searchable and PreviewableItem models --- .../template/weatherApp/model/Location.kt | 34 ++++++++++ .../weatherApp/model/PreviewableItem.kt | 5 ++ .../template/weatherApp/model/Searchable.kt | 8 +++ .../weatherApp/model/WeatherForecastData.kt | 62 +++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/PreviewableItem.kt create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Searchable.kt create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt 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..a3f822c --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt @@ -0,0 +1,34 @@ +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 id A derived unique identifier for the location in the format `name, country`. + */ +@Immutable +internal data class Location(val name: String, val country: String) : PreviewableItem, Searchable { + val id: String = "$name, $country" + + override fun isSearchApplicable(query: String): Boolean { + val applicableCandidates = listOf( + id, + name, + country, + name.split(" ").map { it.first() }.joinToString(""), + "${name.first()}${country.first()}", + "${country.first()}${name.first()}" + ) + + return applicableCandidates.any { it.contains(query, ignoreCase = true) } + } + + override val label: String + get() = id +} + +@Immutable +internal 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..4d97d35 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/PreviewableItem.kt @@ -0,0 +1,5 @@ +package org.jetbrains.plugins.template.weatherApp.model + +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..5bafa75 --- /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. + */ +internal interface Searchable { + fun isSearchApplicable(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..84a423f --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt @@ -0,0 +1,62 @@ +package org.jetbrains.plugins.template.weatherApp.model + +import java.time.LocalDateTime + +/** + * Data class representing weather information to be displayed in the Weather Card. + */ +data class WeatherForecastData( + val cityName: String, + val temperature: Float, + val currentTime: LocalDateTime, + val windSpeed: Float, + val windDirection: WindDirection, + val humidity: Int, // Percentage + val weatherType: WeatherType +) { + companion object Companion { + val EMPTY: WeatherForecastData = WeatherForecastData( + "", + 0f, + LocalDateTime.now(), + 0f, + WindDirection.NORTH, + 0, + WeatherType.SUNNY + ) + } +} + +/** + * Enum representing different weather types. + */ +enum class WeatherType(val label: String) { + SUNNY("Sunny"), + CLOUDY("Cloudy"), + PARTLY_CLOUDY("Partly Cloudy"), + RAINY("Rainy"), + SNOWY("Snowy"), + STORMY("Stormy"); + + companion object { + fun random(): WeatherType = entries.toTypedArray().random() + } +} + +/** + * Enum representing wind directions. + */ +enum class WindDirection(val label: String) { + NORTH("N"), + NORTH_EAST("NE"), + EAST("E"), + SOUTH_EAST("SE"), + SOUTH("S"), + SOUTH_WEST("SW"), + WEST("W"), + NORTH_WEST("NW"); + + companion object { + fun random(): WindDirection = entries.toTypedArray().random() + } +} \ No newline at end of file From 5dffd6f91d690ff14253c2f4c012cca3f9b8b3b5 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 14:54:19 +0200 Subject: [PATCH 07/71] Add LocationProvider service with mock data --- .../weatherApp/services/LocationsProvider.kt | 32 +++++++++++++++++++ .../SearchAutoCompletionItemProvider.kt | 16 ++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsProvider.kt create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/SearchAutoCompletionItemProvider.kt 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..6b192ef --- /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 +internal 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.isSearchApplicable(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..521e6e2 --- /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]. + */ +internal interface SearchAutoCompletionItemProvider { + fun provideSearchableItems(searchTerm: String): List +} \ No newline at end of file From fddf10c3faa09c573e1433f0e725b032f004c080 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 14:55:04 +0200 Subject: [PATCH 08/71] Add WeatherForecastService with a mock data --- .../services/WeatherForecastService.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt 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..8d00dda --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt @@ -0,0 +1,55 @@ +package org.jetbrains.plugins.template.weatherApp.services + +import com.intellij.openapi.components.Service +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.jetbrains.plugins.template.weatherApp.model.Location +import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData +import org.jetbrains.plugins.template.weatherApp.model.WeatherType +import org.jetbrains.plugins.template.weatherApp.model.WindDirection +import java.time.LocalDateTime + +@Service +internal class WeatherForecastService(private val cs: CoroutineScope) { + private val _weatherForecast: MutableStateFlow = MutableStateFlow(WeatherForecastData.EMPTY) + + val weatherForecast: StateFlow = _weatherForecast.asStateFlow() + + fun loadWeatherForecastFor(location: Location) { + cs.launch(Dispatchers.IO) { + // TODO Cache data + emit(getWeatherData(location)) + } + } + + private fun emit(weatherData: WeatherForecastData) { + _weatherForecast.value = weatherData + } + + /** + * 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 temperature = (-10..40).random().toFloat() + val windSpeed = (0..30).random().toFloat() + val humidity = (30..90).random() + + delay(100) + + return WeatherForecastData( + cityName = location.name, + temperature = temperature, + currentTime = LocalDateTime.now(), + windSpeed = windSpeed, + windDirection = WindDirection.random(), + humidity = humidity, + weatherType = WeatherType.random() + ) + } +} \ No newline at end of file From 9be508b0b2a2f1b21a6fa88a939bea0c9416169e Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 14:55:29 +0200 Subject: [PATCH 09/71] Add MyLocationViewModel implementation --- .../services/MyLocationsViewModel.kt | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt new file mode 100644 index 0000000..a08e821 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt @@ -0,0 +1,97 @@ +package org.jetbrains.plugins.template.weatherApp.services + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import org.jetbrains.plugins.template.weatherApp.model.Location +import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation +import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData + + +/** + * 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. + */ +internal interface MyLocationsViewModelApi { + fun onAddLocation(locationToAdd: Location) + + fun onDeleteLocation(locationToDelete: Location) + + fun onLocationSelected(selectedLocationIndex: Int) + + val myLocationsFlow: Flow> +} + +/** + * Interface representing a ViewModel for managing weather-related data + * and user interactions. + */ +internal interface WeatherViewModelApi { + val weatherForecast: Flow + + fun onLoadWeatherForecast(location: Location) + + fun onReloadWeatherForecast() +} + +@Service +internal class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelApi, WeatherViewModelApi { + + private val weatherService = service() + + private val myLocations = MutableStateFlow(listOf(Location("Munich", "Germany"))) + + private val selectedLocationIndex = MutableStateFlow(myLocations.value.lastIndex) + + override val weatherForecast: Flow = weatherService.weatherForecast + + override val myLocationsFlow: StateFlow> = myLocations + .combine(selectedLocationIndex) { locations, selectedIndex -> + locations.mapIndexed { index, location -> + SelectableLocation(location, index == selectedIndex) + } + }.stateIn(cs, SharingStarted.WhileSubscribed(), emptyList()) + + init { + onReloadWeatherForecast() + } + + override fun onAddLocation(locationToAdd: Location) { + if (myLocations.value.contains(locationToAdd)) { + selectedLocationIndex.value = myLocations.value.indexOf(locationToAdd) + } else { + myLocations.value += locationToAdd + selectedLocationIndex.value = myLocations.value.lastIndex + } + } + + override fun onDeleteLocation(locationToDelete: Location) { + myLocations.value -= locationToDelete + + val itemIndex = myLocations.value.indexOf(locationToDelete) + val currentSelectedIndex = selectedLocationIndex.value + if (itemIndex in 0..currentSelectedIndex) { + selectedLocationIndex.value = (currentSelectedIndex - 1).coerceAtLeast(0) + } + } + + override fun onLocationSelected(selectedLocationIndex: Int) { + this.selectedLocationIndex.value = selectedLocationIndex + + onReloadWeatherForecast() + } + + override fun onReloadWeatherForecast() { + myLocations.value.getOrNull(selectedLocationIndex.value)?.let { location -> + onLoadWeatherForecast(location) + } + } + + override fun onLoadWeatherForecast(location: Location) { + weatherService.loadWeatherForecastFor(location) + } +} \ No newline at end of file From fad9228e42bed7e340b8fc060110319b5a238d2f Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 14:55:58 +0200 Subject: [PATCH 10/71] Add SearchBarWithAutoCompletion widget implementation --- .../components/SearchBarWithAutoCompletion.kt | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt 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..312c1bf --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -0,0 +1,283 @@ +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.* +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +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.onGloballyPositioned +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 androidx.compose.ui.zIndex +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onEach +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.weatherApp.model.PreviewableItem +import org.jetbrains.plugins.template.weatherApp.model.Searchable +import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider + +@OptIn(ExperimentalJewelApi::class) +@Composable +internal fun SearchBarWithAutoCompletion( + modifier: Modifier = Modifier, + searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, + textFieldState: TextFieldState = rememberTextFieldState(""), + searchFieldPlaceholder: String = "Type a place name...", + onInputCleared: () -> Unit = {}, + onItemAutocomplete: (T) -> Unit = {}, +) where T : Searchable, T : PreviewableItem { + val focusRequester = remember { FocusRequester() } + + val popupController = remember { CompletionPopupController(searchAutoCompletionItemProvider) } + + LaunchedEffect(Unit) { + snapshotFlow { textFieldState.text.toString() } + .onEach { println("Text changed: $it") } + .distinctUntilChanged() + .collect { searchTerm -> popupController.onQueryChanged(searchTerm) } + } + + Column(modifier = modifier) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + var textFieldWidth by remember { mutableIntStateOf(-1) } + TextField( + state = textFieldState, + modifier = Modifier + .onGloballyPositioned { coordinates -> textFieldWidth = coordinates.size.width } + .fillMaxWidth() + .handlePopupCompletionKeyEvents(popupController) { item -> + textFieldState.setTextAndPlaceCursorAtEnd(item.label) + onItemAutocomplete(item) + } + .focusRequester(focusRequester), + placeholder = { Text(searchFieldPlaceholder) }, + leadingIcon = { + Icon(AllIconsKeys.Actions.Find, contentDescription = "Find icon", Modifier.padding(end = 8.dp)) + }, + trailingIcon = { + if (textFieldState.text.isNotBlank()) { + CloseIconButton { + onInputCleared() + 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() }) + .wrapContentHeight() + .padding(vertical = 4.dp, horizontal = 2.dp) + .zIndex(5f), + popupProperties = PopupProperties(focusable = false), + ) { + popupController.filteredItems.forEach { item -> + selectableItem( + popupController.isItemSelected(item), + onClick = { + onItemAutocomplete(item) + popupController.onItemAutocompleteConfirmed() + textFieldState.setTextAndPlaceCursorAtEnd(item.label) + }, + ) { + Text(item.label) + } + } + } + } + } + } +} + +@Composable +internal 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 = "Clear", + modifier = Modifier.pointerHoverIcon(PointerIcon.Default).clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + ) { onClick() }, + ) +} + +private class CompletionPopupController( + private val itemsProvider: SearchAutoCompletionItemProvider, +) { + var selectedItemIndex by mutableIntStateOf(0) + private set + + /** + * 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) + var filteredItems by mutableStateOf(emptyList()) + private set + + val selectedItem: T + get() = filteredItems[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) { + println("Skipping opening the dropdown, because item was just autocompleted.") + + skipPopupShowing = false + return + } + + if (searchTerm.isEmpty()) { + println("Hiding popup, because query is empty.") + + hidePopup() + + return + } + + updateFilteredItems(itemsProvider.provideSearchableItems(searchTerm)) + moveSelectionToFirstItem() + + if (filteredItems.isNotEmpty()) { + println("Showing popup, because there are items matching the query.") + showPopup() + } else { + println("Hiding popup, because there are no items matching the query.") + hidePopup() + } + } + + private fun showPopup() { + isVisible = true + } + + private fun hidePopup() { + isVisible = false + } + + fun reset() { + moveSelectionToFirstItem() + hidePopup() + clearFilteredItems() + } + + fun isItemSelected(item: T): Boolean = (filteredItems[selectedItemIndex] == item) + + fun onItemAutocompleteConfirmed(): T { + val selectedItem = this.selectedItem + + skipPopupShowing = true + + reset() + + return selectedItem + } + + private fun updateFilteredItems(filteredItems: List) { + this.filteredItems = filteredItems + } + + private fun clearFilteredItems() { + filteredItems = emptyList() + } + + private fun moveSelectionToFirstItem() { + moveSelectionTo(0) + } + + private fun moveSelectionTo(index: Int) { + selectedItemIndex = index + } + + private fun normalizeIndex(index: Int) = index.coerceIn(0..filteredItems.lastIndex) +} + +/** + * Handles navigation keyboard key events for the completion popup. + */ +private fun Modifier.handlePopupCompletionKeyEvents( + popupController: CompletionPopupController, + onItemAutocompleteConfirmed: (T) -> Unit = {}, +): Modifier { + return onPreviewKeyEvent { keyEvent -> + if (keyEvent.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + + println("${keyEvent.key} key is pressed") + + return@onPreviewKeyEvent when (keyEvent.key) { + Key.Tab, Key.Enter, Key.NumPadEnter -> { + onItemAutocompleteConfirmed(popupController.onItemAutocompleteConfirmed()) + true + } + + Key.DirectionUp -> { + popupController.onSelectionMovedUp() + true + } + + Key.DirectionDown -> { + popupController.onSelectionMovedDown() + true + } + + Key.Escape -> { + popupController.reset() + true + } + + else -> false + } + } +} \ No newline at end of file From 6fd30b231d1361e49d47569f81f9ec44c8074be2 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 15:00:00 +0200 Subject: [PATCH 11/71] Add SearchToolbarMenu widget implementation --- .../ui/components/SearchToolbarMenu.kt | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/SearchToolbarMenu.kt 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..3931f2f --- /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 +internal 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("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, + onInputCleared = { + isConfirmButtonVisible.value = false + previewItem.value = null + }, + onItemAutocomplete = { autocompletedItem -> + isConfirmButtonVisible.value = true + previewItem.value = autocompletedItem + onSearchPerformed(autocompletedItem) + }, + ) + } +} \ No newline at end of file From aeef1e7811ff389d3ab14a8eb1bc8786d7337b18 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 15:06:14 +0200 Subject: [PATCH 12/71] Add WeatherDetailsCard widget implementation --- .../services/WeatherForecastService.kt | 12 +- .../ui/components/WeatherDetailsCard.kt | 196 ++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt 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 index 8d00dda..3e18243 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt @@ -12,7 +12,10 @@ import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData import org.jetbrains.plugins.template.weatherApp.model.WeatherType import org.jetbrains.plugins.template.weatherApp.model.WindDirection +import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime +import kotlin.random.Random @Service internal class WeatherForecastService(private val cs: CoroutineScope) { @@ -45,11 +48,18 @@ internal class WeatherForecastService(private val cs: CoroutineScope) { return WeatherForecastData( cityName = location.name, temperature = temperature, - currentTime = LocalDateTime.now(), + currentTime = LocalDateTime.of(LocalDate.now(), getRandomTime()), windSpeed = windSpeed, windDirection = WindDirection.random(), humidity = humidity, weatherType = WeatherType.random() ) } + + 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/components/WeatherDetailsCard.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt new file mode 100644 index 0000000..ab74995 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt @@ -0,0 +1,196 @@ +package org.jetbrains.plugins.template.weatherApp.ui.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.icons.AllIconsKeys +import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData +import org.jetbrains.plugins.template.weatherApp.model.WeatherType +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import kotlin.random.Random + +/** + * A composable function that displays a weather card with Jewel theme. + * The card displays city name, temperature, current time, wind information, + * humidity, and a background icon representing the weather state. + * The card and text color change based on temperature and time of day. + * + * @param weatherForecastData The weather data to display + * @param modifier Additional modifier for the card + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun WeatherDetailsCard( + modifier: Modifier = Modifier, + weatherForecastData: WeatherForecastData, + onReloadWeatherData: () -> Unit +) { + val isNightTime = isNightTime(weatherForecastData.currentTime) + val cardColor = getCardColorByTemperature(weatherForecastData.temperature, isNightTime) + val textColor = Color.White + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(cardColor) + .padding(16.dp) + ) { + + // Card content + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // City name + Text( + text = weatherForecastData.cityName, + color = textColor, + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + + ActionButton( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color.Transparent) + .padding(8.dp), + tooltip = { Text("Refresh weather data") }, + onClick = { onReloadWeatherData() }, + ) { + 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 + ) { + // Temperature (emphasized) + Text( + text = "${weatherForecastData.temperature.toInt()}°C", + color = textColor, + fontSize = 48.sp, + fontWeight = FontWeight.ExtraBold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Weather type + Text( + text = weatherForecastData.weatherType.label, + color = textColor, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Current time + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + // Simple text for time + Text( + text = "Time: ${formatDateTime(weatherForecastData.currentTime)}", + color = textColor, + fontSize = 16.sp + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Wind and humidity info + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Wind info + Text( + text = "Wind: ${weatherForecastData.windSpeed.toInt()} km/h ${weatherForecastData.windDirection.label}", + color = textColor, + fontSize = 16.sp + ) + + // Humidity info + Text( + text = "Humidity: ${weatherForecastData.humidity}%", + color = textColor, + fontSize = 16.sp + ) + } + } + } +} + +/** + * Returns a color for the weather type indicator. + */ +fun getWeatherTypeColor(weatherType: WeatherType, baseColor: Color): Color { + return when (weatherType) { + WeatherType.SUNNY -> Color.Yellow.copy(alpha = 0.2f) + WeatherType.CLOUDY -> Color.Gray.copy(alpha = 0.2f) + WeatherType.PARTLY_CLOUDY -> Color.LightGray.copy(alpha = 0.2f) + WeatherType.RAINY -> Color.Blue.copy(alpha = 0.2f) + WeatherType.SNOWY -> Color.White.copy(alpha = 0.3f) + WeatherType.STORMY -> Color.DarkGray.copy(alpha = 0.2f) + } +} + +/** + * 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 = Random.nextInt(0, 24) + return hour < 6 || hour >= 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 -> Color(0xFF1A237E) // Dark blue for night + temperature < 0 -> Color(0xFF3F51B5) // Cold: blue + temperature < 10 -> Color(0xFF5E35B1) // Cool: purple + temperature < 20 -> Color(0xFF039BE5) // Mild: light blue + temperature < 30 -> Color(0xFFFF9800) // Warm: orange + else -> Color(0xFFE91E63) // Hot: pink/red + } +} + +/** + * Formats the date time to a readable string. + */ +fun formatDateTime(dateTime: LocalDateTime): String { + val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm") + return dateTime.format(formatter) +} \ No newline at end of file From 2c94191e82924d5742a604519c649d359818dd0e Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 15:07:48 +0200 Subject: [PATCH 13/71] Add simple WeatherAppSample implementation --- .../weatherApp/ui/WeatherAppSample.kt | 141 ++++++++++++++++-- 1 file changed, 128 insertions(+), 13 deletions(-) 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 index 69fb0e9..6161758 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -1,22 +1,137 @@ package org.jetbrains.plugins.template.weatherApp.ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.intellij.openapi.components.service +import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified +import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn +import org.jetbrains.jewel.foundation.lazy.SelectionMode +import org.jetbrains.jewel.foundation.lazy.items +import org.jetbrains.jewel.foundation.lazy.rememberSelectableLazyListState import org.jetbrains.jewel.foundation.theme.JewelTheme -import org.jetbrains.jewel.ui.component.Text - +import org.jetbrains.jewel.ui.component.* +import org.jetbrains.plugins.template.weatherApp.model.Location +import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData +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() { - Column(Modifier - .fillMaxWidth() - .heightIn(20.dp) - .padding(16.dp)) { - Text( - "Not yet implemented", - style = JewelTheme.defaultTextStyle - ) +internal fun WeatherAppSample() { + val viewModel: MyLocationsViewModel = service() + val searchAutoCompletionItemProvided = service() + + HorizontalSplitLayout( + first = { LeftColumn(viewModel, modifier = Modifier.fillMaxSize()) }, + second = { + RightColumn( + viewModel, + viewModel, + searchAutoCompletionItemProvided, + modifier = Modifier.fillMaxSize() + ) + }, + modifier = Modifier.fillMaxSize(), + firstPaneMinWidth = 100.dp, + secondPaneMinWidth = 300.dp, + state = rememberSplitLayoutState(.2f) + ) +} + +@Composable +private fun LeftColumn( + myLocationsViewModelApi: MyLocationsViewModelApi, + modifier: Modifier = Modifier, +) { + val myLocations = myLocationsViewModelApi.myLocationsFlow.collectAsState(emptyList()).value + + // TODO Set selected item on initial showing + + Column(modifier.fillMaxSize().padding(8.dp)) { + GroupHeader("My Locations", modifier = Modifier.padding(bottom = 8.dp)) + + Spacer(modifier = Modifier.height(4.dp)) + + val listState = rememberSelectableLazyListState() + + SelectableLazyColumn( + modifier = modifier.fillMaxSize(), + selectionMode = SelectionMode.Single, + state = listState, + onSelectedIndexesChange = { indices -> + val selectedLocationIndex = indices.firstOrNull() ?: return@SelectableLazyColumn + myLocationsViewModelApi.onLocationSelected(selectedLocationIndex) + }, + ) { + items( + items = myLocations, + key = { item -> item }, + contentType = { item -> item.location }, + ) { item -> + + ContentItemRow( + item = item.location, isSelected = item.isSelected, isActive = isActive + ) + } + } } -} \ No newline at end of file +} + +@Composable +private fun ContentItemRow(item: Location, isSelected: Boolean, isActive: Boolean) { + val color = when { + isSelected && isActive -> retrieveColorOrUnspecified("List.selectionBackground") + isSelected && !isActive -> retrieveColorOrUnspecified("List.selectionInactiveBackground") + else -> Transparent + } + Row( + modifier = Modifier + .height(JewelTheme.globalMetrics.rowHeight) + .background(color) + .padding(horizontal = 4.dp) + .padding(end = scrollbarContentSafePadding()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(text = item.id, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) + } +} + +@Composable +private fun RightColumn( + myLocationViewModel: MyLocationsViewModelApi, + weatherViewModelApi: WeatherViewModelApi, + searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, + modifier: Modifier = Modifier, +) { + val weatherForecastData = weatherViewModelApi.weatherForecast.collectAsState(WeatherForecastData.EMPTY).value + + Column(modifier.fillMaxWidth().padding(8.dp)) { + SearchToolbarMenu( + searchAutoCompletionItemProvider = searchAutoCompletionItemProvider, + confirmButtonText = "Add", + onSearchPerformed = { place -> + weatherViewModelApi.onLoadWeatherForecast(place) + }, + onSearchConfirmed = { place -> + myLocationViewModel.onAddLocation(place) + } + ) + + WeatherDetailsCard( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally), // optional for positioning + weatherForecastData + ) { + weatherViewModelApi.onReloadWeatherForecast() + } + } +} From 08abaebbd8eb806a1fff56355022d34ca80c2b1f Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 23 Jul 2025 15:08:35 +0200 Subject: [PATCH 14/71] fixup! Add SearchToolbarMenu widget implementation --- src/main/resources/messages/ComposeTemplate.properties | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/resources/messages/ComposeTemplate.properties b/src/main/resources/messages/ComposeTemplate.properties index cf30aa4..55e0807 100644 --- a/src/main/resources/messages/ComposeTemplate.properties +++ b/src/main/resources/messages/ComposeTemplate.properties @@ -3,4 +3,6 @@ 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 +action.dev.sebastiano.jewel.ijplugin.demo.text=Jewel Demo Dialog + +search.toolbar.menu.add.button.content.description=Add a place to a watch list. \ No newline at end of file From 34dbbf2af3166cf7976f82b88f066a904cbce6f5 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 25 Jul 2025 14:42:57 +0200 Subject: [PATCH 15/71] Change cityName parameter to location in WeatherForecastData.kt --- .../template/weatherApp/model/WeatherForecastData.kt | 6 +++--- .../template/weatherApp/services/WeatherForecastService.kt | 2 +- .../template/weatherApp/ui/components/WeatherDetailsCard.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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 index 84a423f..5a39cc0 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt @@ -5,8 +5,8 @@ import java.time.LocalDateTime /** * Data class representing weather information to be displayed in the Weather Card. */ -data class WeatherForecastData( - val cityName: String, +internal data class WeatherForecastData( + val location: Location, val temperature: Float, val currentTime: LocalDateTime, val windSpeed: Float, @@ -16,7 +16,7 @@ data class WeatherForecastData( ) { companion object Companion { val EMPTY: WeatherForecastData = WeatherForecastData( - "", + Location("", ""), 0f, LocalDateTime.now(), 0f, 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 index 3e18243..025f9f1 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt @@ -46,7 +46,7 @@ internal class WeatherForecastService(private val cs: CoroutineScope) { delay(100) return WeatherForecastData( - cityName = location.name, + location = location, temperature = temperature, currentTime = LocalDateTime.of(LocalDate.now(), getRandomTime()), windSpeed = windSpeed, 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 index ab74995..7f1de0b 100644 --- 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 @@ -60,7 +60,7 @@ internal fun WeatherDetailsCard( ) { // City name Text( - text = weatherForecastData.cityName, + text = weatherForecastData.location.id, color = textColor, fontSize = 28.sp, fontWeight = FontWeight.Bold From 74d388a813685242f6d1d44d934771cb1f2f1584 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 25 Jul 2025 14:47:08 +0200 Subject: [PATCH 16/71] Add icon resources and WeatherIcons accessor --- .../template/weatherApp/ui/WeatherIcons.kt | 63 +++++++ .../resources/icons/weather/angry_clouds.svg | 25 +++ src/main/resources/icons/weather/cloudy.svg | 25 +++ .../resources/icons/weather/day_clear.svg | 86 ++++++++++ .../icons/weather/day_partial_cloud.svg | 104 +++++++++++ src/main/resources/icons/weather/day_rain.svg | 111 ++++++++++++ .../icons/weather/day_rain_thunder.svg | 128 ++++++++++++++ .../resources/icons/weather/day_sleet.svg | 129 ++++++++++++++ src/main/resources/icons/weather/day_snow.svg | 133 +++++++++++++++ .../icons/weather/day_snow_thunder.svg | 161 ++++++++++++++++++ src/main/resources/icons/weather/fog.svg | 32 ++++ src/main/resources/icons/weather/mist.svg | 30 ++++ .../icons/weather/night_full_moon_clear.svg | 41 +++++ .../weather/night_full_moon_partial_cloud.svg | 59 +++++++ .../icons/weather/night_full_moon_rain.svg | 66 +++++++ .../weather/night_full_moon_rain_thunder.svg | 82 +++++++++ .../icons/weather/night_full_moon_sleet.svg | 84 +++++++++ .../icons/weather/night_full_moon_snow.svg | 88 ++++++++++ .../weather/night_full_moon_snow_thunder.svg | 116 +++++++++++++ .../icons/weather/night_half_moon_clear.svg | 31 ++++ .../weather/night_half_moon_partial_cloud.svg | 56 ++++++ .../icons/weather/night_half_moon_rain.svg | 63 +++++++ .../weather/night_half_moon_rain_thunder.svg | 78 +++++++++ .../icons/weather/night_half_moon_sleet.svg | 81 +++++++++ .../icons/weather/night_half_moon_snow.svg | 85 +++++++++ .../weather/night_half_moon_snow_thunder.svg | 112 ++++++++++++ src/main/resources/icons/weather/overcast.svg | 59 +++++++ src/main/resources/icons/weather/rain.svg | 32 ++++ .../resources/icons/weather/rain_thunder.svg | 47 +++++ src/main/resources/icons/weather/sleet.svg | 50 ++++++ src/main/resources/icons/weather/snow.svg | 54 ++++++ .../resources/icons/weather/snow_thunder.svg | 81 +++++++++ src/main/resources/icons/weather/thunder.svg | 76 +++++++++ src/main/resources/icons/weather/tornado.svg | 100 +++++++++++ src/main/resources/icons/weather/wind.svg | 33 ++++ 35 files changed, 2601 insertions(+) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherIcons.kt create mode 100644 src/main/resources/icons/weather/angry_clouds.svg create mode 100644 src/main/resources/icons/weather/cloudy.svg create mode 100644 src/main/resources/icons/weather/day_clear.svg create mode 100644 src/main/resources/icons/weather/day_partial_cloud.svg create mode 100644 src/main/resources/icons/weather/day_rain.svg create mode 100644 src/main/resources/icons/weather/day_rain_thunder.svg create mode 100644 src/main/resources/icons/weather/day_sleet.svg create mode 100644 src/main/resources/icons/weather/day_snow.svg create mode 100644 src/main/resources/icons/weather/day_snow_thunder.svg create mode 100644 src/main/resources/icons/weather/fog.svg create mode 100644 src/main/resources/icons/weather/mist.svg create mode 100644 src/main/resources/icons/weather/night_full_moon_clear.svg create mode 100644 src/main/resources/icons/weather/night_full_moon_partial_cloud.svg create mode 100644 src/main/resources/icons/weather/night_full_moon_rain.svg create mode 100644 src/main/resources/icons/weather/night_full_moon_rain_thunder.svg create mode 100644 src/main/resources/icons/weather/night_full_moon_sleet.svg create mode 100644 src/main/resources/icons/weather/night_full_moon_snow.svg create mode 100644 src/main/resources/icons/weather/night_full_moon_snow_thunder.svg create mode 100644 src/main/resources/icons/weather/night_half_moon_clear.svg create mode 100644 src/main/resources/icons/weather/night_half_moon_partial_cloud.svg create mode 100644 src/main/resources/icons/weather/night_half_moon_rain.svg create mode 100644 src/main/resources/icons/weather/night_half_moon_rain_thunder.svg create mode 100644 src/main/resources/icons/weather/night_half_moon_sleet.svg create mode 100644 src/main/resources/icons/weather/night_half_moon_snow.svg create mode 100644 src/main/resources/icons/weather/night_half_moon_snow_thunder.svg create mode 100644 src/main/resources/icons/weather/overcast.svg create mode 100644 src/main/resources/icons/weather/rain.svg create mode 100644 src/main/resources/icons/weather/rain_thunder.svg create mode 100644 src/main/resources/icons/weather/sleet.svg create mode 100644 src/main/resources/icons/weather/snow.svg create mode 100644 src/main/resources/icons/weather/snow_thunder.svg create mode 100644 src/main/resources/icons/weather/thunder.svg create mode 100644 src/main/resources/icons/weather/tornado.svg create mode 100644 src/main/resources/icons/weather/wind.svg 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/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 @@ + + + + + + + + + + + + + + + + + From 74db9cd93b2f371bac7cd98d6f252a1c17cca715 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 25 Jul 2025 14:47:31 +0200 Subject: [PATCH 17/71] Use weather icons in a WeatherDetailsCard --- .../weatherApp/model/WeatherForecastData.kt | 31 +++++++--- .../ui/components/WeatherDetailsCard.kt | 56 ++++++++++--------- 2 files changed, 53 insertions(+), 34 deletions(-) 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 index 5a39cc0..71e0e47 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt @@ -1,5 +1,7 @@ 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 /** @@ -22,7 +24,7 @@ internal data class WeatherForecastData( 0f, WindDirection.NORTH, 0, - WeatherType.SUNNY + WeatherType.CLEAR ) } } @@ -30,13 +32,26 @@ internal data class WeatherForecastData( /** * Enum representing different weather types. */ -enum class WeatherType(val label: String) { - SUNNY("Sunny"), - CLOUDY("Cloudy"), - PARTLY_CLOUDY("Partly Cloudy"), - RAINY("Rainy"), - SNOWY("Snowy"), - STORMY("Stormy"); +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() 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 index 7f1de0b..a839cdb 100644 --- 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 @@ -12,12 +12,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.ActionButton +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData import org.jetbrains.plugins.template.weatherApp.model.WeatherType +import org.jetbrains.plugins.template.weatherApp.ui.WeatherIcons import java.time.LocalDateTime -import java.time.LocalTime import java.time.format.DateTimeFormatter import kotlin.random.Random @@ -58,11 +61,11 @@ internal fun WeatherDetailsCard( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - // City name + // Current Time Text( - text = weatherForecastData.location.id, + text = "Time: ${formatDateTime(weatherForecastData.currentTime)}", color = textColor, - fontSize = 28.sp, + fontSize = JewelTheme.defaultTextStyle.fontSize, fontWeight = FontWeight.Bold ) @@ -81,6 +84,7 @@ internal fun WeatherDetailsCard( ) } } + Spacer(modifier = Modifier.height(16.dp)) // Temperature and weather type column (vertically aligned) @@ -88,42 +92,37 @@ internal fun WeatherDetailsCard( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { + + Icon( + key = WeatherIcons.cloudy, +// key = if (isNightTime) weatherForecastData.weatherType.nightIconKey else weatherForecastData.weatherType.dayIconKey, + contentDescription = weatherForecastData.weatherType.label, + hint = CssStyleInlinerSvgPatchHint + ) + + Spacer(modifier = Modifier.height(8.dp)) + // Temperature (emphasized) Text( text = "${weatherForecastData.temperature.toInt()}°C", color = textColor, - fontSize = 48.sp, + fontSize = 32.sp, fontWeight = FontWeight.ExtraBold ) Spacer(modifier = Modifier.height(8.dp)) - // Weather type + // City name Text( - text = weatherForecastData.weatherType.label, + text = weatherForecastData.location.label, color = textColor, - fontSize = 24.sp, + fontSize = 18.sp, fontWeight = FontWeight.Bold ) } Spacer(modifier = Modifier.height(16.dp)) - // Current time - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - // Simple text for time - Text( - text = "Time: ${formatDateTime(weatherForecastData.currentTime)}", - color = textColor, - fontSize = 16.sp - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - // Wind and humidity info Row( modifier = Modifier.fillMaxWidth(), @@ -152,12 +151,17 @@ internal fun WeatherDetailsCard( */ fun getWeatherTypeColor(weatherType: WeatherType, baseColor: Color): Color { return when (weatherType) { - WeatherType.SUNNY -> Color.Yellow.copy(alpha = 0.2f) + WeatherType.CLEAR -> Color.Yellow.copy(alpha = 0.2f) WeatherType.CLOUDY -> Color.Gray.copy(alpha = 0.2f) WeatherType.PARTLY_CLOUDY -> Color.LightGray.copy(alpha = 0.2f) + WeatherType.RAINY_AND_THUNDER, WeatherType.RAINY -> Color.Blue.copy(alpha = 0.2f) + WeatherType.SNOWY -> Color.White.copy(alpha = 0.3f) - WeatherType.STORMY -> Color.DarkGray.copy(alpha = 0.2f) + WeatherType.TORNADO -> Color.DarkGray.copy(alpha = 0.2f) + WeatherType.THUNDER -> Color.DarkGray.copy(alpha = 0.2f) + WeatherType.FOG -> Color.LightGray.copy(alpha = 0.2f) + WeatherType.MIST -> Color.LightGray.copy(alpha = 0.2f) } } From 593ce91bc17aa8b98ca5e5197676108616be98d7 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 25 Jul 2025 15:21:25 +0200 Subject: [PATCH 18/71] Support embedded CSS styles in SVG by inlining the SVG styles in EmbeddedToInlineCssSvgTransformerHint --- .../EmbeddedToInlineCssSvgTransformerHint.kt | 179 ++++++++++++++++++ .../ui/components/WeatherDetailsCard.kt | 2 +- 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/EmbeddedToInlineCssSvgTransformerHint.kt 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..33352ff --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/EmbeddedToInlineCssSvgTransformerHint.kt @@ -0,0 +1,179 @@ +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) { + val processedElement = element.inlineEmbeddedStylesCSS() + + println(PrintableElement(processedElement).writeToString()) + } + + 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/WeatherDetailsCard.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt index a839cdb..116700e 100644 --- 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 @@ -97,7 +97,7 @@ internal fun WeatherDetailsCard( key = WeatherIcons.cloudy, // key = if (isNightTime) weatherForecastData.weatherType.nightIconKey else weatherForecastData.weatherType.dayIconKey, contentDescription = weatherForecastData.weatherType.label, - hint = CssStyleInlinerSvgPatchHint + hint = EmbeddedToInlineCssSvgTransformerHint ) Spacer(modifier = Modifier.height(8.dp)) From 6f479416367a4dafe23297d5fc2e73449f2c1b45 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Mon, 28 Jul 2025 14:56:42 +0200 Subject: [PATCH 19/71] Bump IntelliJ Platform Gradle Plugin version to 2.7.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5879184..deeb2f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ opentest4j = "1.3.0" # plugins changelog = "2.2.1" -intelliJPlatform = "2.5.0" +intelliJPlatform = "2.7.0" kotlin = "2.1.20" kover = "0.9.1" qodana = "2024.3.4" From 2486e307106fde9dc16b60dd8c740cb4685d807b Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Mon, 28 Jul 2025 15:49:59 +0200 Subject: [PATCH 20/71] Pass viewmodels as a composable param --- .../toolWindow/MyToolWindowFactory.kt | 13 ++++++++++- .../weatherApp/ui/WeatherAppSample.kt | 23 ++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt index e45a92b..b0fe66f 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt @@ -1,17 +1,28 @@ package org.jetbrains.plugins.template.toolWindow +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 org.jetbrains.jewel.bridge.addComposeTab import org.jetbrains.plugins.template.ui.ChatAppSample +import org.jetbrains.plugins.template.weatherApp.services.LocationsProvider +import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModel import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppSample class MyToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - toolWindow.addComposeTab("Weather App") { WeatherAppSample() } + toolWindow.addComposeTab("Weather App") { + val viewModel = service() + val locationProviderApi = service() + WeatherAppSample( + viewModel, + viewModel, + locationProviderApi + ) + } toolWindow.addComposeTab("Chat App") { ChatAppSample() } } 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 index 6161758..364726d 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -3,13 +3,13 @@ package org.jetbrains.plugins.template.weatherApp.ui import androidx.compose.foundation.background 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.Companion.Transparent import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.intellij.openapi.components.service import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn import org.jetbrains.jewel.foundation.lazy.SelectionMode @@ -24,16 +24,17 @@ import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard @Composable -internal fun WeatherAppSample() { - val viewModel: MyLocationsViewModel = service() - val searchAutoCompletionItemProvided = service() - +internal fun WeatherAppSample( + myLocationViewModel: MyLocationsViewModelApi, + weatherViewModelApi: WeatherViewModelApi, + searchAutoCompletionItemProvided: LocationsProvider +) { HorizontalSplitLayout( - first = { LeftColumn(viewModel, modifier = Modifier.fillMaxSize()) }, + first = { LeftColumn(myLocationViewModel, modifier = Modifier.fillMaxSize()) }, second = { RightColumn( - viewModel, - viewModel, + myLocationViewModel, + weatherViewModelApi, searchAutoCompletionItemProvided, modifier = Modifier.fillMaxSize() ) @@ -60,6 +61,12 @@ private fun LeftColumn( Spacer(modifier = Modifier.height(4.dp)) val listState = rememberSelectableLazyListState() + LaunchedEffect(myLocations) { + listState + .selectedKeys = myLocations + .mapIndexedNotNull { index, item -> if (item.isSelected) index else null } + .toSet() + } SelectableLazyColumn( modifier = modifier.fillMaxSize(), From 5c5267778bc45fec5926c392a1775456d64540b5 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Mon, 28 Jul 2025 15:50:36 +0200 Subject: [PATCH 21/71] Rename MyLocationsViewModel to WeatherAppViewModel --- .../plugins/template/toolWindow/MyToolWindowFactory.kt | 4 ++-- .../{MyLocationsViewModel.kt => WeatherAppViewModel.kt} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/{MyLocationsViewModel.kt => WeatherAppViewModel.kt} (96%) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt index b0fe66f..8e3bfd2 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt @@ -8,14 +8,14 @@ import com.intellij.openapi.wm.ToolWindowFactory import org.jetbrains.jewel.bridge.addComposeTab import org.jetbrains.plugins.template.ui.ChatAppSample import org.jetbrains.plugins.template.weatherApp.services.LocationsProvider -import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModel +import org.jetbrains.plugins.template.weatherApp.services.WeatherAppViewModel import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppSample class MyToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { toolWindow.addComposeTab("Weather App") { - val viewModel = service() + val viewModel = service() val locationProviderApi = service() WeatherAppSample( viewModel, diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt similarity index 96% rename from src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt rename to src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt index a08e821..56aeea0 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt @@ -39,7 +39,7 @@ internal interface WeatherViewModelApi { } @Service -internal class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelApi, WeatherViewModelApi { +internal class WeatherAppViewModel(cs: CoroutineScope) : MyLocationsViewModelApi, WeatherViewModelApi { private val weatherService = service() From 4a5b2f32f6f9994680db66d080927cc158499f7c Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Mon, 28 Jul 2025 16:19:57 +0200 Subject: [PATCH 22/71] Rename MyLocationsViewModel to WeatherAppViewModel --- .../plugins/template/toolWindow/MyToolWindowFactory.kt | 4 ++-- .../{WeatherAppViewModel.kt => MyLocationsViewModel.kt} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/{WeatherAppViewModel.kt => MyLocationsViewModel.kt} (96%) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt index 8e3bfd2..b0fe66f 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt @@ -8,14 +8,14 @@ import com.intellij.openapi.wm.ToolWindowFactory import org.jetbrains.jewel.bridge.addComposeTab import org.jetbrains.plugins.template.ui.ChatAppSample import org.jetbrains.plugins.template.weatherApp.services.LocationsProvider -import org.jetbrains.plugins.template.weatherApp.services.WeatherAppViewModel +import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModel import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppSample class MyToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { toolWindow.addComposeTab("Weather App") { - val viewModel = service() + val viewModel = service() val locationProviderApi = service() WeatherAppSample( viewModel, diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt similarity index 96% rename from src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt rename to src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt index 56aeea0..a08e821 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt @@ -39,7 +39,7 @@ internal interface WeatherViewModelApi { } @Service -internal class WeatherAppViewModel(cs: CoroutineScope) : MyLocationsViewModelApi, WeatherViewModelApi { +internal class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelApi, WeatherViewModelApi { private val weatherService = service() From 07ab7b2f0543f45d9a265405fbe7e3d6f7e6d43d Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Mon, 28 Jul 2025 16:44:48 +0200 Subject: [PATCH 23/71] Cleanup --- .../template/components/SearchBarWithAutoCompletion.kt | 9 --------- .../components/EmbeddedToInlineCssSvgTransformerHint.kt | 6 ++---- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index 312c1bf..a137c73 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -49,7 +49,6 @@ internal fun SearchBarWithAutoCompletion( LaunchedEffect(Unit) { snapshotFlow { textFieldState.text.toString() } - .onEach { println("Text changed: $it") } .distinctUntilChanged() .collect { searchTerm -> popupController.onQueryChanged(searchTerm) } } @@ -173,15 +172,11 @@ private class CompletionPopupController( fun onQueryChanged(searchTerm: String) { if (skipPopupShowing) { - println("Skipping opening the dropdown, because item was just autocompleted.") - skipPopupShowing = false return } if (searchTerm.isEmpty()) { - println("Hiding popup, because query is empty.") - hidePopup() return @@ -191,10 +186,8 @@ private class CompletionPopupController( moveSelectionToFirstItem() if (filteredItems.isNotEmpty()) { - println("Showing popup, because there are items matching the query.") showPopup() } else { - println("Hiding popup, because there are no items matching the query.") hidePopup() } } @@ -254,8 +247,6 @@ private fun Modifier.handlePopupCompletionKeyEvents( return onPreviewKeyEvent { keyEvent -> if (keyEvent.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - println("${keyEvent.key} key is pressed") - return@onPreviewKeyEvent when (keyEvent.key) { Key.Tab, Key.Enter, Key.NumPadEnter -> { onItemAutocompleteConfirmed(popupController.onItemAutocompleteConfirmed()) 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 index 33352ff..bf5bbd4 100644 --- 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 @@ -17,7 +17,7 @@ import javax.xml.transform.stream.StreamResult import javax.xml.xpath.XPathConstants import javax.xml.xpath.XPathFactory -object EmbeddedToInlineCssSvgTransformerHint : PainterSvgPatchHint { +internal object EmbeddedToInlineCssSvgTransformerHint : PainterSvgPatchHint { private val CSS_STYLEABLE_TAGS = listOf( "linearGradient", "radialGradient", "pattern", "filter", "clipPath", "mask", "symbol", @@ -25,9 +25,7 @@ object EmbeddedToInlineCssSvgTransformerHint : PainterSvgPatchHint { ) override fun PainterProviderScope.patch(element: Element) { - val processedElement = element.inlineEmbeddedStylesCSS() - - println(PrintableElement(processedElement).writeToString()) + element.inlineEmbeddedStylesCSS() } private fun Element.inlineEmbeddedStylesCSS(): Element { From 291a35b6bdcdf8cbf823bef83b160192d7224e22 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 29 Jul 2025 15:00:11 +0200 Subject: [PATCH 24/71] Extract Weather colors to WeatherAppColor object --- .../plugins/template/weatherApp/WeatherAppColors.kt | 12 ++++++++++++ .../weatherApp/ui/components/WeatherDetailsCard.kt | 12 ++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/weatherApp/WeatherAppColors.kt 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/ui/components/WeatherDetailsCard.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt index 116700e..efe8d66 100644 --- 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 @@ -182,12 +182,12 @@ fun isNightTime(dateTime: LocalDateTime): Boolean { */ fun getCardColorByTemperature(temperature: Float, isNightTime: Boolean): Color { return when { - isNightTime -> Color(0xFF1A237E) // Dark blue for night - temperature < 0 -> Color(0xFF3F51B5) // Cold: blue - temperature < 10 -> Color(0xFF5E35B1) // Cool: purple - temperature < 20 -> Color(0xFF039BE5) // Mild: light blue - temperature < 30 -> Color(0xFFFF9800) // Warm: orange - else -> Color(0xFFE91E63) // Hot: pink/red + isNightTime -> WeatherAppColors.nightWeatherColor + temperature < 0 -> WeatherAppColors.coldWeatherColor + temperature < 10 -> WeatherAppColors.coolWeatherColor + temperature < 20 -> WeatherAppColors.mildWeatherColor + temperature < 30 -> WeatherAppColors.warmWeatherColor + else -> WeatherAppColors.hotWeatherColor } } From b800d6bb1703c8d08e33cda18fb8f6f8e0fa04f6 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 29 Jul 2025 15:21:44 +0200 Subject: [PATCH 25/71] Cleanup modifier usages --- .../weatherApp/ui/WeatherAppSample.kt | 35 +++++++++++++------ .../ui/components/WeatherDetailsCard.kt | 1 - 2 files changed, 25 insertions(+), 11 deletions(-) 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 index 364726d..bb9095d 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -17,9 +17,13 @@ import org.jetbrains.jewel.foundation.lazy.items import org.jetbrains.jewel.foundation.lazy.rememberSelectableLazyListState import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* +import org.jetbrains.plugins.template.ComposeTemplateBundle import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData -import org.jetbrains.plugins.template.weatherApp.services.* +import org.jetbrains.plugins.template.weatherApp.services.LocationsProvider +import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi +import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider +import org.jetbrains.plugins.template.weatherApp.services.WeatherViewModelApi import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard @@ -30,16 +34,27 @@ internal fun WeatherAppSample( searchAutoCompletionItemProvided: LocationsProvider ) { HorizontalSplitLayout( - first = { LeftColumn(myLocationViewModel, modifier = Modifier.fillMaxSize()) }, + first = { + LeftColumn( + myLocationViewModel, + modifier = Modifier + .fillMaxSize() + .padding(start = 8.dp, end = 8.dp) + ) + }, second = { RightColumn( myLocationViewModel, weatherViewModelApi, searchAutoCompletionItemProvided, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .padding(start = 8.dp, end = 8.dp) ) }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(all = 8.dp), firstPaneMinWidth = 100.dp, secondPaneMinWidth = 300.dp, state = rememberSplitLayoutState(.2f) @@ -55,10 +70,10 @@ private fun LeftColumn( // TODO Set selected item on initial showing - Column(modifier.fillMaxSize().padding(8.dp)) { - GroupHeader("My Locations", modifier = Modifier.padding(bottom = 8.dp)) + Column(modifier) { + GroupHeader("My Locations", modifier = Modifier.wrapContentHeight().fillMaxWidth()) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(10.dp)) val listState = rememberSelectableLazyListState() LaunchedEffect(myLocations) { @@ -69,7 +84,7 @@ private fun LeftColumn( } SelectableLazyColumn( - modifier = modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), selectionMode = SelectionMode.Single, state = listState, onSelectedIndexesChange = { indices -> @@ -120,7 +135,7 @@ private fun RightColumn( ) { val weatherForecastData = weatherViewModelApi.weatherForecast.collectAsState(WeatherForecastData.EMPTY).value - Column(modifier.fillMaxWidth().padding(8.dp)) { + Column(modifier) { SearchToolbarMenu( searchAutoCompletionItemProvider = searchAutoCompletionItemProvider, confirmButtonText = "Add", @@ -135,7 +150,7 @@ private fun RightColumn( WeatherDetailsCard( modifier = Modifier .fillMaxWidth() - .align(Alignment.CenterHorizontally), // optional for positioning + .align(Alignment.CenterHorizontally), weatherForecastData ) { weatherViewModelApi.onReloadWeatherForecast() 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 index efe8d66..34e52d8 100644 --- 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 @@ -46,7 +46,6 @@ internal fun WeatherDetailsCard( Box( modifier = modifier - .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(cardColor) .padding(16.dp) From 01d981038c1c3033981f62080c67466ec4012ab7 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 29 Jul 2025 15:25:33 +0200 Subject: [PATCH 26/71] Add a 7-Days forecast widget --- .../weatherApp/model/WeatherForecastData.kt | 35 ++- .../services/WeatherForecastService.kt | 49 ++-- .../ui/components/WeatherDetailsCard.kt | 211 +++++++++++++++--- 3 files changed, 241 insertions(+), 54 deletions(-) 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 index 71e0e47..657d657 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt @@ -4,27 +4,38 @@ 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. + */ +internal 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. */ internal data class WeatherForecastData( val location: Location, - val temperature: Float, - val currentTime: LocalDateTime, - val windSpeed: Float, - val windDirection: WindDirection, - val humidity: Int, // Percentage - val weatherType: WeatherType + val currentWeatherForecast: DailyForecast, + val dailyForecasts: List = emptyList() ) { companion object Companion { val EMPTY: WeatherForecastData = WeatherForecastData( Location("", ""), - 0f, - LocalDateTime.now(), - 0f, - WindDirection.NORTH, - 0, - WeatherType.CLEAR + DailyForecast( + LocalDateTime.now(), + 0f, + WeatherType.CLEAR, + 0, + 0f, + WindDirection.NORTH, + ), + emptyList() ) } } 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 index 025f9f1..80786a7 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt @@ -8,10 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.jetbrains.plugins.template.weatherApp.model.Location -import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData -import org.jetbrains.plugins.template.weatherApp.model.WeatherType -import org.jetbrains.plugins.template.weatherApp.model.WindDirection +import org.jetbrains.plugins.template.weatherApp.model.* import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime @@ -39,23 +36,49 @@ internal class WeatherForecastService(private val cs: CoroutineScope) { * In a real application, this would fetch data from a weather API. */ private suspend fun getWeatherData(location: Location): WeatherForecastData { - val temperature = (-10..40).random().toFloat() - val windSpeed = (0..30).random().toFloat() - val humidity = (30..90).random() + val currentTime = LocalDateTime.of(LocalDate.now(), getRandomTime()) + + // Generate 7-day forecast data + val dailyForecasts = generateDailyForecasts(currentTime) delay(100) return WeatherForecastData( location = location, - temperature = temperature, - currentTime = LocalDateTime.of(LocalDate.now(), getRandomTime()), - windSpeed = windSpeed, - windDirection = WindDirection.random(), - humidity = humidity, - weatherType = WeatherType.random() + 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) 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 index 34e52d8..2daa515 100644 --- 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 @@ -2,7 +2,11 @@ package org.jetbrains.plugins.template.weatherApp.ui.components 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 @@ -10,19 +14,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.ActionButton +import org.jetbrains.jewel.ui.component.HorizontallyScrollableContainer import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.Text import org.jetbrains.jewel.ui.icons.AllIconsKeys +import org.jetbrains.plugins.template.weatherApp.WeatherAppColors +import org.jetbrains.plugins.template.weatherApp.model.DailyForecast import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData -import org.jetbrains.plugins.template.weatherApp.model.WeatherType -import org.jetbrains.plugins.template.weatherApp.ui.WeatherIcons import java.time.LocalDateTime import java.time.format.DateTimeFormatter -import kotlin.random.Random +import java.time.format.TextStyle +import java.util.* /** * A composable function that displays a weather card with Jewel theme. @@ -40,8 +47,9 @@ internal fun WeatherDetailsCard( weatherForecastData: WeatherForecastData, onReloadWeatherData: () -> Unit ) { - val isNightTime = isNightTime(weatherForecastData.currentTime) - val cardColor = getCardColorByTemperature(weatherForecastData.temperature, isNightTime) + val currentWeatherForecast = weatherForecastData.currentWeatherForecast + val isNightTime = isNightTime(currentWeatherForecast.date) + val cardColor = getCardColorByTemperature(currentWeatherForecast.temperature, isNightTime) val textColor = Color.White Box( @@ -62,7 +70,7 @@ internal fun WeatherDetailsCard( ) { // Current Time Text( - text = "Time: ${formatDateTime(weatherForecastData.currentTime)}", + text = "Time: ${formatDateTime(currentWeatherForecast.date)}", color = textColor, fontSize = JewelTheme.defaultTextStyle.fontSize, fontWeight = FontWeight.Bold @@ -93,9 +101,11 @@ internal fun WeatherDetailsCard( ) { Icon( - key = WeatherIcons.cloudy, -// key = if (isNightTime) weatherForecastData.weatherType.nightIconKey else weatherForecastData.weatherType.dayIconKey, - contentDescription = weatherForecastData.weatherType.label, + key = when { + isNightTime -> currentWeatherForecast.weatherType.nightIconKey + else -> currentWeatherForecast.weatherType.dayIconKey + }, + contentDescription = currentWeatherForecast.weatherType.label, hint = EmbeddedToInlineCssSvgTransformerHint ) @@ -103,7 +113,7 @@ internal fun WeatherDetailsCard( // Temperature (emphasized) Text( - text = "${weatherForecastData.temperature.toInt()}°C", + text = "${currentWeatherForecast.temperature.toInt()}°C", color = textColor, fontSize = 32.sp, fontWeight = FontWeight.ExtraBold @@ -129,38 +139,171 @@ internal fun WeatherDetailsCard( ) { // Wind info Text( - text = "Wind: ${weatherForecastData.windSpeed.toInt()} km/h ${weatherForecastData.windDirection.label}", + text = "Wind: ${currentWeatherForecast.windSpeed.toInt()} km/h ${currentWeatherForecast.windDirection.label}", color = textColor, fontSize = 16.sp ) // Humidity info Text( - text = "Humidity: ${weatherForecastData.humidity}%", + text = "Humidity: ${currentWeatherForecast.humidity}%", color = textColor, fontSize = 16.sp ) } + + Spacer(modifier = Modifier.height(24.dp)) + + // 7-day forecast section + SevenDaysForecastWidget( + weatherForecastData, + Modifier + .fillMaxWidth() + .wrapContentHeight() + .align(Alignment.CenterHorizontally), + textColor + ) + } + } +} + +@Composable +private fun SevenDaysForecastWidget( + weatherForecastData: WeatherForecastData, + modifier: Modifier, + textColor: Color +) { + if (weatherForecastData.dailyForecasts.isNotEmpty()) { + Column(modifier) { + Text( + text = "7-Day Forecast", + 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(), + 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 + ) + } + } + } } } } /** - * Returns a color for the weather type indicator. + * 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 */ -fun getWeatherTypeColor(weatherType: WeatherType, baseColor: Color): Color { - return when (weatherType) { - WeatherType.CLEAR -> Color.Yellow.copy(alpha = 0.2f) - WeatherType.CLOUDY -> Color.Gray.copy(alpha = 0.2f) - WeatherType.PARTLY_CLOUDY -> Color.LightGray.copy(alpha = 0.2f) - WeatherType.RAINY_AND_THUNDER, - WeatherType.RAINY -> Color.Blue.copy(alpha = 0.2f) +@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 + ) - WeatherType.SNOWY -> Color.White.copy(alpha = 0.3f) - WeatherType.TORNADO -> Color.DarkGray.copy(alpha = 0.2f) - WeatherType.THUNDER -> Color.DarkGray.copy(alpha = 0.2f) - WeatherType.FOG -> Color.LightGray.copy(alpha = 0.2f) - WeatherType.MIST -> Color.LightGray.copy(alpha = 0.2f) + 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 = "${forecast.temperature.toInt()}°C", + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Humidity + Text( + text = "Humidity: ${forecast.humidity}%", + color = textColor, + fontSize = 12.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Wind direction + Text( + text = "Wind: ${forecast.windDirection.label}", + color = textColor, + fontSize = 12.sp + ) + } +} + +/** + * 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" + } } } @@ -169,8 +312,8 @@ fun getWeatherTypeColor(weatherType: WeatherType, baseColor: Color): Color { * Night time is considered to be between 7 PM (19:00) and 6 AM (6:00). */ fun isNightTime(dateTime: LocalDateTime): Boolean { - val hour = Random.nextInt(0, 24) - return hour < 6 || hour >= 19 + val hour = dateTime.hour + return hour !in 6..<19 } /** @@ -193,7 +336,17 @@ fun getCardColorByTemperature(temperature: Float, isNightTime: Boolean): Color { /** * Formats the date time to a readable string. */ -fun formatDateTime(dateTime: LocalDateTime): String { - val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm") +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 From bafad75ceab26622a01ca2727131bc7afe7d934e Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 29 Jul 2025 15:43:30 +0200 Subject: [PATCH 27/71] Use the arrow symbol for a wind direction --- .../weatherApp/model/WeatherForecastData.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index 657d657..980a8b7 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt @@ -73,14 +73,14 @@ enum class WeatherType(val label: String, val dayIconKey: IconKey, val nightIcon * Enum representing wind directions. */ enum class WindDirection(val label: String) { - NORTH("N"), - NORTH_EAST("NE"), - EAST("E"), - SOUTH_EAST("SE"), - SOUTH("S"), - SOUTH_WEST("SW"), - WEST("W"), - NORTH_WEST("NW"); + NORTH("↑"), + NORTH_EAST("↗"), + EAST("→"), + SOUTH_EAST("↘"), + SOUTH("↓"), + SOUTH_WEST("↙"), + WEST("←"), + NORTH_WEST("↖"); companion object { fun random(): WindDirection = entries.toTypedArray().random() From 2a0daaa806fe8d1d678f82bd004140ffb2cb42e8 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 29 Jul 2025 15:47:03 +0200 Subject: [PATCH 28/71] Use Bundle to load string resources --- .../weatherApp/ui/WeatherAppSample.kt | 12 +++++-- .../ui/components/SearchToolbarMenu.kt | 2 +- .../ui/components/WeatherDetailsCard.kt | 35 +++++++++++++++---- .../messages/ComposeTemplate.properties | 15 ++++---- 4 files changed, 45 insertions(+), 19 deletions(-) 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 index bb9095d..ae40259 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -71,11 +71,18 @@ private fun LeftColumn( // TODO Set selected item on initial showing Column(modifier) { - GroupHeader("My Locations", modifier = Modifier.wrapContentHeight().fillMaxWidth()) + GroupHeader( + ComposeTemplateBundle.message("weather.app.my.locations.header.text"), + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + ) Spacer(modifier = Modifier.height(10.dp)) val listState = rememberSelectableLazyListState() + + // TODO Check why preselection isn't working LaunchedEffect(myLocations) { listState .selectedKeys = myLocations @@ -95,7 +102,6 @@ private fun LeftColumn( items( items = myLocations, key = { item -> item }, - contentType = { item -> item.location }, ) { item -> ContentItemRow( @@ -138,7 +144,7 @@ private fun RightColumn( Column(modifier) { SearchToolbarMenu( searchAutoCompletionItemProvider = searchAutoCompletionItemProvider, - confirmButtonText = "Add", + confirmButtonText = ComposeTemplateBundle.message("weather.app.search.toolbar.menu.add.button.text"), onSearchPerformed = { place -> weatherViewModelApi.onLoadWeatherForecast(place) }, 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 index 3931f2f..54c805f 100644 --- 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 @@ -54,7 +54,7 @@ internal fun SearchToolbarMenu( Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Icon( AllIconsKeys.Actions.AddList, - contentDescription = ComposeTemplateBundle.message("search.toolbar.menu.add.button.content.description") + contentDescription = ComposeTemplateBundle.message("weather.app.search.toolbar.menu.add.button.content.description") ) Text(confirmButtonText) } 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 index 2daa515..1f0db7d 100644 --- 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 @@ -23,6 +23,7 @@ import org.jetbrains.jewel.ui.component.HorizontallyScrollableContainer import org.jetbrains.jewel.ui.component.Icon 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.weatherApp.WeatherAppColors import org.jetbrains.plugins.template.weatherApp.model.DailyForecast import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData @@ -113,7 +114,10 @@ internal fun WeatherDetailsCard( // Temperature (emphasized) Text( - text = "${currentWeatherForecast.temperature.toInt()}°C", + text = ComposeTemplateBundle.message( + "weather.app.temperature.text", + currentWeatherForecast.temperature.toInt() + ), color = textColor, fontSize = 32.sp, fontWeight = FontWeight.ExtraBold @@ -139,14 +143,21 @@ internal fun WeatherDetailsCard( ) { // Wind info Text( - text = "Wind: ${currentWeatherForecast.windSpeed.toInt()} km/h ${currentWeatherForecast.windDirection.label}", + text = ComposeTemplateBundle.message( + "weather.app.wind.direction.text", + currentWeatherForecast.windSpeed.toInt(), + currentWeatherForecast.windDirection.label + ), color = textColor, fontSize = 16.sp ) // Humidity info Text( - text = "Humidity: ${currentWeatherForecast.humidity}%", + text = ComposeTemplateBundle.message( + "weather.app.humidity.text", + currentWeatherForecast.humidity + ), color = textColor, fontSize = 16.sp ) @@ -176,7 +187,7 @@ private fun SevenDaysForecastWidget( if (weatherForecastData.dailyForecasts.isNotEmpty()) { Column(modifier) { Text( - text = "7-Day Forecast", + text = ComposeTemplateBundle.message("weather.app.7days.forecast.title.text"), color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Bold, @@ -262,7 +273,10 @@ private fun DayForecastItem( // Temperature Text( - text = "${forecast.temperature.toInt()}°C", + text = ComposeTemplateBundle.message( + "weather.app.temperature.text", + forecast.temperature.toInt() + ), color = textColor, fontSize = 16.sp, fontWeight = FontWeight.Bold @@ -272,7 +286,10 @@ private fun DayForecastItem( // Humidity Text( - text = "Humidity: ${forecast.humidity}%", + text = ComposeTemplateBundle.message( + "weather.app.humidity.text", + forecast.humidity + ), color = textColor, fontSize = 12.sp ) @@ -281,7 +298,11 @@ private fun DayForecastItem( // Wind direction Text( - text = "Wind: ${forecast.windDirection.label}", + text = ComposeTemplateBundle.message( + "weather.app.wind.direction.text", + forecast.windSpeed.toInt(), + forecast.windDirection.label + ), color = textColor, fontSize = 12.sp ) diff --git a/src/main/resources/messages/ComposeTemplate.properties b/src/main/resources/messages/ComposeTemplate.properties index 55e0807..becc060 100644 --- a/src/main/resources/messages/ComposeTemplate.properties +++ b/src/main/resources/messages/ComposeTemplate.properties @@ -1,8 +1,7 @@ -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 - -search.toolbar.menu.add.button.content.description=Add a place to a watch list. \ No newline at end of file +weather.app.temperature.text={0}\u00B0C +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.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 \ No newline at end of file From da436500bffdb9c910f8e168ec7861d453537456 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 29 Jul 2025 15:47:26 +0200 Subject: [PATCH 29/71] Increase font on wind and humidity texts --- .../template/weatherApp/ui/components/WeatherDetailsCard.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 1f0db7d..0347950 100644 --- 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 @@ -149,7 +149,7 @@ internal fun WeatherDetailsCard( currentWeatherForecast.windDirection.label ), color = textColor, - fontSize = 16.sp + fontSize = 18.sp, ) // Humidity info @@ -159,7 +159,7 @@ internal fun WeatherDetailsCard( currentWeatherForecast.humidity ), color = textColor, - fontSize = 16.sp + fontSize = 18.sp, ) } From e9188b61cb78873849520dc93466147180050d89 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 29 Jul 2025 16:04:18 +0200 Subject: [PATCH 30/71] Fix UI bugs with reloading weather data --- .../template/weatherApp/services/MyLocationsViewModel.kt | 4 ++++ .../plugins/template/weatherApp/ui/WeatherAppSample.kt | 6 ++---- .../template/weatherApp/ui/components/WeatherDetailsCard.kt | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt index a08e821..c876b86 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt @@ -67,6 +67,8 @@ internal class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelAp myLocations.value += locationToAdd selectedLocationIndex.value = myLocations.value.lastIndex } + + onReloadWeatherForecast() } override fun onDeleteLocation(locationToDelete: Location) { @@ -77,6 +79,8 @@ internal class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelAp if (itemIndex in 0..currentSelectedIndex) { selectedLocationIndex.value = (currentSelectedIndex - 1).coerceAtLeast(0) } + + onReloadWeatherForecast() } override fun onLocationSelected(selectedLocationIndex: Int) { 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 index ae40259..f355fa3 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -68,8 +68,6 @@ private fun LeftColumn( ) { val myLocations = myLocationsViewModelApi.myLocationsFlow.collectAsState(emptyList()).value - // TODO Set selected item on initial showing - Column(modifier) { GroupHeader( ComposeTemplateBundle.message("weather.app.my.locations.header.text"), @@ -158,8 +156,8 @@ private fun RightColumn( .fillMaxWidth() .align(Alignment.CenterHorizontally), weatherForecastData - ) { - weatherViewModelApi.onReloadWeatherForecast() + ) { location -> + weatherViewModelApi.onLoadWeatherForecast(location) } } } 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 index 0347950..c1fac51 100644 --- 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 @@ -26,6 +26,7 @@ import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.plugins.template.ComposeTemplateBundle 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 java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -46,7 +47,7 @@ import java.util.* internal fun WeatherDetailsCard( modifier: Modifier = Modifier, weatherForecastData: WeatherForecastData, - onReloadWeatherData: () -> Unit + onReloadWeatherData: (Location) -> Unit ) { val currentWeatherForecast = weatherForecastData.currentWeatherForecast val isNightTime = isNightTime(currentWeatherForecast.date) @@ -83,7 +84,7 @@ internal fun WeatherDetailsCard( .background(Color.Transparent) .padding(8.dp), tooltip = { Text("Refresh weather data") }, - onClick = { onReloadWeatherData() }, + onClick = { onReloadWeatherData(weatherForecastData.location) }, ) { Icon( key = AllIconsKeys.Actions.Refresh, From 3dcefe6feda23db87b32e66d5c6bf34f4adf0f63 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 30 Jul 2025 14:33:53 +0200 Subject: [PATCH 31/71] Fix: Preselecting location in MyLocations list --- .../services/MyLocationsViewModel.kt | 2 ++ .../weatherApp/ui/WeatherAppSample.kt | 24 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt index c876b86..9297b27 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt @@ -84,6 +84,8 @@ internal class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelAp } override fun onLocationSelected(selectedLocationIndex: Int) { + if (this.selectedLocationIndex.value == selectedLocationIndex) return + this.selectedLocationIndex.value = selectedLocationIndex onReloadWeatherForecast() 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 index f355fa3..d617b09 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -80,12 +80,24 @@ private fun LeftColumn( val listState = rememberSelectableLazyListState() - // TODO Check why preselection isn't working + // JEWEL-938 This will trigger on SelectableLazyColum's `onSelectedIndexesChange` callback LaunchedEffect(myLocations) { - listState - .selectedKeys = myLocations - .mapIndexedNotNull { index, item -> if (item.isSelected) index else null } - .toSet() + var lastActiveItemIndex = -1 + val selectedItemKeys = mutableSetOf() + myLocations.forEachIndexed { index, location -> + if (location.isSelected) { + 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.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( @@ -99,7 +111,7 @@ private fun LeftColumn( ) { items( items = myLocations, - key = { item -> item }, + key = { item -> item.location.label }, ) { item -> ContentItemRow( From 54e147242e037dbf885b41fe22cf43f0797aeef8 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 31 Jul 2025 10:45:24 +0200 Subject: [PATCH 32/71] Extract MyLocations Widget to a separate function --- .../weatherApp/ui/WeatherAppSample.kt | 76 ++++++++++--------- 1 file changed, 40 insertions(+), 36 deletions(-) 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 index d617b09..0855144 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -66,8 +66,6 @@ private fun LeftColumn( myLocationsViewModelApi: MyLocationsViewModelApi, modifier: Modifier = Modifier, ) { - val myLocations = myLocationsViewModelApi.myLocationsFlow.collectAsState(emptyList()).value - Column(modifier) { GroupHeader( ComposeTemplateBundle.message("weather.app.my.locations.header.text"), @@ -78,46 +76,52 @@ private fun LeftColumn( Spacer(modifier = Modifier.height(10.dp)) - val listState = rememberSelectableLazyListState() + MyLocationsList(Modifier.fillMaxSize(), myLocationsViewModelApi) + } +} - // JEWEL-938 This will trigger on SelectableLazyColum's `onSelectedIndexesChange` callback - LaunchedEffect(myLocations) { - var lastActiveItemIndex = -1 - val selectedItemKeys = mutableSetOf() - myLocations.forEachIndexed { index, location -> - if (location.isSelected) { - 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.location.label) +@Composable +internal fun MyLocationsList(modifier: Modifier = Modifier, myLocationsViewModelApi: MyLocationsViewModelApi) { + val myLocations = myLocationsViewModelApi.myLocationsFlow.collectAsState(emptyList()).value + + val listState = rememberSelectableLazyListState() + // JEWEL-938 This will trigger on SelectableLazyColum's `onSelectedIndexesChange` callback + LaunchedEffect(myLocations) { + var lastActiveItemIndex = -1 + val selectedItemKeys = mutableSetOf() + myLocations.forEachIndexed { index, location -> + if (location.isSelected) { + 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.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 } + // 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.fillMaxSize(), - selectionMode = SelectionMode.Single, - state = listState, - onSelectedIndexesChange = { indices -> - val selectedLocationIndex = indices.firstOrNull() ?: return@SelectableLazyColumn - myLocationsViewModelApi.onLocationSelected(selectedLocationIndex) - }, - ) { - items( - items = myLocations, - key = { item -> item.location.label }, - ) { item -> + SelectableLazyColumn( + modifier = modifier, + selectionMode = SelectionMode.Single, + state = listState, + onSelectedIndexesChange = { indices -> + val selectedLocationIndex = indices.firstOrNull() ?: return@SelectableLazyColumn + myLocationsViewModelApi.onLocationSelected(selectedLocationIndex) + }, + ) { + items( + items = myLocations, + key = { item -> item.location.label }, + ) { item -> - ContentItemRow( - item = item.location, isSelected = item.isSelected, isActive = isActive - ) - } + ContentItemRow( + item = item.location, isSelected = item.isSelected, isActive = isActive + ) } } } From 88c1d4b4bd5f717871fe5539d093f81d12035158 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 31 Jul 2025 14:12:51 +0200 Subject: [PATCH 33/71] Add test dependencies and update libraries in build files --- build.gradle.kts | 5 +++++ gradle/libs.versions.toml | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 2e3b6db..798f1dc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,11 +26,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/libs.versions.toml b/gradle/libs.versions.toml index deeb2f0..bdac41e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,10 @@ # 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" @@ -13,6 +17,9 @@ qodana = "2024.3.4" [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" } From e791d63d220f39119843dab0741e8cfcea460b36 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 31 Jul 2025 14:24:49 +0200 Subject: [PATCH 34/71] Add an empty list placeholder for MyLocationsList --- .../weatherApp/ui/WeatherAppSample.kt | 53 ++++++++- .../messages/ComposeTemplate.properties | 2 + .../plugins/template/MyLocationListTest.kt | 101 ++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt 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 index 0855144..181a4ab 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -7,7 +7,9 @@ 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.graphics.Color.Companion.Transparent +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified @@ -17,8 +19,11 @@ import org.jetbrains.jewel.foundation.lazy.items 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.model.SelectableLocation import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData import org.jetbrains.plugins.template.weatherApp.services.LocationsProvider import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi @@ -76,14 +81,58 @@ private fun LeftColumn( Spacer(modifier = Modifier.height(10.dp)) - MyLocationsList(Modifier.fillMaxSize(), myLocationsViewModelApi) + MyLocationsListWithEmptyListPlaceholder(Modifier.fillMaxSize(), myLocationsViewModelApi) } } @Composable -internal fun MyLocationsList(modifier: Modifier = Modifier, myLocationsViewModelApi: MyLocationsViewModelApi) { +internal fun MyLocationsListWithEmptyListPlaceholder( + modifier: Modifier = Modifier, + myLocationsViewModelApi: MyLocationsViewModelApi +) { val myLocations = myLocationsViewModelApi.myLocationsFlow.collectAsState(emptyList()).value + if (myLocations.isNotEmpty()) { + MyLocationList(myLocations, 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( + myLocations: List, + modifier: Modifier, + myLocationsViewModelApi: MyLocationsViewModelApi +) { val listState = rememberSelectableLazyListState() // JEWEL-938 This will trigger on SelectableLazyColum's `onSelectedIndexesChange` callback LaunchedEffect(myLocations) { diff --git a/src/main/resources/messages/ComposeTemplate.properties b/src/main/resources/messages/ComposeTemplate.properties index becc060..39ddfb4 100644 --- a/src/main/resources/messages/ComposeTemplate.properties +++ b/src/main/resources/messages/ComposeTemplate.properties @@ -2,6 +2,8 @@ weather.app.temperature.text={0}\u00B0C 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 \ 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..84d5491 --- /dev/null +++ b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt @@ -0,0 +1,101 @@ +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.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +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.combine +import kotlinx.coroutines.test.runTest +import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme +import org.jetbrains.plugins.template.weatherApp.model.Location +import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation +import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi +import org.jetbrains.plugins.template.weatherApp.ui.MyLocationsListWithEmptyListPlaceholder +import org.junit.Rule +import org.junit.Test + +internal class MyLocationListTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `show placeholder when no locations is added`() = runTest { + val myLocationsRobot = MyLocationListRobot(composeRule) + + composeRule.setContentWrappedInTheme { + val noLocations = emptyList() + val myLocationsViewModel = FakeMyLocationsViewModel(locations = noLocations) + MyLocationsListWithEmptyListPlaceholder( + modifier = Modifier.fillMaxWidth(), + myLocationsViewModelApi = myLocationsViewModel + ) + } + + myLocationsRobot + .verifyNoLocationsPlaceHolderVisible() + } + + private class FakeMyLocationsViewModel( + locations: List = emptyList() + ) : MyLocationsViewModelApi { + + private val locationsFlow = MutableStateFlow(locations.toMutableList()) + + private val selectedItemIndex = MutableStateFlow(if (locations.isNotEmpty()) 0 else -1) + + private val _myLocations = locationsFlow + .combine(selectedItemIndex) { locations, selectedIndex -> + locations.mapIndexed { index, location -> + SelectableLocation(location, index == selectedIndex) + } + } + override val myLocationsFlow: Flow> = _myLocations + + override fun onAddLocation(locationToAdd: Location) { + val currentLocations = locationsFlow.value + currentLocations.add(locationToAdd) + locationsFlow.value = currentLocations + } + + override fun onDeleteLocation(locationToDelete: Location) { + val currentLocations = locationsFlow.value + currentLocations.remove(locationToDelete) + locationsFlow.value = currentLocations + } + + override fun onLocationSelected(selectedLocationIndex: Int) { + selectedItemIndex.value = selectedLocationIndex + } + } + + private fun ComposeContentTestRule.setContentWrappedInTheme(content: @Composable () -> Unit) { + setContent { + IntUiTheme { + content() + } + } + } +} + +private class MyLocationListRobot(private val composableRule: ComposeContentTestRule) { + 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() + } +} \ No newline at end of file From 45ef9a28da863d1322fc8ffbc3ccc21325c160fb Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 31 Jul 2025 15:01:00 +0200 Subject: [PATCH 35/71] Add additional test cases for MyLocationsList --- .../plugins/template/MyLocationListTest.kt | 160 ++++++++++++++++-- 1 file changed, 149 insertions(+), 11 deletions(-) diff --git a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt index 84d5491..297af5c 100644 --- a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt +++ b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt @@ -3,6 +3,7 @@ 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.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -23,24 +24,136 @@ import org.junit.Test internal class MyLocationListTest { @get:Rule val composeRule = createComposeRule() + private val noLocations = emptyList() + private val myLocationsViewModelApi = FakeMyLocationsViewModel(locations = noLocations) @Test - fun `show placeholder when no locations is added`() = runTest { - val myLocationsRobot = MyLocationListRobot(composeRule) - - composeRule.setContentWrappedInTheme { - val noLocations = emptyList() - val myLocationsViewModel = FakeMyLocationsViewModel(locations = noLocations) - MyLocationsListWithEmptyListPlaceholder( - modifier = Modifier.fillMaxWidth(), - myLocationsViewModelApi = myLocationsViewModel - ) - } + fun `verify placeholder is shown when no locations is added`() = composeRule.runComposeTest { + val myLocationsRobot = MyLocationListRobot(this) myLocationsRobot .verifyNoLocationsPlaceHolderVisible() } + @Test + fun `verify location is selected when user adds location`() = composeRule.runComposeTest { locationsViewModelApi -> + val myLocationsRobot = MyLocationListRobot(this) + + locationsViewModelApi.onAddLocation(Location("Munich", "Germany")) + + myLocationsRobot + .verifyListItemWithTextIsSelected("Munich, Germany") + } + + @Test + fun `verify item selection when multiple items are present`() = composeRule.runComposeTest { locationsViewModelApi -> + val myLocationsRobot = MyLocationListRobot(this) + + // Add multiple locations + locationsViewModelApi.onAddLocation(Location("Munich", "Germany")) + locationsViewModelApi.onAddLocation(Location("Berlin", "Germany")) + locationsViewModelApi.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`() = composeRule.runComposeTest { locationsViewModelApi -> + val myLocationsRobot = MyLocationListRobot(this) + + // Add multiple locations + val munich = Location("Munich", "Germany") + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + + locationsViewModelApi.onAddLocation(munich) + locationsViewModelApi.onAddLocation(berlin) + locationsViewModelApi.onAddLocation(paris) + + // Initially, the last added location (Paris) should be selected + myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") + + // Delete the selected location (Paris) + locationsViewModelApi.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`() = composeRule.runComposeTest { locationsViewModelApi -> + val myLocationsRobot = MyLocationListRobot(this) + + // Add three locations + val munich = Location("Munich", "Germany") + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + + locationsViewModelApi.onAddLocation(munich) + locationsViewModelApi.onAddLocation(berlin) + locationsViewModelApi.onAddLocation(paris) + + // Initially, the last added location (Paris) should be selected + myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") + + // Delete the middle location (Berlin) + locationsViewModelApi.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`() = composeRule.runComposeTest { locationsViewModelApi -> + val myLocationsRobot = MyLocationListRobot(this) + + // Add one location + val munich = Location("Munich", "Germany") + locationsViewModelApi.onAddLocation(munich) + + // Verify the location is selected + myLocationsRobot.verifyListItemWithTextIsSelected("Munich, Germany") + + // Delete the only location + locationsViewModelApi.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 fun ComposeContentTestRule.runComposeTest( + myLocationsViewModelApi: MyLocationsViewModelApi = this@MyLocationListTest.myLocationsViewModelApi, + block: suspend ComposeContentTestRule.(MyLocationsViewModelApi) -> Unit + ) = runTest { + this@runComposeTest.setContentWrappedInTheme { + MyLocationsListWithEmptyListPlaceholder( + modifier = Modifier.fillMaxWidth(), + myLocationsViewModelApi = myLocationsViewModelApi + ) + } + + this@runComposeTest.block(myLocationsViewModelApi) + } + private class FakeMyLocationsViewModel( locations: List = emptyList() ) : MyLocationsViewModelApi { @@ -60,13 +173,17 @@ internal class MyLocationListTest { override fun onAddLocation(locationToAdd: Location) { val currentLocations = locationsFlow.value currentLocations.add(locationToAdd) + locationsFlow.value = currentLocations + selectedItemIndex.value = currentLocations.lastIndex } override fun onDeleteLocation(locationToDelete: Location) { val currentLocations = locationsFlow.value currentLocations.remove(locationToDelete) + locationsFlow.value = currentLocations + selectedItemIndex.value = currentLocations.lastIndex } override fun onLocationSelected(selectedLocationIndex: Int) { @@ -84,6 +201,7 @@ internal class MyLocationListTest { } private class MyLocationListRobot(private val composableRule: ComposeContentTestRule) { + fun clickOnItemWithText(text: String) { composableRule .onNodeWithText(text) @@ -94,8 +212,28 @@ private class MyLocationListRobot(private val composableRule: ComposeContentTest 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() + } } \ No newline at end of file From 25d2d2513252d7744940585526d3502d5e40d862 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 31 Jul 2025 15:25:39 +0200 Subject: [PATCH 36/71] Extract ComposeBasedTestCase class for setting up Compose test --- .../plugins/template/ComposeBasedTestCase.kt | 46 +++++ .../plugins/template/MyLocationListTest.kt | 195 +++++++++--------- 2 files changed, 138 insertions(+), 103 deletions(-) create mode 100644 src/test/kotlin/org/jetbrains/plugins/template/ComposeBasedTestCase.kt 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 index 297af5c..5e04ce7 100644 --- a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt +++ b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt @@ -5,30 +5,33 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.ComposeContentTestRule -import androidx.compose.ui.test.junit4.createComposeRule +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.combine -import kotlinx.coroutines.test.runTest import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi import org.jetbrains.plugins.template.weatherApp.ui.MyLocationsListWithEmptyListPlaceholder -import org.junit.Rule import org.junit.Test -internal class MyLocationListTest { - @get:Rule - val composeRule = createComposeRule() +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`() = composeRule.runComposeTest { + fun `verify placeholder is shown when no locations is added`() = runComposeTest { val myLocationsRobot = MyLocationListRobot(this) myLocationsRobot @@ -36,124 +39,110 @@ internal class MyLocationListTest { } @Test - fun `verify location is selected when user adds location`() = composeRule.runComposeTest { locationsViewModelApi -> + fun `verify location is selected when user adds location`() = runComposeTest { val myLocationsRobot = MyLocationListRobot(this) - locationsViewModelApi.onAddLocation(Location("Munich", "Germany")) + myLocationsViewModelApi.onAddLocation(Location("Munich", "Germany")) myLocationsRobot .verifyListItemWithTextIsSelected("Munich, Germany") } @Test - fun `verify item selection when multiple items are present`() = composeRule.runComposeTest { locationsViewModelApi -> + fun `verify item selection when multiple items are present`() = runComposeTest { val myLocationsRobot = MyLocationListRobot(this) - + // Add multiple locations - locationsViewModelApi.onAddLocation(Location("Munich", "Germany")) - locationsViewModelApi.onAddLocation(Location("Berlin", "Germany")) - locationsViewModelApi.onAddLocation(Location("Paris", "France")) - + 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`() = composeRule.runComposeTest { locationsViewModelApi -> + 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") - - locationsViewModelApi.onAddLocation(munich) - locationsViewModelApi.onAddLocation(berlin) - locationsViewModelApi.onAddLocation(paris) - + + 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) - locationsViewModelApi.onDeleteLocation(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`() = composeRule.runComposeTest { locationsViewModelApi -> + 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") - - locationsViewModelApi.onAddLocation(munich) - locationsViewModelApi.onAddLocation(berlin) - locationsViewModelApi.onAddLocation(paris) - + + 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) - locationsViewModelApi.onDeleteLocation(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`() = composeRule.runComposeTest { locationsViewModelApi -> + fun `verify deletion of the only item in list`() = runComposeTest { val myLocationsRobot = MyLocationListRobot(this) - + // Add one location val munich = Location("Munich", "Germany") - locationsViewModelApi.onAddLocation(munich) - + myLocationsViewModelApi.onAddLocation(munich) + // Verify the location is selected myLocationsRobot.verifyListItemWithTextIsSelected("Munich, Germany") - + // Delete the only location - locationsViewModelApi.onDeleteLocation(munich) - + 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 fun ComposeContentTestRule.runComposeTest( - myLocationsViewModelApi: MyLocationsViewModelApi = this@MyLocationListTest.myLocationsViewModelApi, - block: suspend ComposeContentTestRule.(MyLocationsViewModelApi) -> Unit - ) = runTest { - this@runComposeTest.setContentWrappedInTheme { - MyLocationsListWithEmptyListPlaceholder( - modifier = Modifier.fillMaxWidth(), - myLocationsViewModelApi = myLocationsViewModelApi - ) - } - - this@runComposeTest.block(myLocationsViewModelApi) - } - private class FakeMyLocationsViewModel( locations: List = emptyList() ) : MyLocationsViewModelApi { @@ -161,13 +150,13 @@ internal class MyLocationListTest { private val locationsFlow = MutableStateFlow(locations.toMutableList()) private val selectedItemIndex = MutableStateFlow(if (locations.isNotEmpty()) 0 else -1) - private val _myLocations = locationsFlow .combine(selectedItemIndex) { locations, selectedIndex -> locations.mapIndexed { index, location -> SelectableLocation(location, index == selectedIndex) } } + override val myLocationsFlow: Flow> = _myLocations override fun onAddLocation(locationToAdd: Location) { @@ -198,42 +187,42 @@ internal class MyLocationListTest { } } } + + 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() + } + } } - -private class MyLocationListRobot(private val composableRule: ComposeContentTestRule) { - - 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() - } -} \ No newline at end of file From a6435ba771da8aca5db82d61258e790860557542 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 31 Jul 2025 15:38:38 +0200 Subject: [PATCH 37/71] Refactor: Replace the param type 'LocationsProvider' with 'SearchAutoCompletionItemProvider' --- .../plugins/template/weatherApp/ui/WeatherAppSample.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 index 181a4ab..2f69f7a 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -25,7 +25,6 @@ import org.jetbrains.plugins.template.ComposeTemplateBundle import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData -import org.jetbrains.plugins.template.weatherApp.services.LocationsProvider import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider import org.jetbrains.plugins.template.weatherApp.services.WeatherViewModelApi @@ -36,7 +35,7 @@ import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCar internal fun WeatherAppSample( myLocationViewModel: MyLocationsViewModelApi, weatherViewModelApi: WeatherViewModelApi, - searchAutoCompletionItemProvided: LocationsProvider + searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider ) { HorizontalSplitLayout( first = { @@ -51,7 +50,7 @@ internal fun WeatherAppSample( RightColumn( myLocationViewModel, weatherViewModelApi, - searchAutoCompletionItemProvided, + searchAutoCompletionItemProvider, modifier = Modifier .fillMaxSize() .padding(start = 8.dp, end = 8.dp) From 5bae8306556eb71a14f98849c63b1896804007f7 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 11:21:03 +0200 Subject: [PATCH 38/71] Cleanup SearchBarWithAutoCompletion function parameters --- .../components/SearchBarWithAutoCompletion.kt | 16 +++++++--------- .../ui/components/SearchToolbarMenu.kt | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index a137c73..b623655 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.interaction.HoverInteraction import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -22,7 +21,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.zIndex import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.onEach import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.PopupMenu @@ -38,10 +36,10 @@ import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionIt internal fun SearchBarWithAutoCompletion( modifier: Modifier = Modifier, searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, - textFieldState: TextFieldState = rememberTextFieldState(""), + textFieldState: TextFieldState, searchFieldPlaceholder: String = "Type a place name...", - onInputCleared: () -> Unit = {}, - onItemAutocomplete: (T) -> Unit = {}, + onClear: () -> Unit = {}, + onSelectCompletion: (T) -> Unit = {}, ) where T : Searchable, T : PreviewableItem { val focusRequester = remember { FocusRequester() } @@ -67,7 +65,7 @@ internal fun SearchBarWithAutoCompletion( .fillMaxWidth() .handlePopupCompletionKeyEvents(popupController) { item -> textFieldState.setTextAndPlaceCursorAtEnd(item.label) - onItemAutocomplete(item) + onSelectCompletion(item) } .focusRequester(focusRequester), placeholder = { Text(searchFieldPlaceholder) }, @@ -77,7 +75,7 @@ internal fun SearchBarWithAutoCompletion( trailingIcon = { if (textFieldState.text.isNotBlank()) { CloseIconButton { - onInputCleared() + onClear() textFieldState.setTextAndPlaceCursorAtEnd("") } } @@ -101,9 +99,9 @@ internal fun SearchBarWithAutoCompletion( ) { popupController.filteredItems.forEach { item -> selectableItem( - popupController.isItemSelected(item), + popupController. isItemSelected(item), onClick = { - onItemAutocomplete(item) + onSelectCompletion(item) popupController.onItemAutocompleteConfirmed() textFieldState.setTextAndPlaceCursorAtEnd(item.label) }, 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 index 54c805f..1cfac4b 100644 --- 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 @@ -70,11 +70,11 @@ internal fun SearchToolbarMenu( .align(Alignment.CenterVertically), searchAutoCompletionItemProvider = searchAutoCompletionItemProvider, textFieldState = searchTextFieldState, - onInputCleared = { + onClear = { isConfirmButtonVisible.value = false previewItem.value = null }, - onItemAutocomplete = { autocompletedItem -> + onSelectCompletion = { autocompletedItem -> isConfirmButtonVisible.value = true previewItem.value = autocompletedItem onSearchPerformed(autocompletedItem) From 0657ead6a52e45b872fc1819e28022aadd174fe2 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 11:25:17 +0200 Subject: [PATCH 39/71] Remove unnecessary Column usage --- .../components/SearchBarWithAutoCompletion.kt | 103 +++++++++--------- 1 file changed, 50 insertions(+), 53 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index b623655..9afaea4 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -51,63 +51,60 @@ internal fun SearchBarWithAutoCompletion( .collect { searchTerm -> popupController.onQueryChanged(searchTerm) } } - Column(modifier = modifier) { - Box( + Box( + modifier = modifier + .padding(8.dp) + ) { + var textFieldWidth by remember { mutableIntStateOf(-1) } + TextField( + state = textFieldState, modifier = Modifier + .onGloballyPositioned { coordinates -> textFieldWidth = coordinates.size.width } .fillMaxWidth() - .padding(8.dp) - ) { - var textFieldWidth by remember { mutableIntStateOf(-1) } - TextField( - state = textFieldState, - modifier = Modifier - .onGloballyPositioned { coordinates -> textFieldWidth = coordinates.size.width } - .fillMaxWidth() - .handlePopupCompletionKeyEvents(popupController) { item -> - textFieldState.setTextAndPlaceCursorAtEnd(item.label) - onSelectCompletion(item) + .handlePopupCompletionKeyEvents(popupController) { item -> + textFieldState.setTextAndPlaceCursorAtEnd(item.label) + onSelectCompletion(item) + } + .focusRequester(focusRequester), + placeholder = { Text(searchFieldPlaceholder) }, + leadingIcon = { + Icon(AllIconsKeys.Actions.Find, contentDescription = "Find icon", Modifier.padding(end = 8.dp)) + }, + trailingIcon = { + if (textFieldState.text.isNotBlank()) { + CloseIconButton { + onClear() + textFieldState.setTextAndPlaceCursorAtEnd("") } - .focusRequester(focusRequester), - placeholder = { Text(searchFieldPlaceholder) }, - leadingIcon = { - Icon(AllIconsKeys.Actions.Find, contentDescription = "Find icon", Modifier.padding(end = 8.dp)) - }, - trailingIcon = { - if (textFieldState.text.isNotBlank()) { - CloseIconButton { - onClear() - 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() }) - .wrapContentHeight() - .padding(vertical = 4.dp, horizontal = 2.dp) - .zIndex(5f), - popupProperties = PopupProperties(focusable = false), - ) { - popupController.filteredItems.forEach { item -> - selectableItem( - popupController. isItemSelected(item), - onClick = { - onSelectCompletion(item) - popupController.onItemAutocompleteConfirmed() - textFieldState.setTextAndPlaceCursorAtEnd(item.label) - }, - ) { - Text(item.label) - } + if (popupController.isVisible) { + PopupMenu( + onDismissRequest = { + popupController.reset() + true + }, + horizontalAlignment = Alignment.Start, + modifier = Modifier + // Aligns PopupMenu with TextField + .width(with(LocalDensity.current) { textFieldWidth.toDp() }) + .wrapContentHeight() + .padding(vertical = 4.dp, horizontal = 2.dp) + .zIndex(5f), + popupProperties = PopupProperties(focusable = false), + ) { + popupController.filteredItems.forEach { item -> + selectableItem( + popupController.isItemSelected(item), + onClick = { + onSelectCompletion(item) + popupController.onItemAutocompleteConfirmed() + textFieldState.setTextAndPlaceCursorAtEnd(item.label) + }, + ) { + Text(item.label) } } } From 267f35bbbcd8412bfbbd5c4590e2a896849fa3b5 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 11:26:38 +0200 Subject: [PATCH 40/71] Replace onGloballyPositioned with onSizeChanged in SearchBarWithAutoCompletion --- .../template/components/SearchBarWithAutoCompletion.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index 9afaea4..fc21327 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -14,7 +14,7 @@ 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.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp @@ -59,7 +59,7 @@ internal fun SearchBarWithAutoCompletion( TextField( state = textFieldState, modifier = Modifier - .onGloballyPositioned { coordinates -> textFieldWidth = coordinates.size.width } + .onSizeChanged { coordinates -> textFieldWidth = coordinates.width } .fillMaxWidth() .handlePopupCompletionKeyEvents(popupController) { item -> textFieldState.setTextAndPlaceCursorAtEnd(item.label) From 2eb729d331ef5176f3dacfeabafc53fc07890d55 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 11:27:32 +0200 Subject: [PATCH 41/71] Remove unnecessary content description in SearchBarWithAutoCompletion icon --- .../plugins/template/components/SearchBarWithAutoCompletion.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index fc21327..9f1681a 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -68,7 +68,7 @@ internal fun SearchBarWithAutoCompletion( .focusRequester(focusRequester), placeholder = { Text(searchFieldPlaceholder) }, leadingIcon = { - Icon(AllIconsKeys.Actions.Find, contentDescription = "Find icon", Modifier.padding(end = 8.dp)) + Icon(AllIconsKeys.Actions.Find, contentDescription = null, Modifier.padding(end = 8.dp)) }, trailingIcon = { if (textFieldState.text.isNotBlank()) { From 7996e74e2434b9986975e42c284c54cdb370109f Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 11:30:33 +0200 Subject: [PATCH 42/71] Refactor: Introduce `isInputFieldEmpty` for clarity in SearchBarWithAutoCompletion --- .../plugins/template/components/SearchBarWithAutoCompletion.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index 9f1681a..e6dfa04 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -44,6 +44,7 @@ internal fun SearchBarWithAutoCompletion( val focusRequester = remember { FocusRequester() } val popupController = remember { CompletionPopupController(searchAutoCompletionItemProvider) } + val isInputFieldEmpty by remember { derivedStateOf { textFieldState.text.isBlank() } } LaunchedEffect(Unit) { snapshotFlow { textFieldState.text.toString() } @@ -71,7 +72,7 @@ internal fun SearchBarWithAutoCompletion( Icon(AllIconsKeys.Actions.Find, contentDescription = null, Modifier.padding(end = 8.dp)) }, trailingIcon = { - if (textFieldState.text.isNotBlank()) { + if (!isInputFieldEmpty) { CloseIconButton { onClear() textFieldState.setTextAndPlaceCursorAtEnd("") From 80a485b4bde9c79f4a2f10bc532e4f00b445c4af Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 11:32:18 +0200 Subject: [PATCH 43/71] Remove unnecessary PopupMenu modifiers --- .../template/components/SearchBarWithAutoCompletion.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index e6dfa04..9fe9ddc 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -19,7 +19,6 @@ 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 androidx.compose.ui.zIndex import kotlinx.coroutines.flow.distinctUntilChanged import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.ui.component.Icon @@ -90,10 +89,7 @@ internal fun SearchBarWithAutoCompletion( horizontalAlignment = Alignment.Start, modifier = Modifier // Aligns PopupMenu with TextField - .width(with(LocalDensity.current) { textFieldWidth.toDp() }) - .wrapContentHeight() - .padding(vertical = 4.dp, horizontal = 2.dp) - .zIndex(5f), + .width(with(LocalDensity.current) { textFieldWidth.toDp() }), popupProperties = PopupProperties(focusable = false), ) { popupController.filteredItems.forEach { item -> From 5b4ff635c47d0aec250ad99506a0369806c30852 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 11:37:13 +0200 Subject: [PATCH 44/71] Use localized content description for Clear button icon --- .../components/SearchBarWithAutoCompletion.kt | 15 +++++++++------ .../resources/messages/ComposeTemplate.properties | 4 +++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index 9fe9ddc..95b9fce 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -26,6 +26,7 @@ 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 @@ -125,12 +126,14 @@ internal fun CloseIconButton(onClick: () -> Unit) { Icon( key = if (hovered) AllIconsKeys.Actions.CloseHovered else AllIconsKeys.Actions.Close, - contentDescription = "Clear", - modifier = Modifier.pointerHoverIcon(PointerIcon.Default).clickable( - interactionSource = interactionSource, - indication = null, - role = Role.Button, - ) { onClick() }, + contentDescription = ComposeTemplateBundle.message("weather.app.clear.button.content.description"), + modifier = Modifier + .pointerHoverIcon(PointerIcon.Default) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + ) { onClick() }, ) } diff --git a/src/main/resources/messages/ComposeTemplate.properties b/src/main/resources/messages/ComposeTemplate.properties index 39ddfb4..b9082ab 100644 --- a/src/main/resources/messages/ComposeTemplate.properties +++ b/src/main/resources/messages/ComposeTemplate.properties @@ -6,4 +6,6 @@ weather.app.my.locations.empty.list.placeholder.text=No locations added yet. Go 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 \ No newline at end of file +weather.app.7days.forecast.title.text=7-day Forecast + +weather.app.clear.button.content.description=Clear \ No newline at end of file From cb6c90876983085b7f52db35cc2b8ceea064f9f9 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 12:50:07 +0200 Subject: [PATCH 45/71] Refactor: Improve completion handling logic in SearchBarWithAutoCompletion --- .../components/SearchBarWithAutoCompletion.kt | 98 ++++++++++++------- 1 file changed, 65 insertions(+), 33 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index 95b9fce..2347deb 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -3,7 +3,10 @@ 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.* +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.* @@ -43,13 +46,20 @@ internal fun SearchBarWithAutoCompletion( ) where T : Searchable, T : PreviewableItem { val focusRequester = remember { FocusRequester() } - val popupController = remember { CompletionPopupController(searchAutoCompletionItemProvider) } + 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 -> popupController.onQueryChanged(searchTerm) } + .collect { searchTerm -> + popupController.onQueryChanged(searchTerm) + } } Box( @@ -62,10 +72,7 @@ internal fun SearchBarWithAutoCompletion( modifier = Modifier .onSizeChanged { coordinates -> textFieldWidth = coordinates.width } .fillMaxWidth() - .handlePopupCompletionKeyEvents(popupController) { item -> - textFieldState.setTextAndPlaceCursorAtEnd(item.label) - onSelectCompletion(item) - } + .handlePopupCompletionKeyEvents(popupController) .focusRequester(focusRequester), placeholder = { Text(searchFieldPlaceholder) }, leadingIcon = { @@ -93,16 +100,15 @@ internal fun SearchBarWithAutoCompletion( .width(with(LocalDensity.current) { textFieldWidth.toDp() }), popupProperties = PopupProperties(focusable = false), ) { - popupController.filteredItems.forEach { item -> + popupController.completionItems.forEach { completionItem -> selectableItem( - popupController.isItemSelected(item), + completionItem.isSelected, onClick = { - onSelectCompletion(item) - popupController.onItemAutocompleteConfirmed() - textFieldState.setTextAndPlaceCursorAtEnd(item.label) + popupController.onSelectCompletion() + textFieldState.setTextAndPlaceCursorAtEnd(completionItem.item.label) }, ) { - Text(item.label) + Text(completionItem.item.label) } } } @@ -137,22 +143,30 @@ internal fun CloseIconButton(onClick: () -> Unit) { ) } + +private data class CompletionItem( + val item: T, + val isSelected: Boolean, +) + private class CompletionPopupController( private val itemsProvider: SearchAutoCompletionItemProvider, + private val onSelectCompletion: (CompletionItem) -> Unit = {}, ) { - var selectedItemIndex by mutableIntStateOf(0) - private set + 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) - var filteredItems by mutableStateOf(emptyList()) - private set - val selectedItem: T - get() = filteredItems[selectedItemIndex] + private val _filteredCompletionItems = mutableStateListOf>() + + val completionItems: List> get() = _filteredCompletionItems + + val selectedItem: CompletionItem + get() = _filteredCompletionItems[selectedItemIndex] var isVisible by mutableStateOf(false) private set @@ -177,10 +191,14 @@ private class CompletionPopupController( return } - updateFilteredItems(itemsProvider.provideSearchableItems(searchTerm)) + val newItems = itemsProvider.provideSearchableItems(searchTerm) + .map { CompletionItem(it, false) } + + updateFilteredItems(newItems) + moveSelectionToFirstItem() - if (filteredItems.isNotEmpty()) { + if (completionItems.isNotEmpty()) { showPopup() } else { hidePopup() @@ -196,29 +214,31 @@ private class CompletionPopupController( } fun reset() { - moveSelectionToFirstItem() hidePopup() + moveSelectionToFirstItem() clearFilteredItems() } - fun isItemSelected(item: T): Boolean = (filteredItems[selectedItemIndex] == item) + fun onSelectCompletion() { + if (!isVisible) return - fun onItemAutocompleteConfirmed(): T { - val selectedItem = this.selectedItem + val completionPopupItem = selectedItem skipPopupShowing = true reset() - return selectedItem + onSelectCompletion(completionPopupItem) } - private fun updateFilteredItems(filteredItems: List) { - this.filteredItems = filteredItems + private fun updateFilteredItems(newItems: List>) { + // TODO Can be done in a more efficient way + clearFilteredItems() + _filteredCompletionItems.addAll(newItems) } private fun clearFilteredItems() { - filteredItems = emptyList() + _filteredCompletionItems.clear() } private fun moveSelectionToFirstItem() { @@ -226,25 +246,37 @@ private class CompletionPopupController( } 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..filteredItems.lastIndex) + 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, - onItemAutocompleteConfirmed: (T) -> Unit = {}, + 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 -> { - onItemAutocompleteConfirmed(popupController.onItemAutocompleteConfirmed()) + popupController.onSelectCompletion() true } From edb8fb0b282804e6dd3e9adbc808bce810a6bb24 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 12:53:39 +0200 Subject: [PATCH 46/71] Properly notify parents when SearchBarWithAutoCompletion text field text is cleared --- .../template/components/SearchBarWithAutoCompletion.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index 2347deb..6a52110 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -58,6 +58,10 @@ internal fun SearchBarWithAutoCompletion( snapshotFlow { textFieldState.text.toString() } .distinctUntilChanged() .collect { searchTerm -> + if (searchTerm.isEmpty()) { + onClear() + } + popupController.onQueryChanged(searchTerm) } } @@ -81,7 +85,6 @@ internal fun SearchBarWithAutoCompletion( trailingIcon = { if (!isInputFieldEmpty) { CloseIconButton { - onClear() textFieldState.setTextAndPlaceCursorAtEnd("") } } From 8fe4926fb70b97753045267a6ffc9f75119c743b Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 12:56:29 +0200 Subject: [PATCH 47/71] Drop 'internal' modifier for readability and shortness of examples --- .../template/components/SearchBarWithAutoCompletion.kt | 4 ++-- .../jetbrains/plugins/template/weatherApp/model/Location.kt | 4 ++-- .../plugins/template/weatherApp/model/Searchable.kt | 2 +- .../template/weatherApp/model/WeatherForecastData.kt | 4 ++-- .../template/weatherApp/services/LocationsProvider.kt | 2 +- .../template/weatherApp/services/MyLocationsViewModel.kt | 6 +++--- .../weatherApp/services/SearchAutoCompletionItemProvider.kt | 2 +- .../template/weatherApp/services/WeatherForecastService.kt | 2 +- .../plugins/template/weatherApp/ui/WeatherAppSample.kt | 4 ++-- .../ui/components/EmbeddedToInlineCssSvgTransformerHint.kt | 2 +- .../template/weatherApp/ui/components/SearchToolbarMenu.kt | 2 +- .../template/weatherApp/ui/components/WeatherDetailsCard.kt | 2 +- 12 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index 6a52110..29c5e7c 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -36,7 +36,7 @@ import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionIt @OptIn(ExperimentalJewelApi::class) @Composable -internal fun SearchBarWithAutoCompletion( +fun SearchBarWithAutoCompletion( modifier: Modifier = Modifier, searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, textFieldState: TextFieldState, @@ -120,7 +120,7 @@ internal fun SearchBarWithAutoCompletion( } @Composable -internal fun CloseIconButton(onClick: () -> Unit) { +fun CloseIconButton(onClick: () -> Unit) { val interactionSource = remember { MutableInteractionSource() } var hovered by remember { mutableStateOf(false) } 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 index a3f822c..d347bce 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt @@ -10,7 +10,7 @@ import androidx.compose.runtime.Immutable * @property id A derived unique identifier for the location in the format `name, country`. */ @Immutable -internal data class Location(val name: String, val country: String) : PreviewableItem, Searchable { +data class Location(val name: String, val country: String) : PreviewableItem, Searchable { val id: String = "$name, $country" override fun isSearchApplicable(query: String): Boolean { @@ -31,4 +31,4 @@ internal data class Location(val name: String, val country: String) : Previewabl } @Immutable -internal data class SelectableLocation(val location: Location, val isSelected: Boolean) \ No newline at end of file +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/Searchable.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Searchable.kt index 5bafa75..063f563 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Searchable.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Searchable.kt @@ -3,6 +3,6 @@ package org.jetbrains.plugins.template.weatherApp.model /** * Represents an entity that can be filtered by a search query. */ -internal interface Searchable { +interface Searchable { fun isSearchApplicable(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 index 980a8b7..e40ac5a 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/WeatherForecastData.kt @@ -7,7 +7,7 @@ import java.time.LocalDateTime /** * Data class representing a daily weather forecast. */ -internal data class DailyForecast( +data class DailyForecast( val date: LocalDateTime, val temperature: Float, val weatherType: WeatherType, @@ -19,7 +19,7 @@ internal data class DailyForecast( /** * Data class representing weather information to be displayed in the Weather Card. */ -internal data class WeatherForecastData( +data class WeatherForecastData( val location: Location, val currentWeatherForecast: DailyForecast, val dailyForecasts: List = emptyList() 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 index 6b192ef..a73212d 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsProvider.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsProvider.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow import org.jetbrains.plugins.template.weatherApp.model.Location @Service -internal class LocationsProvider : SearchAutoCompletionItemProvider { +class LocationsProvider : SearchAutoCompletionItemProvider { private val locationStateFlow = MutableStateFlow( listOf( Location("Munich", "Germany"), diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt index 9297b27..cf72db7 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt @@ -16,7 +16,7 @@ import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData * a flow to observe the list of selectable locations. Implementations are expected to handle * location-related logic and state management. */ -internal interface MyLocationsViewModelApi { +interface MyLocationsViewModelApi { fun onAddLocation(locationToAdd: Location) fun onDeleteLocation(locationToDelete: Location) @@ -30,7 +30,7 @@ internal interface MyLocationsViewModelApi { * Interface representing a ViewModel for managing weather-related data * and user interactions. */ -internal interface WeatherViewModelApi { +interface WeatherViewModelApi { val weatherForecast: Flow fun onLoadWeatherForecast(location: Location) @@ -39,7 +39,7 @@ internal interface WeatherViewModelApi { } @Service -internal class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelApi, WeatherViewModelApi { +class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelApi, WeatherViewModelApi { private val weatherService = service() 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 index 521e6e2..2f47622 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/SearchAutoCompletionItemProvider.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/SearchAutoCompletionItemProvider.kt @@ -11,6 +11,6 @@ import org.jetbrains.plugins.template.weatherApp.model.Searchable * * @param T The type of items provided by this interface, which must extend [Searchable]. */ -internal interface SearchAutoCompletionItemProvider { +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 index 80786a7..d926f84 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt @@ -15,7 +15,7 @@ import java.time.LocalTime import kotlin.random.Random @Service -internal class WeatherForecastService(private val cs: CoroutineScope) { +class WeatherForecastService(private val cs: CoroutineScope) { private val _weatherForecast: MutableStateFlow = MutableStateFlow(WeatherForecastData.EMPTY) val weatherForecast: StateFlow = _weatherForecast.asStateFlow() 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 index 2f69f7a..231c8c5 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -32,7 +32,7 @@ import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard @Composable -internal fun WeatherAppSample( +fun WeatherAppSample( myLocationViewModel: MyLocationsViewModelApi, weatherViewModelApi: WeatherViewModelApi, searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider @@ -85,7 +85,7 @@ private fun LeftColumn( } @Composable -internal fun MyLocationsListWithEmptyListPlaceholder( +fun MyLocationsListWithEmptyListPlaceholder( modifier: Modifier = Modifier, myLocationsViewModelApi: MyLocationsViewModelApi ) { 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 index bf5bbd4..c6015e1 100644 --- 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 @@ -17,7 +17,7 @@ import javax.xml.transform.stream.StreamResult import javax.xml.xpath.XPathConstants import javax.xml.xpath.XPathFactory -internal object EmbeddedToInlineCssSvgTransformerHint : PainterSvgPatchHint { +object EmbeddedToInlineCssSvgTransformerHint : PainterSvgPatchHint { private val CSS_STYLEABLE_TAGS = listOf( "linearGradient", "radialGradient", "pattern", "filter", "clipPath", "mask", "symbol", 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 index 1cfac4b..0316221 100644 --- 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 @@ -20,7 +20,7 @@ import org.jetbrains.plugins.template.weatherApp.model.Searchable import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider @Composable -internal fun SearchToolbarMenu( +fun SearchToolbarMenu( searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, confirmButtonText: String = "Confirm", onSearchPerformed: (T) -> Unit = {}, 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 index c1fac51..267b9ee 100644 --- 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 @@ -44,7 +44,7 @@ import java.util.* */ @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun WeatherDetailsCard( +fun WeatherDetailsCard( modifier: Modifier = Modifier, weatherForecastData: WeatherForecastData, onReloadWeatherData: (Location) -> Unit From ef4e5c8c6c4e55ff08508e2a26886514600aa5e6 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 13:00:03 +0200 Subject: [PATCH 48/71] Refactor: Rename `isSearchApplicable` to `matches` for clarity and consistency --- .../org/jetbrains/plugins/template/weatherApp/model/Location.kt | 2 +- .../jetbrains/plugins/template/weatherApp/model/Searchable.kt | 2 +- .../plugins/template/weatherApp/services/LocationsProvider.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 index d347bce..722bd70 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.Immutable data class Location(val name: String, val country: String) : PreviewableItem, Searchable { val id: String = "$name, $country" - override fun isSearchApplicable(query: String): Boolean { + override fun matches(query: String): Boolean { val applicableCandidates = listOf( id, name, 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 index 063f563..78b088b 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Searchable.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Searchable.kt @@ -4,5 +4,5 @@ package org.jetbrains.plugins.template.weatherApp.model * Represents an entity that can be filtered by a search query. */ interface Searchable { - fun isSearchApplicable(query: String): Boolean + fun matches(query: String): Boolean } \ 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 index a73212d..699977f 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsProvider.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsProvider.kt @@ -27,6 +27,6 @@ class LocationsProvider : SearchAutoCompletionItemProvider { override fun provideSearchableItems(searchTerm: String): List { return locationStateFlow .value - .filter { it.isSearchApplicable(searchTerm) } + .filter { it.matches(searchTerm) } } } \ No newline at end of file From 324fea18bf5f512cff54251700f088fdbb19b80f Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 13:00:41 +0200 Subject: [PATCH 49/71] Add documentation for `PreviewableItem` interface --- .../plugins/template/weatherApp/model/PreviewableItem.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 index 4d97d35..05a44e4 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/PreviewableItem.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/PreviewableItem.kt @@ -1,5 +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 From 376292e5f8d67908a128b5973c31ccac11e593b4 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 13:18:06 +0200 Subject: [PATCH 50/71] Refactor: Replace `id` with `label` for improved naming clarity --- .../template/weatherApp/model/Location.kt | 21 +++++++++++++------ .../weatherApp/ui/WeatherAppSample.kt | 2 +- 2 files changed, 16 insertions(+), 7 deletions(-) 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 index 722bd70..6216bb7 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/model/Location.kt @@ -7,15 +7,27 @@ import androidx.compose.runtime.Immutable * * @property name The name of the location. * @property country The associated country of the location. - * @property id A derived unique identifier for the location in the format `name, country`. + * @property label A textual representation of the location. */ @Immutable data class Location(val name: String, val country: String) : PreviewableItem, Searchable { - val id: String = "$name, $country" + + 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( - id, + label, name, country, name.split(" ").map { it.first() }.joinToString(""), @@ -25,9 +37,6 @@ data class Location(val name: String, val country: String) : PreviewableItem, Se return applicableCandidates.any { it.contains(query, ignoreCase = true) } } - - override val label: String - get() = id } @Immutable 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 index 231c8c5..68994fb 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -190,7 +190,7 @@ private fun ContentItemRow(item: Location, isSelected: Boolean, isActive: Boolea verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Text(text = item.id, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) + Text(text = item.label, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) } } From b603edf1107c411f5c56c6de5a9ae911cf8d3a74 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 13:18:33 +0200 Subject: [PATCH 51/71] Use localized string for weather time display in `WeatherDetailsCard` --- .../template/weatherApp/ui/components/WeatherDetailsCard.kt | 2 +- src/main/resources/messages/ComposeTemplate.properties | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 index 267b9ee..3481809 100644 --- 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 @@ -72,7 +72,7 @@ fun WeatherDetailsCard( ) { // Current Time Text( - text = "Time: ${formatDateTime(currentWeatherForecast.date)}", + text = ComposeTemplateBundle.message("weather.app.time.text", timeToDisplay), color = textColor, fontSize = JewelTheme.defaultTextStyle.fontSize, fontWeight = FontWeight.Bold diff --git a/src/main/resources/messages/ComposeTemplate.properties b/src/main/resources/messages/ComposeTemplate.properties index b9082ab..81f87f8 100644 --- a/src/main/resources/messages/ComposeTemplate.properties +++ b/src/main/resources/messages/ComposeTemplate.properties @@ -1,4 +1,5 @@ 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 From 53b4a2cb5531c6c84fa7dc3e993803c14b3e97b6 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 13:31:20 +0200 Subject: [PATCH 52/71] Bump IntelliJ Platform version to 2025.1.4.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 1fc6ae4..6fea550 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ pluginUntilBuild = 252.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformType = IC -platformVersion = 2025.1.3 +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 From 11dfe4124904313e84bc2d9121f10b239185af0f Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 1 Aug 2025 13:36:13 +0200 Subject: [PATCH 53/71] Refactor: Rename `MyToolWindowFactory` to `ComposeSamplesToolWindowFactory` and update related plugin.xml references --- ...yToolWindowFactory.kt => ComposeSamplesToolWindowFactory.kt} | 2 +- src/main/resources/META-INF/plugin.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/main/kotlin/org/jetbrains/plugins/template/toolWindow/{MyToolWindowFactory.kt => ComposeSamplesToolWindowFactory.kt} (94%) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt similarity index 94% rename from src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt rename to src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt index b0fe66f..9940b8a 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/MyToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt @@ -11,7 +11,7 @@ import org.jetbrains.plugins.template.weatherApp.services.LocationsProvider import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModel import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppSample -class MyToolWindowFactory : ToolWindowFactory, DumbAware { +class ComposeSamplesToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { toolWindow.addComposeTab("Weather App") { 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 - + From f25ee25399a3c7257cfc7e19959e2d4192cb337b Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 5 Aug 2025 12:41:19 +0200 Subject: [PATCH 54/71] Make WeatherDetailsCard vertically scrollable and add safeContentPadding --- .../ui/components/WeatherDetailsCard.kt | 296 +++++++++++------- 1 file changed, 184 insertions(+), 112 deletions(-) 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 index 3481809..82af7be 100644 --- 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 @@ -18,10 +18,7 @@ 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.ActionButton -import org.jetbrains.jewel.ui.component.HorizontallyScrollableContainer -import org.jetbrains.jewel.ui.component.Icon -import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.plugins.template.ComposeTemplateBundle import org.jetbrains.plugins.template.weatherApp.WeatherAppColors @@ -54,127 +51,170 @@ fun WeatherDetailsCard( val cardColor = getCardColorByTemperature(currentWeatherForecast.temperature, isNightTime) val textColor = Color.White - Box( - modifier = modifier - .clip(RoundedCornerShape(16.dp)) - .background(cardColor) - .padding(16.dp) - ) { - - // Card content - Column( + VerticallyScrollableContainer(modifier = modifier.safeContentPadding()) { + Box( modifier = Modifier - .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(cardColor) + .padding(16.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - // Current Time - Text( - text = ComposeTemplateBundle.message("weather.app.time.text", timeToDisplay), - color = textColor, - fontSize = JewelTheme.defaultTextStyle.fontSize, - fontWeight = FontWeight.Bold - ) - ActionButton( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(Color.Transparent) - .padding(8.dp), - tooltip = { Text("Refresh weather data") }, - onClick = { onReloadWeatherData(weatherForecastData.location) }, + // Card content + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween ) { + // Current Time + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ + Text( + text = ComposeTemplateBundle.message( + "weather.app.time.text", + formatDateTime(currentWeatherForecast.date) + ), + color = textColor, + fontSize = JewelTheme.defaultTextStyle.fontSize, + fontWeight = FontWeight.Bold + ) + + /** + * Jewel org.jetbrains.jewel.ui.component.ActionButton + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Button.kt + */ + ActionButton( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color.Transparent) + .padding(8.dp), + tooltip = { + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ + Text("Refresh weather data") + }, + onClick = { onReloadWeatherData(weatherForecastData.location) }, + ) { + /** + * Jewel org.jetbrains.jewel.ui.component.Icon + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt + */ + Icon( + key = AllIconsKeys.Actions.Refresh, + contentDescription = "Refresh", + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Temperature and weather type column (vertically aligned) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + /** + * Jewel org.jetbrains.jewel.ui.component.Icon + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt + */ Icon( - key = AllIconsKeys.Actions.Refresh, - contentDescription = "Refresh", - tint = Color.White + key = when { + isNightTime -> currentWeatherForecast.weatherType.nightIconKey + else -> currentWeatherForecast.weatherType.dayIconKey + }, + contentDescription = currentWeatherForecast.weatherType.label, + hint = EmbeddedToInlineCssSvgTransformerHint + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Temperature (emphasized) + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ + Text( + text = ComposeTemplateBundle.message( + "weather.app.temperature.text", + currentWeatherForecast.temperature.toInt() + ), + color = textColor, + fontSize = 32.sp, + fontWeight = FontWeight.ExtraBold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // City name + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ + Text( + text = weatherForecastData.location.label, + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.Bold ) } - } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // Temperature and weather type column (vertically aligned) - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { + // Wind and humidity info + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Wind info + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ + Text( + text = ComposeTemplateBundle.message( + "weather.app.wind.direction.text", + currentWeatherForecast.windSpeed.toInt(), + currentWeatherForecast.windDirection.label + ), + color = textColor, + fontSize = 18.sp, + ) - Icon( - key = when { - isNightTime -> currentWeatherForecast.weatherType.nightIconKey - else -> currentWeatherForecast.weatherType.dayIconKey - }, - contentDescription = currentWeatherForecast.weatherType.label, - hint = EmbeddedToInlineCssSvgTransformerHint - ) + // Humidity info + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ + Text( + text = ComposeTemplateBundle.message( + "weather.app.humidity.text", + currentWeatherForecast.humidity + ), + color = textColor, + fontSize = 18.sp, + ) + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // Temperature (emphasized) - Text( - text = ComposeTemplateBundle.message( - "weather.app.temperature.text", - currentWeatherForecast.temperature.toInt() - ), - color = textColor, - fontSize = 32.sp, - fontWeight = FontWeight.ExtraBold - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // City name - Text( - text = weatherForecastData.location.label, - color = textColor, - fontSize = 18.sp, - fontWeight = FontWeight.Bold + // 7-day forecast section + SevenDaysForecastWidget( + weatherForecastData, + Modifier + .fillMaxWidth() + .wrapContentHeight() + .align(Alignment.CenterHorizontally), + textColor ) } - - Spacer(modifier = Modifier.height(16.dp)) - - // Wind and humidity info - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - // Wind info - Text( - text = ComposeTemplateBundle.message( - "weather.app.wind.direction.text", - currentWeatherForecast.windSpeed.toInt(), - currentWeatherForecast.windDirection.label - ), - color = textColor, - fontSize = 18.sp, - ) - - // Humidity info - Text( - text = ComposeTemplateBundle.message( - "weather.app.humidity.text", - currentWeatherForecast.humidity - ), - color = textColor, - fontSize = 18.sp, - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - // 7-day forecast section - SevenDaysForecastWidget( - weatherForecastData, - Modifier - .fillMaxWidth() - .wrapContentHeight() - .align(Alignment.CenterHorizontally), - textColor - ) } } } @@ -187,6 +227,10 @@ private fun SevenDaysForecastWidget( ) { if (weatherForecastData.dailyForecasts.isNotEmpty()) { Column(modifier) { + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ Text( text = ComposeTemplateBundle.message("weather.app.7days.forecast.title.text"), color = textColor, @@ -198,8 +242,12 @@ private fun SevenDaysForecastWidget( Spacer(modifier = Modifier.height(8.dp)) val scrollState = rememberLazyListState() + /** + * Jewel org.jetbrains.jewel.ui.component.HorizontallyScrollableContainer + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ScrollableContainer.kt + */ HorizontallyScrollableContainer( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().safeContentPadding(), scrollState = scrollState, ) { LazyRow( @@ -244,6 +292,10 @@ private fun DayForecastItem( .padding(8.dp) ) { // Day name + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ Text( text = dayName, color = textColor, @@ -252,6 +304,10 @@ private fun DayForecastItem( textAlign = TextAlign.Center ) + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ Text( text = date, color = textColor, @@ -263,6 +319,10 @@ private fun DayForecastItem( Spacer(modifier = Modifier.height(8.dp)) // Weather icon + /** + * Jewel org.jetbrains.jewel.ui.component.Icon + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt + */ Icon( key = if (isNightTime(forecast.date)) forecast.weatherType.nightIconKey else forecast.weatherType.dayIconKey, contentDescription = forecast.weatherType.label, @@ -273,6 +333,10 @@ private fun DayForecastItem( Spacer(modifier = Modifier.height(8.dp)) // Temperature + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ Text( text = ComposeTemplateBundle.message( "weather.app.temperature.text", @@ -286,6 +350,10 @@ private fun DayForecastItem( Spacer(modifier = Modifier.height(8.dp)) // Humidity + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ Text( text = ComposeTemplateBundle.message( "weather.app.humidity.text", @@ -298,6 +366,10 @@ private fun DayForecastItem( Spacer(modifier = Modifier.height(8.dp)) // Wind direction + /** + * Jewel org.jetbrains.jewel.ui.component.Text + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt + */ Text( text = ComposeTemplateBundle.message( "weather.app.wind.direction.text", From ccd0a7a0b1c361e5fc745bfe4a9d59ad1d02dbee Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 5 Aug 2025 23:54:46 +0200 Subject: [PATCH 55/71] Refactor: Remove unused `setContentWrappedInTheme` extension function --- .../jetbrains/plugins/template/MyLocationListTest.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt index 5e04ce7..5e7d1c8 100644 --- a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt +++ b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt @@ -4,7 +4,6 @@ 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.ComposeContentTestRule import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -12,7 +11,6 @@ import androidx.compose.ui.test.performClick import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi @@ -180,14 +178,6 @@ internal class MyLocationListTest : ComposeBasedTestCase() { } } - private fun ComposeContentTestRule.setContentWrappedInTheme(content: @Composable () -> Unit) { - setContent { - IntUiTheme { - content() - } - } - } - private class MyLocationListRobot(private val composableRule: ComposeTestRule) { fun clickOnItemWithText(text: String) { From a079747d44d389e9a09b72a3c37967d56bc12fda Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 6 Aug 2025 18:08:38 +0200 Subject: [PATCH 56/71] Fix: Remove trailing '%' from `weather.app.time.text` in ComposeTemplate properties --- src/main/resources/messages/ComposeTemplate.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/messages/ComposeTemplate.properties b/src/main/resources/messages/ComposeTemplate.properties index 81f87f8..9ec96a2 100644 --- a/src/main/resources/messages/ComposeTemplate.properties +++ b/src/main/resources/messages/ComposeTemplate.properties @@ -1,5 +1,5 @@ weather.app.temperature.text={0}\u00B0C -weather.app.time.text=Time: {0}% +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 From a205391e0af4d1d9e7cdc907904c8f785a809dbe Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 6 Aug 2025 18:37:13 +0200 Subject: [PATCH 57/71] Add `CoroutineScopeHolder` service for project-wide coroutine management --- .../plugins/template/CoroutineScopeHolder.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/CoroutineScopeHolder.kt 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) +} From 4673a445bfd2b5af3979868fc7b33cedc6f8289f Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 09:51:48 +0200 Subject: [PATCH 58/71] Fix: Autocomplete popup confirms the last selected item instead of clicked one --- .../components/SearchBarWithAutoCompletion.kt | 22 +- .../CompletionPopupControllerTest.kt | 211 ++++++++++++++++++ 2 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 src/test/kotlin/org/jetbrains/plugins/template/components/CompletionPopupControllerTest.kt diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt index 29c5e7c..3fe49b8 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/SearchBarWithAutoCompletion.kt @@ -107,7 +107,7 @@ fun SearchBarWithAutoCompletion( selectableItem( completionItem.isSelected, onClick = { - popupController.onSelectCompletion() + popupController.onItemClicked(completionItem) textFieldState.setTextAndPlaceCursorAtEnd(completionItem.item.label) }, ) { @@ -147,12 +147,12 @@ fun CloseIconButton(onClick: () -> Unit) { } -private data class CompletionItem( +internal data class CompletionItem( val item: T, val isSelected: Boolean, ) -private class CompletionPopupController( +internal class CompletionPopupController( private val itemsProvider: SearchAutoCompletionItemProvider, private val onSelectCompletion: (CompletionItem) -> Unit = {}, ) { @@ -222,16 +222,22 @@ private class CompletionPopupController( clearFilteredItems() } - fun onSelectCompletion() { - if (!isVisible) return + fun onItemClicked(clickedItem: CompletionItem) { + doCompleteSelection(clickedItem) + } - val completionPopupItem = selectedItem + fun onSelectionConfirmed() { + doCompleteSelection(this.selectedItem) + } + + private fun doCompleteSelection(selectedItem: CompletionItem) { + if (!isVisible) return skipPopupShowing = true reset() - onSelectCompletion(completionPopupItem) + onSelectCompletion(selectedItem) } private fun updateFilteredItems(newItems: List>) { @@ -279,7 +285,7 @@ private fun Modifier.handlePopupCompletionKeyEvents( return@onPreviewKeyEvent when (keyEvent.key) { Key.Tab, Key.Enter, Key.NumPadEnter -> { - popupController.onSelectCompletion() + popupController.onSelectionConfirmed() true } 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 From 85f3a32137915162eb127a1aa147dc174ed1f92e Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 10:05:51 +0200 Subject: [PATCH 59/71] Refactor: Convert `WeatherForecastService` to an interface-based implementation and improve coroutine handling --- .../services/WeatherForecastService.kt | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) 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 index d926f84..8b52566 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt @@ -1,36 +1,43 @@ package org.jetbrains.plugins.template.weatherApp.services -import com.intellij.openapi.components.Service -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext 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 -@Service -class WeatherForecastService(private val cs: CoroutineScope) { - private val _weatherForecast: MutableStateFlow = MutableStateFlow(WeatherForecastData.EMPTY) - val weatherForecast: StateFlow = _weatherForecast.asStateFlow() +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 +} - fun loadWeatherForecastFor(location: Location) { - cs.launch(Dispatchers.IO) { - // TODO Cache data - emit(getWeatherData(location)) +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) } } } - private fun emit(weatherData: WeatherForecastData) { - _weatherForecast.value = weatherData - } - /** * Provides mock weather data for demonstration purposes. * In a real application, this would fetch data from a weather API. @@ -41,6 +48,7 @@ class WeatherForecastService(private val cs: CoroutineScope) { // Generate 7-day forecast data val dailyForecasts = generateDailyForecasts(currentTime) + // Simulates a network request delay(100) return WeatherForecastData( From c137351389963cbc6a5bf23471203aa2e1cc1014 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 10:08:07 +0200 Subject: [PATCH 60/71] Properly scope the coroutine scope of the WeatherAppSample ViewModel's scope is now tied to the scope of a WeatherAppSample Composable. Once WeatherAppSample Composable exits the composition tree, the used view model coroutine scope will be disposed. --- .../ComposeSamplesToolWindowFactory.kt | 28 +++++++- .../services/MyLocationsViewModel.kt | 67 ++++++++++++++----- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt index 9940b8a..faac271 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt @@ -1,28 +1,52 @@ 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.services.MyLocationsViewModel +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 viewModel = service() - val locationProviderApi = service() + val locationProviderApi = remember { service() } + val viewModel = remember { + val weatherForecastServiceApi = WeatherForecastService(Dispatchers.IO) + MyLocationsViewModel( + listOf(Location("Munich", "Germany")), + coroutineScopeHolder + .createScope(MyLocationsViewModel::class.java.simpleName), + weatherForecastServiceApi + ) + } + + DisposableEffect(Unit) { + viewModel.onReloadWeatherForecast() + + onDispose { viewModel.cancel() } + } + WeatherAppSample( viewModel, viewModel, locationProviderApi ) } + toolWindow.addComposeTab("Chat App") { ChatAppSample() } } diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt index cf72db7..45e94ae 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt @@ -1,9 +1,11 @@ package org.jetbrains.plugins.template.weatherApp.services -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service +import com.intellij.openapi.application.EDT import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData @@ -38,27 +40,48 @@ interface WeatherViewModelApi { fun onReloadWeatherForecast() } -@Service -class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelApi, WeatherViewModelApi { +/** + * 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 MyLocationsViewModel( + myInitialLocations: List, + private val viewModelScope: CoroutineScope, + private val weatherService: WeatherForecastServiceApi, +) : MyLocationsViewModelApi, WeatherViewModelApi { - private val weatherService = service() - - private val myLocations = MutableStateFlow(listOf(Location("Munich", "Germany"))) + private val myLocations = MutableStateFlow(myInitialLocations) private val selectedLocationIndex = MutableStateFlow(myLocations.value.lastIndex) - override val weatherForecast: Flow = weatherService.weatherForecast + private val _weatherForecast = MutableStateFlow(WeatherForecastData.EMPTY) + /** + * A stream of weather forecast data that emits updates whenever the forecast changes. + * + * This property exposes a Flow of [WeatherForecastData], which allows consumers to observe + * the weather forecast information for a selected location. + */ + override val weatherForecast: Flow = _weatherForecast.asStateFlow() + + /** + * A [StateFlow] that emits a list of [SelectableLocation] objects representing the user's + * current locations along with the selection state of each location. + */ override val myLocationsFlow: StateFlow> = myLocations .combine(selectedLocationIndex) { locations, selectedIndex -> locations.mapIndexed { index, location -> SelectableLocation(location, index == selectedIndex) } - }.stateIn(cs, SharingStarted.WhileSubscribed(), emptyList()) - - init { - onReloadWeatherForecast() - } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) override fun onAddLocation(locationToAdd: Location) { if (myLocations.value.contains(locationToAdd)) { @@ -98,6 +121,20 @@ class MyLocationsViewModel(cs: CoroutineScope) : MyLocationsViewModelApi, Weathe } override fun onLoadWeatherForecast(location: Location) { - weatherService.loadWeatherForecastFor(location) + viewModelScope.launch { + val weatherForecastData = weatherService.loadWeatherForecastFor(location).getOrNull() ?: return@launch + + _weatherForecast.value = weatherForecastData + } } -} \ No newline at end of file + + /** + * 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. + */ + fun cancel() { + viewModelScope.cancel() + } +} From 9591bc93f325e74c13bf0f06003356f7a46dc581 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 10:08:28 +0200 Subject: [PATCH 61/71] Refactor: Rename `MyLocationsViewModel` to `WeatherAppViewModel` and update references accordingly --- .../template/toolWindow/ComposeSamplesToolWindowFactory.kt | 6 +++--- .../{MyLocationsViewModel.kt => WeatherAppViewModel.kt} | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/{MyLocationsViewModel.kt => WeatherAppViewModel.kt} (99%) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt index faac271..f75481e 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt @@ -13,7 +13,7 @@ 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.services.MyLocationsViewModel +import org.jetbrains.plugins.template.weatherApp.services.WeatherAppViewModel import org.jetbrains.plugins.template.weatherApp.services.WeatherForecastService import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppSample @@ -26,10 +26,10 @@ class ComposeSamplesToolWindowFactory : ToolWindowFactory, DumbAware { val locationProviderApi = remember { service() } val viewModel = remember { val weatherForecastServiceApi = WeatherForecastService(Dispatchers.IO) - MyLocationsViewModel( + WeatherAppViewModel( listOf(Location("Munich", "Germany")), coroutineScopeHolder - .createScope(MyLocationsViewModel::class.java.simpleName), + .createScope(WeatherAppViewModel::class.java.simpleName), weatherForecastServiceApi ) } diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt similarity index 99% rename from src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt rename to src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt index 45e94ae..9078f4f 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/MyLocationsViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt @@ -52,7 +52,7 @@ interface WeatherViewModelApi { * @property viewModelScope The coroutine scope in which this ViewModel operates. * @property weatherService The service responsible for fetching weather forecasts for given locations. */ -class MyLocationsViewModel( +class WeatherAppViewModel( myInitialLocations: List, private val viewModelScope: CoroutineScope, private val weatherService: WeatherForecastServiceApi, From 015bd0426c1a8afc9872dac05fb452b22f32e4ed Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 10:11:01 +0200 Subject: [PATCH 62/71] Refactor: Make ViewModel APIs Disposable and rename `cancel` to `dispose` for cleanup consistency --- .../template/toolWindow/ComposeSamplesToolWindowFactory.kt | 2 +- .../template/weatherApp/services/WeatherAppViewModel.kt | 7 ++++--- .../org/jetbrains/plugins/template/MyLocationListTest.kt | 4 ++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt index f75481e..00c97ac 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt @@ -37,7 +37,7 @@ class ComposeSamplesToolWindowFactory : ToolWindowFactory, DumbAware { DisposableEffect(Unit) { viewModel.onReloadWeatherForecast() - onDispose { viewModel.cancel() } + onDispose { viewModel.dispose() } } WeatherAppSample( diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt index 9078f4f..da469b5 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt @@ -1,5 +1,6 @@ package org.jetbrains.plugins.template.weatherApp.services +import com.intellij.openapi.Disposable import com.intellij.openapi.application.EDT import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -18,7 +19,7 @@ import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData * a flow to observe the list of selectable locations. Implementations are expected to handle * location-related logic and state management. */ -interface MyLocationsViewModelApi { +interface MyLocationsViewModelApi : Disposable { fun onAddLocation(locationToAdd: Location) fun onDeleteLocation(locationToDelete: Location) @@ -32,7 +33,7 @@ interface MyLocationsViewModelApi { * Interface representing a ViewModel for managing weather-related data * and user interactions. */ -interface WeatherViewModelApi { +interface WeatherViewModelApi : Disposable { val weatherForecast: Flow fun onLoadWeatherForecast(location: Location) @@ -134,7 +135,7 @@ class WeatherAppViewModel( * 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. */ - fun cancel() { + override fun dispose() { viewModelScope.cancel() } } diff --git a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt index 5e7d1c8..48f4093 100644 --- a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt +++ b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt @@ -176,6 +176,10 @@ internal class MyLocationListTest : ComposeBasedTestCase() { override fun onLocationSelected(selectedLocationIndex: Int) { selectedItemIndex.value = selectedLocationIndex } + + override fun dispose() { + + } } private class MyLocationListRobot(private val composableRule: ComposeTestRule) { From de0692bba61b50eefc6724a7ef8373c6d5e8683d Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 10:40:27 +0200 Subject: [PATCH 63/71] Only reload weather data for location if the location weather forecast isn't already previewed --- .../template/weatherApp/services/WeatherAppViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt index da469b5..babf71e 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt @@ -92,7 +92,9 @@ class WeatherAppViewModel( selectedLocationIndex.value = myLocations.value.lastIndex } - onReloadWeatherForecast() + if (_weatherForecast.value.location != locationToAdd) { + onReloadWeatherForecast() + } } override fun onDeleteLocation(locationToDelete: Location) { From 54ca91fea43b25369e8466af33ce0584c84e28c4 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 11:02:13 +0200 Subject: [PATCH 64/71] Cancel ongoing weather forecast job before launching a new one to prevent duplicate requests --- .../template/weatherApp/services/WeatherAppViewModel.kt | 9 ++++++--- .../weatherApp/services/WeatherForecastService.kt | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt index babf71e..c0ccade 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt @@ -1,9 +1,8 @@ package org.jetbrains.plugins.template.weatherApp.services import com.intellij.openapi.Disposable -import com.intellij.openapi.application.EDT import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -59,6 +58,8 @@ class WeatherAppViewModel( private val weatherService: WeatherForecastServiceApi, ) : MyLocationsViewModelApi, WeatherViewModelApi { + private var currentWeatherJob: Job? = null + private val myLocations = MutableStateFlow(myInitialLocations) private val selectedLocationIndex = MutableStateFlow(myLocations.value.lastIndex) @@ -124,7 +125,9 @@ class WeatherAppViewModel( } override fun onLoadWeatherForecast(location: Location) { - viewModelScope.launch { + currentWeatherJob?.cancel() + + currentWeatherJob = viewModelScope.launch { val weatherForecastData = weatherService.loadWeatherForecastFor(location).getOrNull() ?: return@launch _weatherForecast.value = weatherForecastData 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 index 8b52566..d5ff58b 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt @@ -3,6 +3,7 @@ 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 @@ -45,10 +46,13 @@ class WeatherForecastService( 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 + // Simulates a network request and stops the execution in case the coroutine + // that launched the getWeatherData task is canceled delay(100) return WeatherForecastData( From cf248aa19bdabb2fb40cc76494ccef562d9609d9 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 14:55:36 +0200 Subject: [PATCH 65/71] Add `PulsingText` Composable for animated text appearance in loading scenarios --- .../template/components/PulsingText.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/components/PulsingText.kt 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 From 4824b281773084c2a9b7d661996a8a468ca2df39 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 15:06:16 +0200 Subject: [PATCH 66/71] Increase network simulation delay in `WeatherForecastService` to 3000ms for showcasing scenarios. --- .../template/weatherApp/services/WeatherForecastService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d5ff58b..3d7ff70 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherForecastService.kt @@ -53,7 +53,7 @@ class WeatherForecastService( // Simulates a network request and stops the execution in case the coroutine // that launched the getWeatherData task is canceled - delay(100) + delay(3000) return WeatherForecastData( location = location, From d62e8e9594dacd7619f018fcf0aa317021cf8f24 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 15:15:15 +0200 Subject: [PATCH 67/71] Introduce loading, error and success state for weather forecast loading scenario. --- .../services/WeatherAppViewModel.kt | 82 ++- .../weatherApp/ui/WeatherAppSample.kt | 4 +- .../ui/components/WeatherDetailsCard.kt | 507 +++++++++++++----- 3 files changed, 435 insertions(+), 158 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt index c0ccade..44e32bb 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt @@ -1,11 +1,8 @@ package org.jetbrains.plugins.template.weatherApp.services import com.intellij.openapi.Disposable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData @@ -33,13 +30,50 @@ interface MyLocationsViewModelApi : Disposable { * and user interactions. */ interface WeatherViewModelApi : Disposable { - val weatherForecast: Flow + 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 +} + /** * A ViewModel responsible for managing the user's locations and corresponding weather data. * @@ -64,15 +98,19 @@ class WeatherAppViewModel( private val selectedLocationIndex = MutableStateFlow(myLocations.value.lastIndex) - private val _weatherForecast = MutableStateFlow(WeatherForecastData.EMPTY) + private val _weatherState = MutableStateFlow(WeatherForecastUIState.Empty) /** - * A stream of weather forecast data that emits updates whenever the forecast changes. + * A flow representing the current UI state of the weather forecast. * - * This property exposes a Flow of [WeatherForecastData], which allows consumers to observe - * the weather forecast information for a selected location. + * This flow emits instances of [WeatherForecastUIState], which encapsulate information + * about the state of weather data loading and processing. The emitted states can represent + * scenarios such as the data being loaded, successfully fetched, an error occurring, or + * the absence of data when no location is selected. + * + * Observers of this flow can react to these state changes to update the UI accordingly. */ - override val weatherForecast: Flow = _weatherForecast.asStateFlow() + override val weatherForecastUIState: Flow = _weatherState.asStateFlow() /** * A [StateFlow] that emits a list of [SelectableLocation] objects representing the user's @@ -93,7 +131,7 @@ class WeatherAppViewModel( selectedLocationIndex.value = myLocations.value.lastIndex } - if (_weatherForecast.value.location != locationToAdd) { + if (_weatherState.value.getLocationOrNull() != locationToAdd) { onReloadWeatherForecast() } } @@ -128,9 +166,18 @@ class WeatherAppViewModel( currentWeatherJob?.cancel() currentWeatherJob = viewModelScope.launch { - val weatherForecastData = weatherService.loadWeatherForecastFor(location).getOrNull() ?: return@launch + _weatherState.value = WeatherForecastUIState.Loading(location) - _weatherForecast.value = weatherForecastData + weatherService.loadWeatherForecastFor(location) + .onSuccess { weatherData -> + _weatherState.value = WeatherForecastUIState.Success(weatherData) + }.onFailure { error -> + if (error is CancellationException) { + throw error + } + + _weatherState.value = errorStateFor(location, error) + } } } @@ -143,4 +190,13 @@ class WeatherAppViewModel( override fun dispose() { viewModelScope.cancel() } + + private fun errorStateFor( + location: Location, + error: Throwable + ): WeatherForecastUIState.Error = WeatherForecastUIState.Error( + "Failed to load weather forecast for ${location.label}", + location, + error + ) } 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 index 68994fb..8efeb9e 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -24,9 +24,9 @@ import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.plugins.template.ComposeTemplateBundle import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation -import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider +import org.jetbrains.plugins.template.weatherApp.services.WeatherForecastUIState import org.jetbrains.plugins.template.weatherApp.services.WeatherViewModelApi import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard @@ -201,7 +201,7 @@ private fun RightColumn( searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, modifier: Modifier = Modifier, ) { - val weatherForecastData = weatherViewModelApi.weatherForecast.collectAsState(WeatherForecastData.EMPTY).value + val weatherForecastData = weatherViewModelApi.weatherForecastUIState.collectAsState(WeatherForecastUIState.Empty).value Column(modifier) { SearchToolbarMenu( 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 index 82af7be..e40f81d 100644 --- 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 @@ -1,5 +1,6 @@ package org.jetbrains.plugins.template.weatherApp.ui.components +import androidx.compose.animation.core.* import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -12,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -21,35 +23,53 @@ import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.plugins.template.ComposeTemplateBundle +import org.jetbrains.plugins.template.components.PulsingText import org.jetbrains.plugins.template.weatherApp.WeatherAppColors import org.jetbrains.plugins.template.weatherApp.model.DailyForecast import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData +import org.jetbrains.plugins.template.weatherApp.services.WeatherForecastUIState +import org.jetbrains.plugins.template.weatherApp.ui.WeatherIcons import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.* + /** - * A composable function that displays a weather card with Jewel theme. - * The card displays city name, temperature, current time, wind information, - * humidity, and a background icon representing the weather state. - * The card and text color change based on temperature and time of day. + * Displays the weather details in a styled card format. This card presents various weather information + * including the current time, temperature, city name, wind details, humidity, and a 7-day forecast. + * The appearance and content dynamically change based on the given weather state. * - * @param weatherForecastData The weather data to display - * @param modifier Additional modifier for the card + * @param modifier Modifier to be applied to the card layout. + * @param weatherForecastState The current state of the weather forecast, which dictates the displayed content + * and appearance. It can represent loading, success, error, or empty states. + * @param onReloadWeatherData Callback invoked to reload weather data when the refresh action is triggered. It + * provides the location for which the weather data should be fetched. */ @OptIn(ExperimentalFoundationApi::class) @Composable fun WeatherDetailsCard( modifier: Modifier = Modifier, - weatherForecastData: WeatherForecastData, + weatherForecastState: WeatherForecastUIState, onReloadWeatherData: (Location) -> Unit ) { - val currentWeatherForecast = weatherForecastData.currentWeatherForecast - val isNightTime = isNightTime(currentWeatherForecast.date) - val cardColor = getCardColorByTemperature(currentWeatherForecast.temperature, isNightTime) - val textColor = Color.White + + val (cardColor, textColor) = when (weatherForecastState) { + is WeatherForecastUIState.Success -> { + val isNightTime = isNightTime(weatherForecastState.weatherForecastData.currentWeatherForecast.date) + val color = + getCardColorByTemperature( + weatherForecastState.weatherForecastData.currentWeatherForecast.temperature, + isNightTime + ) + color to Color.White + } + + is WeatherForecastUIState.Loading -> WeatherAppColors.mildWeatherColor to Color.White + is WeatherForecastUIState.Error -> WeatherAppColors.hotWeatherColor to Color.White // Brown for errors + is WeatherForecastUIState.Empty -> WeatherAppColors.coolWeatherColor to Color.White + } VerticallyScrollableContainer(modifier = modifier.safeContentPadding()) { Box( @@ -69,42 +89,18 @@ fun WeatherDetailsCard( horizontalArrangement = Arrangement.SpaceBetween ) { // Current Time - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ - Text( - text = ComposeTemplateBundle.message( - "weather.app.time.text", - formatDateTime(currentWeatherForecast.date) - ), - color = textColor, - fontSize = JewelTheme.defaultTextStyle.fontSize, - fontWeight = FontWeight.Bold - ) + TimeDisplay(weatherForecastState, textColor) - /** - * Jewel org.jetbrains.jewel.ui.component.ActionButton - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Button.kt - */ ActionButton( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(Color.Transparent) .padding(8.dp), - tooltip = { - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ - Text("Refresh weather data") + tooltip = { Text("Refresh weather data") }, + onClick = { + weatherForecastState.getLocationOrNull()?.let { onReloadWeatherData(it) } }, - onClick = { onReloadWeatherData(weatherForecastData.location) }, ) { - /** - * Jewel org.jetbrains.jewel.ui.component.Icon - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt - */ Icon( key = AllIconsKeys.Actions.Refresh, contentDescription = "Refresh", @@ -121,98 +117,33 @@ fun WeatherDetailsCard( horizontalAlignment = Alignment.CenterHorizontally ) { - /** - * Jewel org.jetbrains.jewel.ui.component.Icon - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt - */ - Icon( - key = when { - isNightTime -> currentWeatherForecast.weatherType.nightIconKey - else -> currentWeatherForecast.weatherType.dayIconKey - }, - contentDescription = currentWeatherForecast.weatherType.label, - hint = EmbeddedToInlineCssSvgTransformerHint - ) + WeatherIconDisplay(weatherForecastState) Spacer(modifier = Modifier.height(8.dp)) - // Temperature (emphasized) - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ - Text( - text = ComposeTemplateBundle.message( - "weather.app.temperature.text", - currentWeatherForecast.temperature.toInt() - ), - color = textColor, - fontSize = 32.sp, - fontWeight = FontWeight.ExtraBold - ) + TemperatureDisplay(weatherForecastState, textColor) Spacer(modifier = Modifier.height(8.dp)) // City name - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ - Text( - text = weatherForecastData.location.label, - color = textColor, - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) + CityNameDisplay(weatherForecastState, textColor) } Spacer(modifier = Modifier.height(16.dp)) // Wind and humidity info - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - // Wind info - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ - Text( - text = ComposeTemplateBundle.message( - "weather.app.wind.direction.text", - currentWeatherForecast.windSpeed.toInt(), - currentWeatherForecast.windDirection.label - ), - color = textColor, - fontSize = 18.sp, - ) - - // Humidity info - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ - Text( - text = ComposeTemplateBundle.message( - "weather.app.humidity.text", - currentWeatherForecast.humidity - ), - color = textColor, - fontSize = 18.sp, - ) - } + WeatherDetailsRow(Modifier.fillMaxWidth(), weatherForecastState, textColor) Spacer(modifier = Modifier.height(24.dp)) // 7-day forecast section SevenDaysForecastWidget( - weatherForecastData, + weatherForecastState, + textColor, Modifier .fillMaxWidth() .wrapContentHeight() - .align(Alignment.CenterHorizontally), - textColor + .align(Alignment.CenterHorizontally) ) } } @@ -227,10 +158,6 @@ private fun SevenDaysForecastWidget( ) { if (weatherForecastData.dailyForecasts.isNotEmpty()) { Column(modifier) { - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ Text( text = ComposeTemplateBundle.message("weather.app.7days.forecast.title.text"), color = textColor, @@ -242,10 +169,7 @@ private fun SevenDaysForecastWidget( Spacer(modifier = Modifier.height(8.dp)) val scrollState = rememberLazyListState() - /** - * Jewel org.jetbrains.jewel.ui.component.HorizontallyScrollableContainer - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ScrollableContainer.kt - */ + HorizontallyScrollableContainer( modifier = Modifier.fillMaxWidth().safeContentPadding(), scrollState = scrollState, @@ -292,10 +216,6 @@ private fun DayForecastItem( .padding(8.dp) ) { // Day name - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ Text( text = dayName, color = textColor, @@ -304,10 +224,6 @@ private fun DayForecastItem( textAlign = TextAlign.Center ) - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ Text( text = date, color = textColor, @@ -319,10 +235,6 @@ private fun DayForecastItem( Spacer(modifier = Modifier.height(8.dp)) // Weather icon - /** - * Jewel org.jetbrains.jewel.ui.component.Icon - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt - */ Icon( key = if (isNightTime(forecast.date)) forecast.weatherType.nightIconKey else forecast.weatherType.dayIconKey, contentDescription = forecast.weatherType.label, @@ -333,10 +245,6 @@ private fun DayForecastItem( Spacer(modifier = Modifier.height(8.dp)) // Temperature - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ Text( text = ComposeTemplateBundle.message( "weather.app.temperature.text", @@ -350,10 +258,6 @@ private fun DayForecastItem( Spacer(modifier = Modifier.height(8.dp)) // Humidity - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ Text( text = ComposeTemplateBundle.message( "weather.app.humidity.text", @@ -366,10 +270,6 @@ private fun DayForecastItem( Spacer(modifier = Modifier.height(8.dp)) // Wind direction - /** - * Jewel org.jetbrains.jewel.ui.component.Text - * @see https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Text.kt - */ Text( text = ComposeTemplateBundle.message( "weather.app.wind.direction.text", @@ -382,6 +282,327 @@ private fun DayForecastItem( } } +/** + * Time display component with loading state + */ +@Composable +private fun TimeDisplay( + weatherState: WeatherForecastUIState, + textColor: Color +) { + val text = when (weatherState) { + is WeatherForecastUIState.Success -> formatDateTime(weatherState.weatherForecastData.currentWeatherForecast.date) + else -> "-" + }.let { time -> ComposeTemplateBundle.message("weather.app.time.text", time) } + + PulsingText( + text, + weatherState.isLoading, + color = textColor, + fontSize = JewelTheme.defaultTextStyle.fontSize, + fontWeight = FontWeight.Bold + ) +} + +/** + * Weather icon that shows spinning progress during loading + */ +@Composable +private fun WeatherIconDisplay( + weatherState: WeatherForecastUIState, + modifier: Modifier = Modifier +) { + when (weatherState) { + is WeatherForecastUIState.Loading -> { + val infiniteTransition = rememberInfiniteTransition(label = "rotating_weather_icon") + + val rotation = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 3000, // 3 seconds per rotation + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "icon_rotation" + ).value + + Icon( + key = WeatherIcons.dayClear, + contentDescription = null, + modifier = modifier.rotate(rotation), + hint = EmbeddedToInlineCssSvgTransformerHint + ) + } + + is WeatherForecastUIState.Success -> { + val currentForecast = weatherState.weatherForecastData.currentWeatherForecast + val isNightTime = isNightTime(currentForecast.date) + + Icon( + key = if (isNightTime) { + currentForecast.weatherType.nightIconKey + } else { + currentForecast.weatherType.dayIconKey + }, + contentDescription = currentForecast.weatherType.label, + hint = EmbeddedToInlineCssSvgTransformerHint, + modifier = modifier + ) + } + + is WeatherForecastUIState.Error -> { + Icon( + key = AllIconsKeys.General.Warning, + contentDescription = "Weather data error", + tint = Color.White.copy(alpha = 0.8f), + modifier = modifier + ) + } + + is WeatherForecastUIState.Empty -> { + Icon( + key = AllIconsKeys.Actions.Find, + contentDescription = "No location selected", + tint = Color.White.copy(alpha = 0.6f), + modifier = modifier + ) + } + } +} + +/** + * Temperature display with loading animation + */ +@Composable +private fun TemperatureDisplay( + weatherState: WeatherForecastUIState, + textColor: Color +) { + val temperatureText = when (weatherState) { + is WeatherForecastUIState.Success -> ComposeTemplateBundle.message( + "weather.app.temperature.text", + weatherState.weatherForecastData.currentWeatherForecast.temperature.toInt() + ) + + is WeatherForecastUIState.Loading -> "--°" + is WeatherForecastUIState.Error -> "N/A°" + is WeatherForecastUIState.Empty -> "--°" + } + + PulsingText( + text = temperatureText, + isLoading = weatherState.isLoading, + color = textColor, + fontSize = 32.sp, + fontWeight = FontWeight.ExtraBold + ) +} + +/** + * City name display that shows "Loading..." during loading state + */ +@Composable +private fun CityNameDisplay( + weatherState: WeatherForecastUIState, + textColor: Color +) { + val loadingText = when (weatherState) { + is WeatherForecastUIState.Success -> weatherState.weatherForecastData.location.label + is WeatherForecastUIState.Loading -> weatherState.location.label + is WeatherForecastUIState.Error -> "weatherState.location.label} - Error" + is WeatherForecastUIState.Empty -> "Select a location" + } + + PulsingText( + text = loadingText, + isLoading = weatherState.isLoading, + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) +} + +/** + * Composable function to display a row of weather details including wind and humidity information. + * + * @param modifier A [Modifier] that can be used to customize the layout or add behavior to the composable. + * @param weatherState The current state of the weather forecast, represented by [WeatherForecastUIState]. + * This determines the display of wind and humidity information based on state. + * @param textColor The color to be applied to the text of the weather details. + */ +@Composable +private fun WeatherDetailsRow( + modifier: Modifier, + weatherState: WeatherForecastUIState, + textColor: Color +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Wind info + val windText = when (weatherState) { + is WeatherForecastUIState.Success -> { + val forecast = weatherState.weatherForecastData.currentWeatherForecast + ComposeTemplateBundle.message( + "weather.app.wind.direction.text", + forecast.windSpeed.toInt(), + forecast.windDirection.label + ) + } + + is WeatherForecastUIState.Loading -> "Wind: --" + is WeatherForecastUIState.Error -> "Wind: N/A" + is WeatherForecastUIState.Empty -> "Wind: --" + } + + PulsingText( + windText, + weatherState.isLoading, + color = textColor, + fontSize = 18.sp + ) + + // Humidity info + val humidityText = when (weatherState) { + is WeatherForecastUIState.Success -> ComposeTemplateBundle.message( + "weather.app.humidity.text", + weatherState.weatherForecastData.currentWeatherForecast.humidity + ) + + is WeatherForecastUIState.Loading -> "Humidity: -- %" + is WeatherForecastUIState.Error -> "Humidity: N/A" + is WeatherForecastUIState.Empty -> "Humidity: -- %" + } + PulsingText( + text = humidityText, + weatherState.isLoading, + color = textColor, + fontSize = 18.sp + ) + + } +} + +/** + * Forecast section that shows skeleton during loading + */ +@Composable +private fun SevenDaysForecastWidget( + weatherState: WeatherForecastUIState, + textColor: Color, + modifier: Modifier +) { + when (weatherState) { + is WeatherForecastUIState.Success -> { + if (weatherState.weatherForecastData.dailyForecasts.isNotEmpty()) { + SevenDaysForecastWidget( + weatherState.weatherForecastData, + modifier, + textColor + ) + } + } + + is WeatherForecastUIState.Loading -> LoadingForecastSkeleton(textColor) + is WeatherForecastUIState.Error -> ErrorForecastMessage(textColor) + is WeatherForecastUIState.Empty -> EmptyForecastMessage(textColor) + } +} + +/** + * Loading skeleton for forecast section + */ +@Composable +private fun LoadingForecastSkeleton(textColor: Color) { + Column { + Text( + text = ComposeTemplateBundle.message("weather.app.7days.forecast.title.text"), + color = textColor.copy(alpha = 0.7f), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + val scrollState = rememberLazyListState() + HorizontallyScrollableContainer( + modifier = Modifier.fillMaxWidth().safeContentPadding(), + scrollState = scrollState, + ) { + LazyRow( + state = scrollState, + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(count = 7) { LoadingForecastItem(textColor) } + } + } + } +} + +@Composable +private fun LoadingForecastItem(textColor: Color) { + val infiniteTransition = rememberInfiniteTransition() + val alpha = infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse + ) + ).value + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(120.dp) + .border(1.dp, textColor.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + Text("--", color = textColor.copy(alpha = alpha), fontSize = 14.sp) + Text("", color = textColor.copy(alpha = alpha), fontSize = 14.sp) + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = Modifier + .size(48.dp) + .background(textColor.copy(alpha = alpha), RoundedCornerShape(4.dp)) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text("--°", color = textColor.copy(alpha = alpha), fontSize = 16.sp) + Text("", color = textColor.copy(alpha = alpha), fontSize = 14.sp) + } +} + +@Composable +private fun ErrorForecastMessage(textColor: Color) { + Text( + text = "Forecast unavailable", + color = textColor.copy(alpha = 0.7f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) +} + +@Composable +private fun EmptyForecastMessage(textColor: Color) { + Text( + text = "Select a location to view forecast", + color = textColor.copy(alpha = 0.7f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) +} + /** * Returns the day name for a given date relative to the current date. * Returns "Today" for the current date, "Tomorrow" for the next day, From 8bb804c6831c9807aec8cb89f336d5f4f427b8d6 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 17:02:59 +0200 Subject: [PATCH 68/71] Add the ` LocationsUIState ` class for managing location selection logic. --- .../services/WeatherAppViewModel.kt | 123 +++++++++++ .../services/LocationsUIStateTest.kt | 208 ++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt index 44e32bb..1d2eb2d 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt @@ -74,6 +74,129 @@ sealed class WeatherForecastUIState { 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) + + /** + * 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. * 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..c3b5a3e --- /dev/null +++ b/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt @@ -0,0 +1,208 @@ +package org.jetbrains.plugins.template.weatherApp.services + +import org.jetbrains.plugins.template.weatherApp.model.Location +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 From 4167fae9d709ec325e76d3b31e488c13a7d9d048 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 17:46:13 +0200 Subject: [PATCH 69/71] Refactor: Simplify location state management by introducing `LocationsUIState` and updating related ViewModel and UI logic --- .../services/WeatherAppViewModel.kt | 66 +++++++++---------- .../weatherApp/ui/WeatherAppSample.kt | 39 ++++++----- .../plugins/template/MyLocationListTest.kt | 33 +++------- 3 files changed, 62 insertions(+), 76 deletions(-) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt index 1d2eb2d..4cc145e 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt @@ -2,7 +2,9 @@ package org.jetbrains.plugins.template.weatherApp.services import com.intellij.openapi.Disposable import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +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 @@ -22,7 +24,7 @@ interface MyLocationsViewModelApi : Disposable { fun onLocationSelected(selectedLocationIndex: Int) - val myLocationsFlow: Flow> + val myLocationsUIStateFlow: Flow } /** @@ -102,6 +104,8 @@ class LocationsUIState private constructor( val selectedLocation: Location? get() = locations.getOrNull(selectedIndex) + val isEmpty: Boolean get() = locations.isEmpty() + /** * Convert to UI representation with selection state */ @@ -217,9 +221,19 @@ class WeatherAppViewModel( private var currentWeatherJob: Job? = null - private val myLocations = MutableStateFlow(myInitialLocations) + private val _myLocationsUIStateFlow = MutableStateFlow(LocationsUIState.initial(myInitialLocations)) - private val selectedLocationIndex = MutableStateFlow(myLocations.value.lastIndex) + /** + * 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) @@ -235,24 +249,10 @@ class WeatherAppViewModel( */ override val weatherForecastUIState: Flow = _weatherState.asStateFlow() - /** - * A [StateFlow] that emits a list of [SelectableLocation] objects representing the user's - * current locations along with the selection state of each location. - */ - override val myLocationsFlow: StateFlow> = myLocations - .combine(selectedLocationIndex) { locations, selectedIndex -> - locations.mapIndexed { index, location -> - SelectableLocation(location, index == selectedIndex) - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) - override fun onAddLocation(locationToAdd: Location) { - if (myLocations.value.contains(locationToAdd)) { - selectedLocationIndex.value = myLocations.value.indexOf(locationToAdd) - } else { - myLocations.value += locationToAdd - selectedLocationIndex.value = myLocations.value.lastIndex - } + val newState = _myLocationsUIStateFlow.value.withLocationAdded(locationToAdd) + updateLocationsUIStateWith(newState) + if (_weatherState.value.getLocationOrNull() != locationToAdd) { onReloadWeatherForecast() @@ -260,27 +260,23 @@ class WeatherAppViewModel( } override fun onDeleteLocation(locationToDelete: Location) { - myLocations.value -= locationToDelete - - val itemIndex = myLocations.value.indexOf(locationToDelete) - val currentSelectedIndex = selectedLocationIndex.value - if (itemIndex in 0..currentSelectedIndex) { - selectedLocationIndex.value = (currentSelectedIndex - 1).coerceAtLeast(0) - } + val newState = _myLocationsUIStateFlow.value.withLocationDeleted(locationToDelete) + updateLocationsUIStateWith(newState) onReloadWeatherForecast() } override fun onLocationSelected(selectedLocationIndex: Int) { - if (this.selectedLocationIndex.value == selectedLocationIndex) return + val newState = _myLocationsUIStateFlow.value.withItemAtIndexSelected(selectedLocationIndex) + updateLocationsUIStateWith(newState) - this.selectedLocationIndex.value = selectedLocationIndex - - onReloadWeatherForecast() + if (_weatherState.value.getLocationOrNull() != newState.selectedLocation) { + onReloadWeatherForecast() + } } override fun onReloadWeatherForecast() { - myLocations.value.getOrNull(selectedLocationIndex.value)?.let { location -> + _myLocationsUIStateFlow.value.selectedLocation?.let { location -> onLoadWeatherForecast(location) } } @@ -314,6 +310,10 @@ class WeatherAppViewModel( viewModelScope.cancel() } + private fun updateLocationsUIStateWith(newState: LocationsUIState) { + _myLocationsUIStateFlow.value = newState + } + private fun errorStateFor( location: Location, error: Throwable 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 index 8efeb9e..d7f05bb 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.unit.dp import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn import org.jetbrains.jewel.foundation.lazy.SelectionMode -import org.jetbrains.jewel.foundation.lazy.items +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.* @@ -23,11 +23,7 @@ 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.model.SelectableLocation -import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi -import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider -import org.jetbrains.plugins.template.weatherApp.services.WeatherForecastUIState -import org.jetbrains.plugins.template.weatherApp.services.WeatherViewModelApi +import org.jetbrains.plugins.template.weatherApp.services.* import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard @@ -89,10 +85,11 @@ fun MyLocationsListWithEmptyListPlaceholder( modifier: Modifier = Modifier, myLocationsViewModelApi: MyLocationsViewModelApi ) { - val myLocations = myLocationsViewModelApi.myLocationsFlow.collectAsState(emptyList()).value + val myLocationsUIState = + myLocationsViewModelApi.myLocationsUIStateFlow.collectAsState(LocationsUIState.empty()).value - if (myLocations.isNotEmpty()) { - MyLocationList(myLocations, modifier, myLocationsViewModelApi) + if (!myLocationsUIState.isEmpty) { + MyLocationList(myLocationsUIState, modifier, myLocationsViewModelApi) } else { EmptyListPlaceholder(modifier) } @@ -128,23 +125,23 @@ private fun EmptyListPlaceholder( @Composable private fun MyLocationList( - myLocations: List, + myLocationsUIState: LocationsUIState, modifier: Modifier, myLocationsViewModelApi: MyLocationsViewModelApi ) { val listState = rememberSelectableLazyListState() // JEWEL-938 This will trigger on SelectableLazyColum's `onSelectedIndexesChange` callback - LaunchedEffect(myLocations) { + LaunchedEffect(myLocationsUIState) { var lastActiveItemIndex = -1 val selectedItemKeys = mutableSetOf() - myLocations.forEachIndexed { index, location -> - if (location.isSelected) { + 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.location.label) + selectedItemKeys.add(location.label) } } // Sets the first selected item as an active item to avoid triggering on click event when user clocks on it @@ -162,13 +159,13 @@ private fun MyLocationList( myLocationsViewModelApi.onLocationSelected(selectedLocationIndex) }, ) { - items( - items = myLocations, - key = { item -> item.location.label }, - ) { item -> + itemsIndexed( + items = myLocationsUIState.locations, + key = { _, item -> item.label }, + ) { index, item -> ContentItemRow( - item = item.location, isSelected = item.isSelected, isActive = isActive + item = item, isSelected = myLocationsUIState.selectedIndex == index, isActive = isActive ) } } @@ -201,7 +198,9 @@ private fun RightColumn( searchAutoCompletionItemProvider: SearchAutoCompletionItemProvider, modifier: Modifier = Modifier, ) { - val weatherForecastData = weatherViewModelApi.weatherForecastUIState.collectAsState(WeatherForecastUIState.Empty).value + val weatherForecastData = weatherViewModelApi + .weatherForecastUIState + .collectAsState(WeatherForecastUIState.Empty).value Column(modifier) { SearchToolbarMenu( diff --git a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt index 48f4093..3b89223 100644 --- a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt +++ b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt @@ -10,9 +10,9 @@ 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.combine +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.services.LocationsUIState import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi import org.jetbrains.plugins.template.weatherApp.ui.MyLocationsListWithEmptyListPlaceholder import org.junit.Test @@ -145,40 +145,27 @@ internal class MyLocationListTest : ComposeBasedTestCase() { locations: List = emptyList() ) : MyLocationsViewModelApi { - private val locationsFlow = MutableStateFlow(locations.toMutableList()) + private val _myLocationsUIStateFlow: MutableStateFlow = + MutableStateFlow(LocationsUIState.initial(locations)) - private val selectedItemIndex = MutableStateFlow(if (locations.isNotEmpty()) 0 else -1) - private val _myLocations = locationsFlow - .combine(selectedItemIndex) { locations, selectedIndex -> - locations.mapIndexed { index, location -> - SelectableLocation(location, index == selectedIndex) - } - } - - override val myLocationsFlow: Flow> = _myLocations override fun onAddLocation(locationToAdd: Location) { - val currentLocations = locationsFlow.value - currentLocations.add(locationToAdd) - - locationsFlow.value = currentLocations - selectedItemIndex.value = currentLocations.lastIndex + _myLocationsUIStateFlow.value = _myLocationsUIStateFlow.value.withLocationAdded(locationToAdd) } override fun onDeleteLocation(locationToDelete: Location) { - val currentLocations = locationsFlow.value - currentLocations.remove(locationToDelete) + _myLocationsUIStateFlow.value = _myLocationsUIStateFlow.value.withLocationDeleted(locationToDelete) - locationsFlow.value = currentLocations - selectedItemIndex.value = currentLocations.lastIndex } override fun onLocationSelected(selectedLocationIndex: Int) { - selectedItemIndex.value = selectedLocationIndex + _myLocationsUIStateFlow.value = _myLocationsUIStateFlow.value.withItemAtIndexSelected(selectedLocationIndex) } - override fun dispose() { + override val myLocationsUIStateFlow: Flow + get() = _myLocationsUIStateFlow.asStateFlow() + override fun dispose() { } } From 222a29686806ff2a004b2f59c338c622500eaa51 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 17:48:35 +0200 Subject: [PATCH 70/71] Move WeatherAppViewModel to ui package --- .../template/toolWindow/ComposeSamplesToolWindowFactory.kt | 2 +- .../weatherApp/{services => ui}/WeatherAppViewModel.kt | 3 ++- .../template/weatherApp/ui/components/WeatherDetailsCard.kt | 2 +- .../org/jetbrains/plugins/template/MyLocationListTest.kt | 4 ++-- .../template/weatherApp/services/LocationsUIStateTest.kt | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) rename src/main/kotlin/org/jetbrains/plugins/template/weatherApp/{services => ui}/WeatherAppViewModel.kt (98%) diff --git a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt index 00c97ac..8de6428 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/toolWindow/ComposeSamplesToolWindowFactory.kt @@ -13,7 +13,7 @@ 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.services.WeatherAppViewModel +import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppViewModel import org.jetbrains.plugins.template.weatherApp.services.WeatherForecastService import org.jetbrains.plugins.template.weatherApp.ui.WeatherAppSample diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppViewModel.kt similarity index 98% rename from src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt rename to src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppViewModel.kt index 4cc145e..8f49818 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppViewModel.kt @@ -1,4 +1,4 @@ -package org.jetbrains.plugins.template.weatherApp.services +package org.jetbrains.plugins.template.weatherApp.ui import com.intellij.openapi.Disposable import kotlinx.coroutines.* @@ -8,6 +8,7 @@ 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 /** 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 index e40f81d..e790892 100644 --- 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 @@ -28,7 +28,7 @@ import org.jetbrains.plugins.template.weatherApp.WeatherAppColors import org.jetbrains.plugins.template.weatherApp.model.DailyForecast import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.WeatherForecastData -import org.jetbrains.plugins.template.weatherApp.services.WeatherForecastUIState +import org.jetbrains.plugins.template.weatherApp.ui.WeatherForecastUIState import org.jetbrains.plugins.template.weatherApp.ui.WeatherIcons import java.time.LocalDateTime import java.time.format.DateTimeFormatter diff --git a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt index 3b89223..097cd11 100644 --- a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt +++ b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt @@ -12,8 +12,8 @@ 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.services.LocationsUIState -import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi +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 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 index c3b5a3e..f4a76cd 100644 --- a/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt +++ b/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt @@ -1,6 +1,7 @@ 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 From a682e6dc25b30ecd47efc5451ceb0b1dbab49e96 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Fri, 8 Aug 2025 00:33:05 +0200 Subject: [PATCH 71/71] Refactor: Replace `ContentItemRow` with simplified `SimpleListItem` in locations list rendering --- .../weatherApp/ui/WeatherAppSample.kt | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) 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 index d7f05bb..43b6a6d 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -1,6 +1,5 @@ package org.jetbrains.plugins.template.weatherApp.ui -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -8,11 +7,8 @@ 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.graphics.Color.Companion.Transparent import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn import org.jetbrains.jewel.foundation.lazy.SelectionMode import org.jetbrains.jewel.foundation.lazy.itemsIndexed @@ -164,33 +160,11 @@ private fun MyLocationList( key = { _, item -> item.label }, ) { index, item -> - ContentItemRow( - item = item, isSelected = myLocationsUIState.selectedIndex == index, isActive = isActive - ) + SimpleListItem(text = item.label, isSelected = myLocationsUIState.selectedIndex == index, isActive = isActive) } } } -@Composable -private fun ContentItemRow(item: Location, isSelected: Boolean, isActive: Boolean) { - val color = when { - isSelected && isActive -> retrieveColorOrUnspecified("List.selectionBackground") - isSelected && !isActive -> retrieveColorOrUnspecified("List.selectionInactiveBackground") - else -> Transparent - } - Row( - modifier = Modifier - .height(JewelTheme.globalMetrics.rowHeight) - .background(color) - .padding(horizontal = 4.dp) - .padding(end = scrollbarContentSafePadding()), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text(text = item.label, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) - } -} - @Composable private fun RightColumn( myLocationViewModel: MyLocationsViewModelApi,