From c137351389963cbc6a5bf23471203aa2e1cc1014 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 10:08:07 +0200 Subject: [PATCH] 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() + } +}