From e2dc37ba26b2c809e8ca35339fc24c2132debd19 Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Sat, 29 Aug 2026 10:58:33 +0300 Subject: [PATCH] Prepare server rows outside composition (#6107) Build presentation-ready server row models in the ViewModel and publish each group's raw data and row data as one atomic state. This removes storage and description work from composition, reduces the server-row API, and keeps reordering consistent across both representations. --- .../java/com/v2ray/ang/ui/main/MainScreen.kt | 1 - .../com/v2ray/ang/ui/main/MainServerPager.kt | 240 ++++++------------ .../v2ray/ang/ui/main/MainServerRowModels.kt | 61 +++++ .../com/v2ray/ang/ui/main/MainViewModel.kt | 85 +++++-- 4 files changed, 211 insertions(+), 176 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerRowModels.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainScreen.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainScreen.kt index e979cbd6..801d46c1 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainScreen.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainScreen.kt @@ -226,7 +226,6 @@ fun MainScreen( selectedGuid = selectedGuid, locateTarget = uiState.locateTarget, doubleColumnDisplay = doubleColumnDisplay, - confirmRemove = confirmRemove, searchQuery = searchQuery, lazyListStates = lazyListStates, lazyGridStates = lazyGridStates, diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt index 26a473cb..304b7fc4 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt @@ -49,11 +49,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.v2ray.ang.R import com.v2ray.ang.dto.LocateTarget import com.v2ray.ang.dto.entities.ProfileItem -import com.v2ray.ang.dto.entities.ServersCache -import com.v2ray.ang.extension.isComplexType -import com.v2ray.ang.extension.nullIfBlank -import com.v2ray.ang.handler.AngConfigManager -import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.ui.compose.ItemDivider import com.v2ray.ang.ui.compose.ReorderableGridItem import com.v2ray.ang.ui.compose.ReorderableListItem @@ -73,7 +68,6 @@ fun GroupPagerPage( selectedGuid: String?, locateTarget: LocateTarget?, doubleColumnDisplay: Boolean, - confirmRemove: Boolean, searchQuery: String, lazyListStates: MutableMap, lazyGridStates: MutableMap, @@ -84,50 +78,63 @@ fun GroupPagerPage( onRemoveServer: (String) -> Unit, contentPadding: PaddingValues ) { - val serverFlow = remember(groupId) { - mainViewModel.serversForGroup(groupId) + val groupStateFlow = remember(groupId) { + mainViewModel.serverGroupState(groupId) } - val servers by serverFlow.collectAsStateWithLifecycle() + val groupState by groupStateFlow.collectAsStateWithLifecycle() val canReorder = groupId.isNotEmpty() && searchQuery.isEmpty() + val actions = remember( + onSelectServer, + onEditServer, + onShareServer, + onMoreServer, + onRemoveServer, + ) { + ServerRowActions( + select = onSelectServer, + edit = onEditServer, + share = onShareServer, + more = onMoreServer, + remove = onRemoveServer, + ) + } ServerListPage( - servers = servers, + rows = groupState.rows, selectedGuid = selectedGuid, locateTarget = locateTarget?.takeIf { it.groupId == groupId }, canReorder = canReorder, doubleColumnDisplay = doubleColumnDisplay, - subscriptionId = groupId, - confirmRemove = confirmRemove, groupId = groupId, lazyListStates = lazyListStates, lazyGridStates = lazyGridStates, - onSelectServer = onSelectServer, - onEditServer = onEditServer, - onShareServer = onShareServer, - onMoreServer = onMoreServer, - onRemoveServer = onRemoveServer, + actions = actions, onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) }, - onMoveServer = { fromIndex, toIndex -> mainViewModel.moveServer(groupId, fromIndex, toIndex) }, + onMoveServer = { fromIndex, toIndex -> + mainViewModel.moveServer(groupId, fromIndex, toIndex) + }, contentPadding = contentPadding ) } +private class ServerRowActions( + val select: (String) -> Unit, + val edit: (String, ProfileItem) -> Unit, + val share: (String, ProfileItem) -> Unit, + val more: (String, ProfileItem) -> Unit, + val remove: (String) -> Unit, +) + @Composable private fun ServerListPage( - servers: List, + rows: List, selectedGuid: String?, locateTarget: LocateTarget?, canReorder: Boolean, doubleColumnDisplay: Boolean, - subscriptionId: String, - confirmRemove: Boolean, groupId: String, lazyListStates: MutableMap, lazyGridStates: MutableMap, - onSelectServer: (String) -> Unit, - onEditServer: (String, ProfileItem) -> Unit, - onShareServer: (String, ProfileItem) -> Unit, - onMoreServer: (String, ProfileItem) -> Unit, - onRemoveServer: (String) -> Unit, + actions: ServerRowActions, onLocateHandled: () -> Unit, onMoveServer: (Int, Int) -> Unit, contentPadding: PaddingValues @@ -142,7 +149,7 @@ private fun ServerListPage( } } else null - LocateTargetEffect(locateTarget, servers, gridState, onLocateHandled) + LocateTargetEffect(locateTarget, rows, gridState, onLocateHandled) LazyVerticalGrid( columns = GridCells.Fixed(2), @@ -152,24 +159,19 @@ private fun ServerListPage( .verticalScrollbar(gridState), contentPadding = contentPadding ) { - itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache -> + itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row -> val content: @Composable () -> Unit = { ServerItemColumn( - serverCache = serverCache, - selectedGuid = selectedGuid, - subscriptionId = subscriptionId, + row = row, + isSelected = row.guid == selectedGuid, doubleColumnDisplay = true, - onSelectServer = onSelectServer, - onEditServer = onEditServer, - onShareServer = onShareServer, - onMoreServer = onMoreServer, - onRemoveServer = onRemoveServer + actions = actions ) } if (canReorder && reorderableGridState != null) { ReorderableItem( reorderableGridState, - key = serverCache.guid + key = row.guid ) { isDragging -> ReorderableGridItem( scope = this, @@ -191,7 +193,7 @@ private fun ServerListPage( } } else null - LocateTargetEffect(locateTarget, servers, listState, onLocateHandled) + LocateTargetEffect(locateTarget, rows, listState, onLocateHandled) LazyColumn( state = listState, @@ -200,39 +202,29 @@ private fun ServerListPage( .verticalScrollbar(listState), contentPadding = contentPadding ) { - itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache -> + itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row -> if (canReorder && reorderableState != null) { ReorderableItem( reorderableState, - key = serverCache.guid + key = row.guid ) { isDragging -> ReorderableListItem( scope = this, isDragging = isDragging ) { ServerItemRow( - serverCache = serverCache, - selectedGuid = selectedGuid, - subscriptionId = subscriptionId, - onSelectServer = onSelectServer, - onEditServer = onEditServer, - onShareServer = onShareServer, - onMoreServer = onMoreServer, - onRemoveServer = onRemoveServer + row = row, + isSelected = row.guid == selectedGuid, + actions = actions ) } ItemDivider() } } else { ServerItemRow( - serverCache = serverCache, - selectedGuid = selectedGuid, - subscriptionId = subscriptionId, - onSelectServer = onSelectServer, - onEditServer = onEditServer, - onShareServer = onShareServer, - onMoreServer = onMoreServer, - onRemoveServer = onRemoveServer + row = row, + isSelected = row.guid == selectedGuid, + actions = actions ) ItemDivider() } @@ -244,13 +236,13 @@ private fun ServerListPage( @Composable private fun LocateTargetEffect( target: LocateTarget?, - servers: List, + rows: List, state: LazyListState, onHandled: () -> Unit, ) { if (target == null) return - LaunchedEffect(target, servers) { - val index = servers.indexOfFirst { it.guid == target.serverGuid } + LaunchedEffect(target, rows) { + val index = rows.indexOfFirst { it.guid == target.serverGuid } if (index < 0) return@LaunchedEffect state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3) onHandled() @@ -260,13 +252,13 @@ private fun LocateTargetEffect( @Composable private fun LocateTargetEffect( target: LocateTarget?, - servers: List, + rows: List, state: LazyGridState, onHandled: () -> Unit, ) { if (target == null) return - LaunchedEffect(target, servers) { - val index = servers.indexOfFirst { it.guid == target.serverGuid } + LaunchedEffect(target, rows) { + val index = rows.indexOfFirst { it.guid == target.serverGuid } if (index < 0) return@LaunchedEffect state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3) onHandled() @@ -275,94 +267,47 @@ private fun LocateTargetEffect( @Composable private fun ServerItemRow( - serverCache: ServersCache, - selectedGuid: String?, - subscriptionId: String, - onSelectServer: (String) -> Unit, - onEditServer: (String, ProfileItem) -> Unit, - onShareServer: (String, ProfileItem) -> Unit, - onMoreServer: (String, ProfileItem) -> Unit, - onRemoveServer: (String) -> Unit + row: ServerRowUiModel, + isSelected: Boolean, + actions: ServerRowActions ) { - val profile = serverCache.profile - val subRemarks = if (subscriptionId.isEmpty()) { - MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull() - ?.toString() ?: "" - } else "" - ServerListItem( - remarks = profile.remarks, - statistics = profile.description.nullIfBlank() - ?: AngConfigManager.generateDescription(profile), - typeDescription = getProtocolDescription(profile), - testDelayMillis = serverCache.testDelayMillis, - isSelected = serverCache.guid == selectedGuid, - subscriptionRemarks = subRemarks, + row = row, + isSelected = isSelected, doubleColumnDisplay = false, - onClick = { onSelectServer(serverCache.guid) }, - onShare = { onShareServer(serverCache.guid, profile) }, - onEdit = { onEditServer(serverCache.guid, profile) }, - onRemove = { onRemoveServer(serverCache.guid) }, - onMore = { onMoreServer(serverCache.guid, profile) } + actions = actions ) } @Composable private fun ServerItemColumn( - serverCache: ServersCache, - selectedGuid: String?, - subscriptionId: String, + row: ServerRowUiModel, + isSelected: Boolean, doubleColumnDisplay: Boolean, - onSelectServer: (String) -> Unit, - onEditServer: (String, ProfileItem) -> Unit, - onShareServer: (String, ProfileItem) -> Unit, - onMoreServer: (String, ProfileItem) -> Unit, - onRemoveServer: (String) -> Unit + actions: ServerRowActions ) { - val profile = serverCache.profile - val subRemarks = if (subscriptionId.isEmpty()) { - MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()?.toString() ?: "" - } else "" Column { ServerListItem( - remarks = profile.remarks, - statistics = profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile), - typeDescription = getProtocolDescription(profile), - testDelayMillis = serverCache.testDelayMillis, - isSelected = serverCache.guid == selectedGuid, - subscriptionRemarks = subRemarks, + row = row, + isSelected = isSelected, doubleColumnDisplay = doubleColumnDisplay, - onClick = { onSelectServer(serverCache.guid) }, - onEdit = { onEditServer(serverCache.guid, profile) }, - onShare = { onShareServer(serverCache.guid, profile) }, - onRemove = { onRemoveServer(serverCache.guid) }, - onMore = { onMoreServer(serverCache.guid, profile) } + actions = actions ) ItemDivider() } } @Composable -fun ServerListItem( - remarks: String, - statistics: String, - typeDescription: String, - testDelayMillis: Long, +private fun ServerListItem( + row: ServerRowUiModel, isSelected: Boolean, - subscriptionRemarks: String, doubleColumnDisplay: Boolean, - onClick: () -> Unit, - onEdit: () -> Unit, - onShare: () -> Unit, - onRemove: () -> Unit, - onMore: () -> Unit, - modifier: Modifier = Modifier, - dragModifier: Modifier = Modifier + actions: ServerRowActions ) { - val testResult = if (testDelayMillis == 0L) { + val testResult = if (row.testDelayMillis == 0L) { "" } else { - stringResource(R.string.server_test_delay_value, testDelayMillis) + stringResource(R.string.server_test_delay_value, row.testDelayMillis) } val selectedStateDescription = if (isSelected) { stringResource(R.string.acc_selected_server) @@ -370,7 +315,7 @@ fun ServerListItem( null } Row( - modifier = modifier + modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) .semantics { @@ -378,8 +323,7 @@ fun ServerListItem( stateDescription = selectedStateDescription } } - .clickable(onClick = onClick) - .then(dragModifier) + .clickable { actions.select(row.guid) } ) { Box( Modifier @@ -406,9 +350,9 @@ fun ServerListItem( .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp) ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(row.remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis) if (doubleColumnDisplay) { - IconButton(onClick = onMore, Modifier.size(36.dp)) { + IconButton(onClick = { actions.more(row.guid, row.profile) }, Modifier.size(36.dp)) { Icon( painterResource(R.drawable.ic_more_vert_24dp), stringResource(R.string.acc_more), @@ -416,21 +360,21 @@ fun ServerListItem( ) } } else { - IconButton(onClick = onShare, Modifier.size(36.dp)) { + IconButton(onClick = { actions.share(row.guid, row.profile) }, Modifier.size(36.dp)) { Icon( painterResource(R.drawable.ic_share_24dp), stringResource(R.string.title_configuration_share), Modifier.size(24.dp) ) } - IconButton(onClick = onEdit, Modifier.size(36.dp)) { + IconButton(onClick = { actions.edit(row.guid, row.profile) }, Modifier.size(36.dp)) { Icon( painterResource(R.drawable.ic_edit_24dp), stringResource(R.string.acc_edit), Modifier.size(24.dp) ) } - IconButton(onClick = onRemove, Modifier.size(36.dp)) { + IconButton(onClick = { actions.remove(row.guid) }, Modifier.size(36.dp)) { Icon( painterResource(R.drawable.ic_delete_24dp), stringResource(R.string.acc_delete), @@ -441,45 +385,27 @@ fun ServerListItem( } Spacer(modifier = Modifier.height(6.dp)) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - if (subscriptionRemarks.isNotBlank()) { + if (row.subscriptionBadge.isNotBlank()) { Box( Modifier .size(24.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), Alignment.Center ) { - Text(subscriptionRemarks.take(1).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Text(row.subscriptionBadge.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) } } - Text(statistics, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(row.statistics, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } Spacer(modifier = Modifier.height(6.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(row.typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (row.testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } } -private fun getProtocolDescription(profile: ProfileItem): String { - if (profile.configType.isComplexType()) return profile.configType.name - val parts = mutableListOf(profile.configType.name) - profile.network?.let { net -> - if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) parts.add(net) - } - profile.security?.let { sec -> - if (sec.isNotBlank()) { - if (profile.insecure == true && sec.equals("tls", ignoreCase = true)) { - parts.add("$sec insecure") - } else { - parts.add(sec) - } - } - } - return parts.joinToString(" / ") -} - internal suspend fun PagerState.navigateToPageOptimized( targetPage: Int, animateAdjacentPage: Boolean = true diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerRowModels.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerRowModels.kt new file mode 100644 index 00000000..557e7bfb --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerRowModels.kt @@ -0,0 +1,61 @@ +package com.v2ray.ang.ui.main + +import com.v2ray.ang.dto.entities.ProfileItem +import com.v2ray.ang.dto.entities.ServersCache +import com.v2ray.ang.extension.isComplexType +import com.v2ray.ang.extension.nullIfBlank +import com.v2ray.ang.handler.AngConfigManager + +internal data class ServerRowUiModel( + val guid: String, + val profile: ProfileItem, + val remarks: String, + val statistics: String, + val typeDescription: String, + val testDelayMillis: Long, + val subscriptionBadge: String, +) + +internal data class ServerGroupUiState( + val servers: List = emptyList(), + val rows: List = emptyList(), +) + +internal fun buildServerRowUiModel( + server: ServersCache, + subscriptionRemarks: String, +): ServerRowUiModel { + val profile = server.profile + return ServerRowUiModel( + guid = server.guid, + profile = profile, + remarks = profile.remarks, + statistics = profile.description.nullIfBlank() + ?: AngConfigManager.generateDescription(profile), + typeDescription = serverProtocolDescription(profile), + testDelayMillis = server.testDelayMillis, + subscriptionBadge = subscriptionRemarks.firstOrNull()?.toString().orEmpty(), + ) +} + +private fun serverProtocolDescription(profile: ProfileItem): String { + if (profile.configType.isComplexType()) return profile.configType.name + val parts = mutableListOf(profile.configType.name) + profile.network?.let { network -> + if (network.isNotBlank() && !network.equals("tcp", ignoreCase = true)) { + parts.add(network) + } + } + profile.security?.let { security -> + if (security.isNotBlank()) { + parts.add( + if (profile.insecure == true && security.equals("tls", ignoreCase = true)) { + "$security insecure" + } else { + security + } + ) + } + } + return parts.joinToString(" / ") +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt index ac6a3165..4dfb2d38 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt @@ -27,8 +27,11 @@ import kotlinx.coroutines.currentCoroutineContext import com.v2ray.ang.extension.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -65,7 +68,8 @@ class MainViewModel( // ---------- Groups & cache ---------- private val cacheMutex = Mutex() private val groupDataCache = mutableMapOf>() - private val groupPageFlows = ConcurrentHashMap>>() + private val groupUiFlows = ConcurrentHashMap>() + private val groupServerFlows = ConcurrentHashMap>>() private val groupLoadMutexes = ConcurrentHashMap() private val serverOrderPersistenceJobs = mutableMapOf() @@ -163,14 +167,25 @@ class MainViewModel( // ---------- Public state accessors ---------- fun serversForGroup(groupId: String): StateFlow> = - groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) } - .asStateFlow() + groupServerFlows.computeIfAbsent(groupId) { + val groupState = mutableServerGroupState(groupId) + groupState + .map { it.servers } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000), + initialValue = groupState.value.servers, + ) + } - private fun mutableServersForGroup(groupId: String): MutableStateFlow> = - groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) } + internal fun serverGroupState(groupId: String): StateFlow = + mutableServerGroupState(groupId).asStateFlow() + + private fun mutableServerGroupState(groupId: String): MutableStateFlow = + groupUiFlows.computeIfAbsent(groupId) { MutableStateFlow(ServerGroupUiState()) } private fun currentServers(): List = - mutableServersForGroup(uiState.value.selectedGroupId).value + mutableServerGroupState(uiState.value.selectedGroupId).value.servers // ---------- Action handler ---------- fun onAction(action: MainAction) { @@ -289,7 +304,31 @@ class MainViewModel( } private fun updateGroupUi(groupId: String, servers: List) { - mutableServersForGroup(groupId).value = applyKeywordFilter(servers) + val filteredServers = applyKeywordFilter(servers) + mutableServerGroupState(groupId).value = ServerGroupUiState( + servers = filteredServers, + rows = buildServerRows(groupId, filteredServers) + ) + } + + private fun buildServerRows(groupId: String, servers: List): List { + val subscriptionRemarks = if (groupId.isEmpty()) { + servers.asSequence() + .map { it.profile.subscriptionId } + .filter { it.isNotEmpty() } + .distinct() + .associateWith { subscriptionId -> + dataSource.getSubscriptionItem(subscriptionId)?.remarks.orEmpty() + } + } else { + emptyMap() + } + return servers.map { server -> + buildServerRowUiModel( + server = server, + subscriptionRemarks = subscriptionRemarks[server.profile.subscriptionId].orEmpty() + ) + } } fun getSubscriptions(): List = dataSource.getSubscriptions() @@ -334,17 +373,18 @@ class MainViewModel( } val selectedGroup = resolveSelectedGroup(groups) val validIds = groups.mapTo(HashSet()) { it.id } - groupPageFlows.keys.removeAll { it !in validIds } + groupUiFlows.keys.removeAll { it !in validIds } + groupServerFlows.keys.removeAll { it !in validIds } groupLoadMutexes.keys.removeAll { it !in validIds } _uiState.update { it.copy( groups = groups, selectedGroupId = selectedGroup, - selectedGuid = dataSource.getSelectServer() + selectedGuid = dataSource.getSelectServer(), ) } - groups.forEach { mutableServersForGroup(it.id) } + groups.forEach { mutableServerGroupState(it.id) } if (groups.isEmpty()) { cacheMutex.withLock { groupDataCache.clear() } @@ -582,7 +622,7 @@ class MainViewModel( fun subscriptionIdChanged(id: String) { if (_uiState.value.groups.none { it.id == id }) return - mutableServersForGroup(id) + mutableServerGroupState(id) if (uiState.value.selectedGroupId != id) { dataSource.setSelectedSubscriptionId(id) _uiState.update { it.copy(selectedGroupId = id) } @@ -660,10 +700,13 @@ class MainViewModel( } fun moveServer(groupId: String, fromPosition: Int, toPosition: Int) { - val servers = mutableServersForGroup(groupId).value.toMutableList() + val groupState = mutableServerGroupState(groupId).value + val servers = groupState.servers.toMutableList() if (!servers.moveItem(fromPosition, toPosition)) return + val rows = groupState.rows.toMutableList() + rows.moveItem(fromPosition, toPosition) val guids = servers.map { it.guid } - mutableServersForGroup(groupId).value = servers + mutableServerGroupState(groupId).value = ServerGroupUiState(servers, rows) // A drag emits several moves; serialize writes so an older order cannot overwrite a newer one. val previousPersistenceJob = serverOrderPersistenceJobs[groupId] serverOrderPersistenceJobs[groupId] = viewModelScope.launch(ioDispatcher) { @@ -694,11 +737,17 @@ class MainViewModel( return } val serverGuids = servers.map { it.guid } - mutableServersForGroup(groupId).update { current -> - current.map { server -> - if (server.testDelayMillis == 0L) server - else server.copy(testDelayMillis = 0L) - } + mutableServerGroupState(groupId).update { current -> + current.copy( + servers = current.servers.map { server -> + if (server.testDelayMillis == 0L) server + else server.copy(testDelayMillis = 0L) + }, + rows = current.rows.map { row -> + if (row.testDelayMillis == 0L) row + else row.copy(testDelayMillis = 0L) + } + ) } testingGroupId = groupId _uiState.update {