mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 06:31:54 +00:00
Consistent reorderable list behavior and persistence (#5968)
* Fix reordered list behavior and persistence Use remove-and-insert semantics for every reorderable collection and persist the resulting order instead of swapping endpoints. Resolve routing rules and proxy-chain members by stable keys, make proxy rows draggable, and serialize profile-order writes per group so rapid drag updates cannot be persisted out of order. * Signal touch reorder activation with haptic feedback Emit one gesture-threshold pulse when a long press makes an item draggable. Reuse the same handle across every reorderable list and rely on the platform touch-feedback setting.
This commit is contained in:
1 parent
794940a1a0
commit
4fee3a67f7
12 files changed
+117
-71
No files matched your search
@@ -46,7 +46,9 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
@@ -278,6 +280,17 @@ private fun reorderableElevation(isDragging: Boolean) = animateDpAsState(
|
|||||||
label = "ReorderableElevation"
|
label = "ReorderableElevation"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReorderableCollectionItemScope.reorderableDragHandle(): Modifier {
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
return Modifier.longPressDraggableHandle(
|
||||||
|
onDragStarted = {
|
||||||
|
// Platform haptics honor the user's touch-feedback setting.
|
||||||
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReorderableListItem(
|
fun ReorderableListItem(
|
||||||
scope: ReorderableCollectionItemScope,
|
scope: ReorderableCollectionItemScope,
|
||||||
@@ -292,7 +305,7 @@ fun ReorderableListItem(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(with(scope) { Modifier.longPressDraggableHandle() }),
|
.then(with(scope) { reorderableDragHandle() }),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
@@ -309,7 +322,7 @@ fun ReorderableGridItem(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(with(scope) { Modifier.longPressDraggableHandle() }),
|
.then(with(scope) { reorderableDragHandle() }),
|
||||||
shadowElevation = elevation
|
shadowElevation = elevation
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.v2ray.ang.extension
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves an item to another index while preserving the relative order of the remaining items.
|
||||||
|
*/
|
||||||
|
internal fun <T> MutableList<T>.moveItem(fromIndex: Int, toIndex: Int): Boolean {
|
||||||
|
if (fromIndex !in indices || toIndex !in indices || fromIndex == toIndex) return false
|
||||||
|
add(toIndex, removeAt(fromIndex))
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import com.v2ray.ang.enums.EConfigType
|
|||||||
import com.v2ray.ang.enums.Language
|
import com.v2ray.ang.enums.Language
|
||||||
import com.v2ray.ang.enums.RoutingType
|
import com.v2ray.ang.enums.RoutingType
|
||||||
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
||||||
|
import com.v2ray.ang.extension.moveItem
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
|
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
|
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
|
||||||
@@ -30,7 +31,6 @@ import com.v2ray.ang.util.LogUtil
|
|||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.util.Collections
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@@ -204,32 +204,6 @@ object SettingsManager {
|
|||||||
return exist == true
|
return exist == true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Swap routing rulesets.
|
|
||||||
* @param fromPosition The position to swap from.
|
|
||||||
* @param toPosition The position to swap to.
|
|
||||||
*/
|
|
||||||
fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) {
|
|
||||||
val rulesetList = MmkvManager.decodeRoutingRulesets()
|
|
||||||
if (rulesetList.isNullOrEmpty()) return
|
|
||||||
|
|
||||||
Collections.swap(rulesetList, fromPosition, toPosition)
|
|
||||||
MmkvManager.encodeRoutingRulesets(rulesetList)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Swap subscriptions.
|
|
||||||
* @param fromPosition The position to swap from.
|
|
||||||
* @param toPosition The position to swap to.
|
|
||||||
*/
|
|
||||||
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
|
|
||||||
val subsList = decodeSubsList()
|
|
||||||
if (subsList.isEmpty()) return
|
|
||||||
|
|
||||||
Collections.swap(subsList, fromPosition, toPosition)
|
|
||||||
MmkvManager.encodeSubsList(subsList)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get server via remarks.
|
* Get server via remarks.
|
||||||
* @param remarks The remarks of the server.
|
* @param remarks The remarks of the server.
|
||||||
@@ -623,10 +597,10 @@ object SettingsManager {
|
|||||||
)
|
)
|
||||||
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||||
|
|
||||||
// Move top
|
// Move to the top
|
||||||
val subsList = decodeSubsList()
|
val subsList = decodeSubsList()
|
||||||
if (subsList.count() > 1) {
|
if (subsList.moveItem(subsList.lastIndex, 0)) {
|
||||||
swapSubscriptions(0, subsList.count() - 1)
|
MmkvManager.encodeSubsList(subsList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ sealed interface MainAction {
|
|||||||
data class RemoveServer(val guid: String) : MainAction
|
data class RemoveServer(val guid: String) : MainAction
|
||||||
data class EditServer(val guid: String, val profile: com.v2ray.ang.dto.entities.ProfileItem) : MainAction
|
data class EditServer(val guid: String, val profile: com.v2ray.ang.dto.entities.ProfileItem) : MainAction
|
||||||
data class Search(val query: String) : MainAction
|
data class Search(val query: String) : MainAction
|
||||||
data class SwapServer(val fromIndex: Int, val toIndex: Int) : MainAction
|
|
||||||
|
|
||||||
data class ShareQRCode(val guid: String) : MainAction
|
data class ShareQRCode(val guid: String) : MainAction
|
||||||
data class ShareClipboard(val guid: String) : MainAction
|
data class ShareClipboard(val guid: String) : MainAction
|
||||||
data class ShareFullContent(val guid: String) : MainAction
|
data class ShareFullContent(val guid: String) : MainAction
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ fun GroupPagerPage(
|
|||||||
onShareServer = onShareServer,
|
onShareServer = onShareServer,
|
||||||
onMoreServer = onMoreServer,
|
onMoreServer = onMoreServer,
|
||||||
onRemoveServer = onRemoveServer,
|
onRemoveServer = onRemoveServer,
|
||||||
onSwapServer = mainViewModel::swapServer,
|
onMoveServer = { fromIndex, toIndex -> mainViewModel.moveServer(groupId, fromIndex, toIndex) },
|
||||||
contentPadding = contentPadding
|
contentPadding = contentPadding
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ private fun ServerListPage(
|
|||||||
onShareServer: (String, ProfileItem) -> Unit,
|
onShareServer: (String, ProfileItem) -> Unit,
|
||||||
onMoreServer: (String, ProfileItem) -> Unit,
|
onMoreServer: (String, ProfileItem) -> Unit,
|
||||||
onRemoveServer: (String) -> Unit,
|
onRemoveServer: (String) -> Unit,
|
||||||
onSwapServer: (Int, Int) -> Unit,
|
onMoveServer: (Int, Int) -> Unit,
|
||||||
contentPadding: PaddingValues
|
contentPadding: PaddingValues
|
||||||
) {
|
) {
|
||||||
if (doubleColumnDisplay) {
|
if (doubleColumnDisplay) {
|
||||||
@@ -128,7 +128,7 @@ private fun ServerListPage(
|
|||||||
}
|
}
|
||||||
val reorderableGridState = if (canReorder) {
|
val reorderableGridState = if (canReorder) {
|
||||||
rememberReorderableLazyGridState(gridState) { from, to ->
|
rememberReorderableLazyGridState(gridState) { from, to ->
|
||||||
onSwapServer(from.index, to.index)
|
onMoveServer(from.index, to.index)
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ private fun ServerListPage(
|
|||||||
}
|
}
|
||||||
val reorderableState = if (canReorder) {
|
val reorderableState = if (canReorder) {
|
||||||
rememberReorderableLazyListState(listState) { from, to ->
|
rememberReorderableLazyListState(listState) { from, to ->
|
||||||
onSwapServer(from.index, to.index)
|
onMoveServer(from.index, to.index)
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.v2ray.ang.dto.entities.ServersCache
|
|||||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||||
import com.v2ray.ang.extension.isComplexType
|
import com.v2ray.ang.extension.isComplexType
|
||||||
import com.v2ray.ang.extension.matchesPattern
|
import com.v2ray.ang.extension.matchesPattern
|
||||||
|
import com.v2ray.ang.extension.moveItem
|
||||||
import com.v2ray.ang.ui.base.BaseViewModel
|
import com.v2ray.ang.ui.base.BaseViewModel
|
||||||
import com.v2ray.ang.util.LogUtil
|
import com.v2ray.ang.util.LogUtil
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -32,7 +33,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.Collections
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.regex.PatternSyntaxException
|
import java.util.regex.PatternSyntaxException
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ class MainViewModel(
|
|||||||
private val groupDataCache = mutableMapOf<String, List<ServersCache>>()
|
private val groupDataCache = mutableMapOf<String, List<ServersCache>>()
|
||||||
private val groupPageFlows = ConcurrentHashMap<String, MutableStateFlow<List<ServersCache>>>()
|
private val groupPageFlows = ConcurrentHashMap<String, MutableStateFlow<List<ServersCache>>>()
|
||||||
private val groupLoadMutexes = ConcurrentHashMap<String, Mutex>()
|
private val groupLoadMutexes = ConcurrentHashMap<String, Mutex>()
|
||||||
|
private val serverOrderPersistenceJobs = mutableMapOf<String, Job>()
|
||||||
|
|
||||||
private var setupGroupJob: Job? = null
|
private var setupGroupJob: Job? = null
|
||||||
private var preloadJob: Job? = null
|
private var preloadJob: Job? = null
|
||||||
@@ -175,7 +176,6 @@ class MainViewModel(
|
|||||||
is MainAction.SelectServer -> updateSelectedGuid(action.guid)
|
is MainAction.SelectServer -> updateSelectedGuid(action.guid)
|
||||||
is MainAction.RemoveServer -> removeServerAndRefresh(action.guid)
|
is MainAction.RemoveServer -> removeServerAndRefresh(action.guid)
|
||||||
is MainAction.Search -> filterConfig(action.query)
|
is MainAction.Search -> filterConfig(action.query)
|
||||||
is MainAction.SwapServer -> swapServer(action.fromIndex, action.toIndex)
|
|
||||||
is MainAction.ImportBatchConfig -> importBatchConfig(action.configText)
|
is MainAction.ImportBatchConfig -> importBatchConfig(action.configText)
|
||||||
is MainAction.LocateHandled -> consumeLocateTarget(action.target)
|
is MainAction.LocateHandled -> consumeLocateTarget(action.target)
|
||||||
is MainAction.ShareQRCode -> {
|
is MainAction.ShareQRCode -> {
|
||||||
@@ -646,15 +646,15 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun swapServer(fromPosition: Int, toPosition: Int) {
|
fun moveServer(groupId: String, fromPosition: Int, toPosition: Int) {
|
||||||
val groupId = uiState.value.selectedGroupId
|
val servers = mutableServersForGroup(groupId).value.toMutableList()
|
||||||
if (groupId.isEmpty()) return
|
if (!servers.moveItem(fromPosition, toPosition)) return
|
||||||
val servers = currentServers().toMutableList()
|
val guids = servers.map { it.guid }
|
||||||
if (fromPosition !in servers.indices || toPosition !in servers.indices) return
|
|
||||||
Collections.swap(servers, fromPosition, toPosition)
|
|
||||||
val guids = servers.mapTo(ArrayList(servers.size)) { it.guid }
|
|
||||||
mutableServersForGroup(groupId).value = servers
|
mutableServersForGroup(groupId).value = servers
|
||||||
viewModelScope.launch(ioDispatcher) {
|
// 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) {
|
||||||
|
previousPersistenceJob?.join()
|
||||||
dataSource.encodeServerList(guids, groupId)
|
dataSource.encodeServerList(guids, groupId)
|
||||||
cacheMutex.withLock { groupDataCache[groupId] = servers }
|
cacheMutex.withLock { groupDataCache[groupId] = servers }
|
||||||
}
|
}
|
||||||
@@ -780,4 +780,4 @@ class MainViewModel(
|
|||||||
throw IllegalArgumentException("Unknown ViewModel class")
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,10 @@ fun RoutingSettingScreen(
|
|||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
||||||
viewModel.swap(from.index - 1, to.index - 1)
|
// Lazy list indices include the preceding non-rule content, so resolve the stable rule keys.
|
||||||
|
val fromIndex = rulesets.indexOfFirst { it.id == from.key }
|
||||||
|
val toIndex = rulesets.indexOfFirst { it.id == to.key }
|
||||||
|
viewModel.move(fromIndex, toIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.v2ray.ang.ui.routing
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.v2ray.ang.dto.entities.RulesetItem
|
import com.v2ray.ang.dto.entities.RulesetItem
|
||||||
|
import com.v2ray.ang.extension.moveItem
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.ui.base.BaseViewModel
|
import com.v2ray.ang.ui.base.BaseViewModel
|
||||||
@@ -41,12 +42,10 @@ class RoutingSettingsViewModel(application: Application) : BaseViewModel(applica
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun swap(fromPosition: Int, toPosition: Int) {
|
fun move(fromPosition: Int, toPosition: Int) {
|
||||||
if (fromPosition in rulesets.indices && toPosition in rulesets.indices) {
|
if (rulesets.moveItem(fromPosition, toPosition)) {
|
||||||
SettingsManager.swapRoutingRuleset(fromPosition, toPosition)
|
MmkvManager.encodeRoutingRulesets(rulesets)
|
||||||
val item = rulesets.removeAt(fromPosition)
|
|
||||||
rulesets.add(toPosition, item)
|
|
||||||
_rulesetsFlow.value = rulesets.toList()
|
_rulesetsFlow.value = rulesets.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,15 +39,18 @@ import com.v2ray.ang.compose.AppTopBar
|
|||||||
import com.v2ray.ang.compose.ConfirmDialog
|
import com.v2ray.ang.compose.ConfirmDialog
|
||||||
import com.v2ray.ang.compose.FormDropdownField
|
import com.v2ray.ang.compose.FormDropdownField
|
||||||
import com.v2ray.ang.compose.FormTextField
|
import com.v2ray.ang.compose.FormTextField
|
||||||
|
import com.v2ray.ang.compose.reorderableDragHandle
|
||||||
import com.v2ray.ang.compose.verticalScrollbar
|
import com.v2ray.ang.compose.verticalScrollbar
|
||||||
import com.v2ray.ang.dto.entities.ProfileItem
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.isComplexType
|
import com.v2ray.ang.extension.isComplexType
|
||||||
|
import com.v2ray.ang.extension.moveItem
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||||
|
import java.util.UUID
|
||||||
import sh.calvin.reorderable.ReorderableItem
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
|
||||||
@@ -195,16 +198,22 @@ fun ProxyChainScreen(
|
|||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
|
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
|
||||||
var members by rememberSaveable { mutableStateOf(initialMembers.toMutableList()) }
|
var members by rememberSaveable { mutableStateOf(initialMembers.toList()) }
|
||||||
|
// Member remarks may be blank or duplicated, so they cannot serve as stable Compose keys.
|
||||||
|
var memberKeys by rememberSaveable { mutableStateOf(List(initialMembers.size) { UUID.randomUUID().toString() }) }
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
val showDelete = editGuid.isNotEmpty() && !isRunning
|
val showDelete = editGuid.isNotEmpty() && !isRunning
|
||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
||||||
val fromIdx = from.index - 1
|
val fromIndex = memberKeys.indexOf(from.key)
|
||||||
val toIdx = to.index - 1
|
val toIndex = memberKeys.indexOf(to.key)
|
||||||
if (fromIdx in members.indices && toIdx in members.indices) {
|
val reordered = members.toMutableList()
|
||||||
members = members.toMutableList().apply { add(toIdx, removeAt(fromIdx)) }
|
val reorderedKeys = memberKeys.toMutableList()
|
||||||
|
if (reordered.moveItem(fromIndex, toIndex)) {
|
||||||
|
reorderedKeys.moveItem(fromIndex, toIndex)
|
||||||
|
members = reordered
|
||||||
|
memberKeys = reorderedKeys
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +237,10 @@ fun ProxyChainScreen(
|
|||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { members = members.toMutableList().also { it.add("") } },
|
onClick = {
|
||||||
|
members = members + ""
|
||||||
|
memberKeys = memberKeys + UUID.randomUUID().toString()
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(y = -20.dp)
|
.offset(y = -20.dp)
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
@@ -268,13 +280,14 @@ fun ProxyChainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsIndexed(items = members, key = { idx, _ -> "member_$idx" }) { index, member ->
|
itemsIndexed(items = members, key = { index, _ -> memberKeys[index] }) { index, member ->
|
||||||
ReorderableItem(reorderableState, key = "member_$index") { isDragging ->
|
ReorderableItem(reorderableState, key = memberKeys[index]) { isDragging ->
|
||||||
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
|
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
|
||||||
Surface(shadowElevation = elevation) {
|
Surface(shadowElevation = elevation) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.then(with(this) { reorderableDragHandle() })
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -297,6 +310,7 @@ fun ProxyChainScreen(
|
|||||||
)
|
)
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
members = members.toMutableList().also { it.removeAt(index) }
|
members = members.toMutableList().also { it.removeAt(index) }
|
||||||
|
memberKeys = memberKeys.toMutableList().also { it.removeAt(index) }
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(R.drawable.ic_delete_24dp),
|
painterResource(R.drawable.ic_delete_24dp),
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ fun SubSettingScreen(
|
|||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
||||||
viewModel.swap(from.index, to.index)
|
viewModel.move(from.index, to.index)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.v2ray.ang.R
|
|||||||
import com.v2ray.ang.dto.SubscriptionUpdateMessage
|
import com.v2ray.ang.dto.SubscriptionUpdateMessage
|
||||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||||
|
import com.v2ray.ang.extension.moveItem
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
@@ -49,11 +50,9 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
|
|||||||
_subsFlow.value = subscriptions.toList()
|
_subsFlow.value = subscriptions.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun swap(fromPosition: Int, toPosition: Int) {
|
fun move(fromPosition: Int, toPosition: Int) {
|
||||||
if (fromPosition in subscriptions.indices && toPosition in subscriptions.indices) {
|
if (subscriptions.moveItem(fromPosition, toPosition)) {
|
||||||
val item = subscriptions.removeAt(fromPosition)
|
MmkvManager.encodeSubsList(subscriptions.mapTo(mutableListOf()) { it.guid })
|
||||||
subscriptions.add(toPosition, item)
|
|
||||||
SettingsManager.swapSubscriptions(fromPosition, toPosition)
|
|
||||||
SettingsChangeManager.makeSetupGroupTab()
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
_subsFlow.value = subscriptions.toList()
|
_subsFlow.value = subscriptions.toList()
|
||||||
}
|
}
|
||||||
@@ -71,4 +70,4 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
|
|||||||
|
|
||||||
toast(R.string.subscription_updater_job_tips)
|
toast(R.string.subscription_updater_job_tips)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.v2ray.ang.extension
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ListExtTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveItem_down_preservesRelativeOrder() {
|
||||||
|
val items = mutableListOf("a", "b", "c", "d")
|
||||||
|
|
||||||
|
assertTrue(items.moveItem(0, 2))
|
||||||
|
|
||||||
|
assertEquals(listOf("b", "c", "a", "d"), items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveItem_up_preservesRelativeOrder() {
|
||||||
|
val items = mutableListOf("a", "b", "c", "d")
|
||||||
|
|
||||||
|
assertTrue(items.moveItem(3, 1))
|
||||||
|
|
||||||
|
assertEquals(listOf("a", "d", "b", "c"), items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveItem_rejectsNoOpAndInvalidIndex() {
|
||||||
|
val items = mutableListOf("a", "b", "c")
|
||||||
|
|
||||||
|
assertFalse(items.moveItem(1, 1))
|
||||||
|
assertFalse(items.moveItem(-1, 1))
|
||||||
|
assertEquals(listOf("a", "b", "c"), items)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user