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 {