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.
This commit is contained in:
eliotcougar
2026-08-29 15:58:33 +08:00
committed by GitHub
parent 26099c198e
commit e2dc37ba26
4 changed files with 211 additions and 176 deletions
@@ -226,7 +226,6 @@ fun MainScreen(
selectedGuid = selectedGuid,
locateTarget = uiState.locateTarget,
doubleColumnDisplay = doubleColumnDisplay,
confirmRemove = confirmRemove,
searchQuery = searchQuery,
lazyListStates = lazyListStates,
lazyGridStates = lazyGridStates,
@@ -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<String, LazyListState>,
lazyGridStates: MutableMap<String, LazyGridState>,
@@ -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<ServersCache>,
rows: List<ServerRowUiModel>,
selectedGuid: String?,
locateTarget: LocateTarget?,
canReorder: Boolean,
doubleColumnDisplay: Boolean,
subscriptionId: String,
confirmRemove: Boolean,
groupId: String,
lazyListStates: MutableMap<String, LazyListState>,
lazyGridStates: MutableMap<String, LazyGridState>,
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<ServersCache>,
rows: List<ServerRowUiModel>,
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<ServersCache>,
rows: List<ServerRowUiModel>,
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
@@ -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<ServersCache> = emptyList(),
val rows: List<ServerRowUiModel> = 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(" / ")
}
@@ -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<String, List<ServersCache>>()
private val groupPageFlows = ConcurrentHashMap<String, MutableStateFlow<List<ServersCache>>>()
private val groupUiFlows = ConcurrentHashMap<String, MutableStateFlow<ServerGroupUiState>>()
private val groupServerFlows = ConcurrentHashMap<String, StateFlow<List<ServersCache>>>()
private val groupLoadMutexes = ConcurrentHashMap<String, Mutex>()
private val serverOrderPersistenceJobs = mutableMapOf<String, Job>()
@@ -163,14 +167,25 @@ class MainViewModel(
// ---------- Public state accessors ----------
fun serversForGroup(groupId: String): StateFlow<List<ServersCache>> =
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<List<ServersCache>> =
groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) }
internal fun serverGroupState(groupId: String): StateFlow<ServerGroupUiState> =
mutableServerGroupState(groupId).asStateFlow()
private fun mutableServerGroupState(groupId: String): MutableStateFlow<ServerGroupUiState> =
groupUiFlows.computeIfAbsent(groupId) { MutableStateFlow(ServerGroupUiState()) }
private fun currentServers(): List<ServersCache> =
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<ServersCache>) {
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<ServersCache>): List<ServerRowUiModel> {
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<SubscriptionCache> = 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 {