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 authored and GitHub committed 2026-08-29 15:58:33 +08:00
1 parent 26099c198e
commit e2dc37ba26
4 files changed
+211 -176

No files matched your search

@@ -226,7 +226,6 @@ fun MainScreen(
selectedGuid = selectedGuid, selectedGuid = selectedGuid,
locateTarget = uiState.locateTarget, locateTarget = uiState.locateTarget,
doubleColumnDisplay = doubleColumnDisplay, doubleColumnDisplay = doubleColumnDisplay,
confirmRemove = confirmRemove,
searchQuery = searchQuery, searchQuery = searchQuery,
lazyListStates = lazyListStates, lazyListStates = lazyListStates,
lazyGridStates = lazyGridStates, lazyGridStates = lazyGridStates,
@@ -49,11 +49,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.LocateTarget import com.v2ray.ang.dto.LocateTarget
import com.v2ray.ang.dto.entities.ProfileItem 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.ItemDivider
import com.v2ray.ang.ui.compose.ReorderableGridItem import com.v2ray.ang.ui.compose.ReorderableGridItem
import com.v2ray.ang.ui.compose.ReorderableListItem import com.v2ray.ang.ui.compose.ReorderableListItem
@@ -73,7 +68,6 @@ fun GroupPagerPage(
selectedGuid: String?, selectedGuid: String?,
locateTarget: LocateTarget?, locateTarget: LocateTarget?,
doubleColumnDisplay: Boolean, doubleColumnDisplay: Boolean,
confirmRemove: Boolean,
searchQuery: String, searchQuery: String,
lazyListStates: MutableMap<String, LazyListState>, lazyListStates: MutableMap<String, LazyListState>,
lazyGridStates: MutableMap<String, LazyGridState>, lazyGridStates: MutableMap<String, LazyGridState>,
@@ -84,50 +78,63 @@ fun GroupPagerPage(
onRemoveServer: (String) -> Unit, onRemoveServer: (String) -> Unit,
contentPadding: PaddingValues contentPadding: PaddingValues
) { ) {
val serverFlow = remember(groupId) { val groupStateFlow = remember(groupId) {
mainViewModel.serversForGroup(groupId) mainViewModel.serverGroupState(groupId)
} }
val servers by serverFlow.collectAsStateWithLifecycle() val groupState by groupStateFlow.collectAsStateWithLifecycle()
val canReorder = groupId.isNotEmpty() && searchQuery.isEmpty() 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( ServerListPage(
servers = servers, rows = groupState.rows,
selectedGuid = selectedGuid, selectedGuid = selectedGuid,
locateTarget = locateTarget?.takeIf { it.groupId == groupId }, locateTarget = locateTarget?.takeIf { it.groupId == groupId },
canReorder = canReorder, canReorder = canReorder,
doubleColumnDisplay = doubleColumnDisplay, doubleColumnDisplay = doubleColumnDisplay,
subscriptionId = groupId,
confirmRemove = confirmRemove,
groupId = groupId, groupId = groupId,
lazyListStates = lazyListStates, lazyListStates = lazyListStates,
lazyGridStates = lazyGridStates, lazyGridStates = lazyGridStates,
onSelectServer = onSelectServer, actions = actions,
onEditServer = onEditServer,
onShareServer = onShareServer,
onMoreServer = onMoreServer,
onRemoveServer = onRemoveServer,
onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) }, onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) },
onMoveServer = { fromIndex, toIndex -> mainViewModel.moveServer(groupId, fromIndex, toIndex) }, onMoveServer = { fromIndex, toIndex ->
mainViewModel.moveServer(groupId, fromIndex, toIndex)
},
contentPadding = contentPadding 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 @Composable
private fun ServerListPage( private fun ServerListPage(
servers: List<ServersCache>, rows: List<ServerRowUiModel>,
selectedGuid: String?, selectedGuid: String?,
locateTarget: LocateTarget?, locateTarget: LocateTarget?,
canReorder: Boolean, canReorder: Boolean,
doubleColumnDisplay: Boolean, doubleColumnDisplay: Boolean,
subscriptionId: String,
confirmRemove: Boolean,
groupId: String, groupId: String,
lazyListStates: MutableMap<String, LazyListState>, lazyListStates: MutableMap<String, LazyListState>,
lazyGridStates: MutableMap<String, LazyGridState>, lazyGridStates: MutableMap<String, LazyGridState>,
onSelectServer: (String) -> Unit, actions: ServerRowActions,
onEditServer: (String, ProfileItem) -> Unit,
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String) -> Unit,
onLocateHandled: () -> Unit, onLocateHandled: () -> Unit,
onMoveServer: (Int, Int) -> Unit, onMoveServer: (Int, Int) -> Unit,
contentPadding: PaddingValues contentPadding: PaddingValues
@@ -142,7 +149,7 @@ private fun ServerListPage(
} }
} else null } else null
LocateTargetEffect(locateTarget, servers, gridState, onLocateHandled) LocateTargetEffect(locateTarget, rows, gridState, onLocateHandled)
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
@@ -152,24 +159,19 @@ private fun ServerListPage(
.verticalScrollbar(gridState), .verticalScrollbar(gridState),
contentPadding = contentPadding contentPadding = contentPadding
) { ) {
itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache -> itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row ->
val content: @Composable () -> Unit = { val content: @Composable () -> Unit = {
ServerItemColumn( ServerItemColumn(
serverCache = serverCache, row = row,
selectedGuid = selectedGuid, isSelected = row.guid == selectedGuid,
subscriptionId = subscriptionId,
doubleColumnDisplay = true, doubleColumnDisplay = true,
onSelectServer = onSelectServer, actions = actions
onEditServer = onEditServer,
onShareServer = onShareServer,
onMoreServer = onMoreServer,
onRemoveServer = onRemoveServer
) )
} }
if (canReorder && reorderableGridState != null) { if (canReorder && reorderableGridState != null) {
ReorderableItem( ReorderableItem(
reorderableGridState, reorderableGridState,
key = serverCache.guid key = row.guid
) { isDragging -> ) { isDragging ->
ReorderableGridItem( ReorderableGridItem(
scope = this, scope = this,
@@ -191,7 +193,7 @@ private fun ServerListPage(
} }
} else null } else null
LocateTargetEffect(locateTarget, servers, listState, onLocateHandled) LocateTargetEffect(locateTarget, rows, listState, onLocateHandled)
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -200,39 +202,29 @@ private fun ServerListPage(
.verticalScrollbar(listState), .verticalScrollbar(listState),
contentPadding = contentPadding contentPadding = contentPadding
) { ) {
itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache -> itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row ->
if (canReorder && reorderableState != null) { if (canReorder && reorderableState != null) {
ReorderableItem( ReorderableItem(
reorderableState, reorderableState,
key = serverCache.guid key = row.guid
) { isDragging -> ) { isDragging ->
ReorderableListItem( ReorderableListItem(
scope = this, scope = this,
isDragging = isDragging isDragging = isDragging
) { ) {
ServerItemRow( ServerItemRow(
serverCache = serverCache, row = row,
selectedGuid = selectedGuid, isSelected = row.guid == selectedGuid,
subscriptionId = subscriptionId, actions = actions
onSelectServer = onSelectServer,
onEditServer = onEditServer,
onShareServer = onShareServer,
onMoreServer = onMoreServer,
onRemoveServer = onRemoveServer
) )
} }
ItemDivider() ItemDivider()
} }
} else { } else {
ServerItemRow( ServerItemRow(
serverCache = serverCache, row = row,
selectedGuid = selectedGuid, isSelected = row.guid == selectedGuid,
subscriptionId = subscriptionId, actions = actions
onSelectServer = onSelectServer,
onEditServer = onEditServer,
onShareServer = onShareServer,
onMoreServer = onMoreServer,
onRemoveServer = onRemoveServer
) )
ItemDivider() ItemDivider()
} }
@@ -244,13 +236,13 @@ private fun ServerListPage(
@Composable @Composable
private fun LocateTargetEffect( private fun LocateTargetEffect(
target: LocateTarget?, target: LocateTarget?,
servers: List<ServersCache>, rows: List<ServerRowUiModel>,
state: LazyListState, state: LazyListState,
onHandled: () -> Unit, onHandled: () -> Unit,
) { ) {
if (target == null) return if (target == null) return
LaunchedEffect(target, servers) { LaunchedEffect(target, rows) {
val index = servers.indexOfFirst { it.guid == target.serverGuid } val index = rows.indexOfFirst { it.guid == target.serverGuid }
if (index < 0) return@LaunchedEffect if (index < 0) return@LaunchedEffect
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3) state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
onHandled() onHandled()
@@ -260,13 +252,13 @@ private fun LocateTargetEffect(
@Composable @Composable
private fun LocateTargetEffect( private fun LocateTargetEffect(
target: LocateTarget?, target: LocateTarget?,
servers: List<ServersCache>, rows: List<ServerRowUiModel>,
state: LazyGridState, state: LazyGridState,
onHandled: () -> Unit, onHandled: () -> Unit,
) { ) {
if (target == null) return if (target == null) return
LaunchedEffect(target, servers) { LaunchedEffect(target, rows) {
val index = servers.indexOfFirst { it.guid == target.serverGuid } val index = rows.indexOfFirst { it.guid == target.serverGuid }
if (index < 0) return@LaunchedEffect if (index < 0) return@LaunchedEffect
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3) state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
onHandled() onHandled()
@@ -275,94 +267,47 @@ private fun LocateTargetEffect(
@Composable @Composable
private fun ServerItemRow( private fun ServerItemRow(
serverCache: ServersCache, row: ServerRowUiModel,
selectedGuid: String?, isSelected: Boolean,
subscriptionId: String, actions: ServerRowActions
onSelectServer: (String) -> Unit,
onEditServer: (String, ProfileItem) -> Unit,
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String) -> Unit
) { ) {
val profile = serverCache.profile
val subRemarks = if (subscriptionId.isEmpty()) {
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
?.toString() ?: ""
} else ""
ServerListItem( ServerListItem(
remarks = profile.remarks, row = row,
statistics = profile.description.nullIfBlank() isSelected = isSelected,
?: AngConfigManager.generateDescription(profile),
typeDescription = getProtocolDescription(profile),
testDelayMillis = serverCache.testDelayMillis,
isSelected = serverCache.guid == selectedGuid,
subscriptionRemarks = subRemarks,
doubleColumnDisplay = false, doubleColumnDisplay = false,
onClick = { onSelectServer(serverCache.guid) }, actions = actions
onShare = { onShareServer(serverCache.guid, profile) },
onEdit = { onEditServer(serverCache.guid, profile) },
onRemove = { onRemoveServer(serverCache.guid) },
onMore = { onMoreServer(serverCache.guid, profile) }
) )
} }
@Composable @Composable
private fun ServerItemColumn( private fun ServerItemColumn(
serverCache: ServersCache, row: ServerRowUiModel,
selectedGuid: String?, isSelected: Boolean,
subscriptionId: String,
doubleColumnDisplay: Boolean, doubleColumnDisplay: Boolean,
onSelectServer: (String) -> Unit, actions: ServerRowActions
onEditServer: (String, ProfileItem) -> Unit,
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String) -> Unit
) { ) {
val profile = serverCache.profile
val subRemarks = if (subscriptionId.isEmpty()) {
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()?.toString() ?: ""
} else ""
Column { Column {
ServerListItem( ServerListItem(
remarks = profile.remarks, row = row,
statistics = profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile), isSelected = isSelected,
typeDescription = getProtocolDescription(profile),
testDelayMillis = serverCache.testDelayMillis,
isSelected = serverCache.guid == selectedGuid,
subscriptionRemarks = subRemarks,
doubleColumnDisplay = doubleColumnDisplay, doubleColumnDisplay = doubleColumnDisplay,
onClick = { onSelectServer(serverCache.guid) }, actions = actions
onEdit = { onEditServer(serverCache.guid, profile) },
onShare = { onShareServer(serverCache.guid, profile) },
onRemove = { onRemoveServer(serverCache.guid) },
onMore = { onMoreServer(serverCache.guid, profile) }
) )
ItemDivider() ItemDivider()
} }
} }
@Composable @Composable
fun ServerListItem( private fun ServerListItem(
remarks: String, row: ServerRowUiModel,
statistics: String,
typeDescription: String,
testDelayMillis: Long,
isSelected: Boolean, isSelected: Boolean,
subscriptionRemarks: String,
doubleColumnDisplay: Boolean, doubleColumnDisplay: Boolean,
onClick: () -> Unit, actions: ServerRowActions
onEdit: () -> Unit,
onShare: () -> Unit,
onRemove: () -> Unit,
onMore: () -> Unit,
modifier: Modifier = Modifier,
dragModifier: Modifier = Modifier
) { ) {
val testResult = if (testDelayMillis == 0L) { val testResult = if (row.testDelayMillis == 0L) {
"" ""
} else { } else {
stringResource(R.string.server_test_delay_value, testDelayMillis) stringResource(R.string.server_test_delay_value, row.testDelayMillis)
} }
val selectedStateDescription = if (isSelected) { val selectedStateDescription = if (isSelected) {
stringResource(R.string.acc_selected_server) stringResource(R.string.acc_selected_server)
@@ -370,7 +315,7 @@ fun ServerListItem(
null null
} }
Row( Row(
modifier = modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.semantics { .semantics {
@@ -378,8 +323,7 @@ fun ServerListItem(
stateDescription = selectedStateDescription stateDescription = selectedStateDescription
} }
} }
.clickable(onClick = onClick) .clickable { actions.select(row.guid) }
.then(dragModifier)
) { ) {
Box( Box(
Modifier Modifier
@@ -406,9 +350,9 @@ fun ServerListItem(
.padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp) .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
) { ) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { 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) { if (doubleColumnDisplay) {
IconButton(onClick = onMore, Modifier.size(36.dp)) { IconButton(onClick = { actions.more(row.guid, row.profile) }, Modifier.size(36.dp)) {
Icon( Icon(
painterResource(R.drawable.ic_more_vert_24dp), painterResource(R.drawable.ic_more_vert_24dp),
stringResource(R.string.acc_more), stringResource(R.string.acc_more),
@@ -416,21 +360,21 @@ fun ServerListItem(
) )
} }
} else { } else {
IconButton(onClick = onShare, Modifier.size(36.dp)) { IconButton(onClick = { actions.share(row.guid, row.profile) }, Modifier.size(36.dp)) {
Icon( Icon(
painterResource(R.drawable.ic_share_24dp), painterResource(R.drawable.ic_share_24dp),
stringResource(R.string.title_configuration_share), stringResource(R.string.title_configuration_share),
Modifier.size(24.dp) Modifier.size(24.dp)
) )
} }
IconButton(onClick = onEdit, Modifier.size(36.dp)) { IconButton(onClick = { actions.edit(row.guid, row.profile) }, Modifier.size(36.dp)) {
Icon( Icon(
painterResource(R.drawable.ic_edit_24dp), painterResource(R.drawable.ic_edit_24dp),
stringResource(R.string.acc_edit), stringResource(R.string.acc_edit),
Modifier.size(24.dp) Modifier.size(24.dp)
) )
} }
IconButton(onClick = onRemove, Modifier.size(36.dp)) { IconButton(onClick = { actions.remove(row.guid) }, Modifier.size(36.dp)) {
Icon( Icon(
painterResource(R.drawable.ic_delete_24dp), painterResource(R.drawable.ic_delete_24dp),
stringResource(R.string.acc_delete), stringResource(R.string.acc_delete),
@@ -441,45 +385,27 @@ fun ServerListItem(
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (subscriptionRemarks.isNotBlank()) { if (row.subscriptionBadge.isNotBlank()) {
Box( Box(
Modifier Modifier
.size(24.dp) .size(24.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), Alignment.Center .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)) Spacer(modifier = Modifier.height(6.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, 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 (testDelayMillis < 0L) colorPingRed else colorPing, 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( internal suspend fun PagerState.navigateToPageOptimized(
targetPage: Int, targetPage: Int,
animateAdjacentPage: Boolean = true 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 com.v2ray.ang.extension.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -65,7 +68,8 @@ class MainViewModel(
// ---------- Groups & cache ---------- // ---------- Groups & cache ----------
private val cacheMutex = Mutex() private val cacheMutex = Mutex()
private val groupDataCache = mutableMapOf<String, List<ServersCache>>() 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 groupLoadMutexes = ConcurrentHashMap<String, Mutex>()
private val serverOrderPersistenceJobs = mutableMapOf<String, Job>() private val serverOrderPersistenceJobs = mutableMapOf<String, Job>()
@@ -163,14 +167,25 @@ class MainViewModel(
// ---------- Public state accessors ---------- // ---------- Public state accessors ----------
fun serversForGroup(groupId: String): StateFlow<List<ServersCache>> = fun serversForGroup(groupId: String): StateFlow<List<ServersCache>> =
groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) } groupServerFlows.computeIfAbsent(groupId) {
.asStateFlow() 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>> = internal fun serverGroupState(groupId: String): StateFlow<ServerGroupUiState> =
groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) } mutableServerGroupState(groupId).asStateFlow()
private fun mutableServerGroupState(groupId: String): MutableStateFlow<ServerGroupUiState> =
groupUiFlows.computeIfAbsent(groupId) { MutableStateFlow(ServerGroupUiState()) }
private fun currentServers(): List<ServersCache> = private fun currentServers(): List<ServersCache> =
mutableServersForGroup(uiState.value.selectedGroupId).value mutableServerGroupState(uiState.value.selectedGroupId).value.servers
// ---------- Action handler ---------- // ---------- Action handler ----------
fun onAction(action: MainAction) { fun onAction(action: MainAction) {
@@ -289,7 +304,31 @@ class MainViewModel(
} }
private fun updateGroupUi(groupId: String, servers: List<ServersCache>) { 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() fun getSubscriptions(): List<SubscriptionCache> = dataSource.getSubscriptions()
@@ -334,17 +373,18 @@ class MainViewModel(
} }
val selectedGroup = resolveSelectedGroup(groups) val selectedGroup = resolveSelectedGroup(groups)
val validIds = groups.mapTo(HashSet()) { it.id } 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 } groupLoadMutexes.keys.removeAll { it !in validIds }
_uiState.update { _uiState.update {
it.copy( it.copy(
groups = groups, groups = groups,
selectedGroupId = selectedGroup, selectedGroupId = selectedGroup,
selectedGuid = dataSource.getSelectServer() selectedGuid = dataSource.getSelectServer(),
) )
} }
groups.forEach { mutableServersForGroup(it.id) } groups.forEach { mutableServerGroupState(it.id) }
if (groups.isEmpty()) { if (groups.isEmpty()) {
cacheMutex.withLock { groupDataCache.clear() } cacheMutex.withLock { groupDataCache.clear() }
@@ -582,7 +622,7 @@ class MainViewModel(
fun subscriptionIdChanged(id: String) { fun subscriptionIdChanged(id: String) {
if (_uiState.value.groups.none { it.id == id }) return if (_uiState.value.groups.none { it.id == id }) return
mutableServersForGroup(id) mutableServerGroupState(id)
if (uiState.value.selectedGroupId != id) { if (uiState.value.selectedGroupId != id) {
dataSource.setSelectedSubscriptionId(id) dataSource.setSelectedSubscriptionId(id)
_uiState.update { it.copy(selectedGroupId = id) } _uiState.update { it.copy(selectedGroupId = id) }
@@ -660,10 +700,13 @@ class MainViewModel(
} }
fun moveServer(groupId: String, fromPosition: Int, toPosition: Int) { 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 if (!servers.moveItem(fromPosition, toPosition)) return
val rows = groupState.rows.toMutableList()
rows.moveItem(fromPosition, toPosition)
val guids = servers.map { it.guid } 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. // A drag emits several moves; serialize writes so an older order cannot overwrite a newer one.
val previousPersistenceJob = serverOrderPersistenceJobs[groupId] val previousPersistenceJob = serverOrderPersistenceJobs[groupId]
serverOrderPersistenceJobs[groupId] = viewModelScope.launch(ioDispatcher) { serverOrderPersistenceJobs[groupId] = viewModelScope.launch(ioDispatcher) {
@@ -694,11 +737,17 @@ class MainViewModel(
return return
} }
val serverGuids = servers.map { it.guid } val serverGuids = servers.map { it.guid }
mutableServersForGroup(groupId).update { current -> mutableServerGroupState(groupId).update { current ->
current.map { server -> current.copy(
if (server.testDelayMillis == 0L) server servers = current.servers.map { server ->
else server.copy(testDelayMillis = 0L) 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 testingGroupId = groupId
_uiState.update { _uiState.update {