mirror of
https://github.com/JetBrains/intellij-platform-plugin-template.git
synced 2025-12-05 06:11:52 +00:00
Refactor: Simplify location state management by introducing LocationsUIState and updating related ViewModel and UI logic
This commit is contained in:
parent
8bb804c683
commit
4167fae9d7
@ -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<List<SelectableLocation>>
|
||||
val myLocationsUIStateFlow: Flow<LocationsUIState>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -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<LocationsUIState> = _myLocationsUIStateFlow.asStateFlow()
|
||||
|
||||
private val _weatherState = MutableStateFlow<WeatherForecastUIState>(WeatherForecastUIState.Empty)
|
||||
|
||||
@ -235,24 +249,10 @@ class WeatherAppViewModel(
|
||||
*/
|
||||
override val weatherForecastUIState: Flow<WeatherForecastUIState> = _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<List<SelectableLocation>> = 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
|
||||
|
||||
this.selectedLocationIndex.value = selectedLocationIndex
|
||||
val newState = _myLocationsUIStateFlow.value.withItemAtIndexSelected(selectedLocationIndex)
|
||||
updateLocationsUIStateWith(newState)
|
||||
|
||||
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
|
||||
|
||||
@ -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<SelectableLocation>,
|
||||
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<String>()
|
||||
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<Location>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val weatherForecastData = weatherViewModelApi.weatherForecastUIState.collectAsState(WeatherForecastUIState.Empty).value
|
||||
val weatherForecastData = weatherViewModelApi
|
||||
.weatherForecastUIState
|
||||
.collectAsState(WeatherForecastUIState.Empty).value
|
||||
|
||||
Column(modifier) {
|
||||
SearchToolbarMenu(
|
||||
|
||||
@ -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<Location> = emptyList()
|
||||
) : MyLocationsViewModelApi {
|
||||
|
||||
private val locationsFlow = MutableStateFlow(locations.toMutableList())
|
||||
private val _myLocationsUIStateFlow: MutableStateFlow<LocationsUIState> =
|
||||
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<List<SelectableLocation>> = _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<LocationsUIState>
|
||||
get() = _myLocationsUIStateFlow.asStateFlow()
|
||||
|
||||
override fun dispose() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user