From 509d93273fb07c3c66dce4a6eee723f172f0294e Mon Sep 17 00:00:00 2001 From: Goooler Date: Mon, 20 Apr 2026 16:17:47 +0800 Subject: [PATCH 01/18] Rewrite ProxyDesign --- app/src/main/AndroidManifest.xml | 4 +- .../kr328/clash/design/AccessControlDesign.kt | 206 ++--- .../github/kr328/clash/design/ProxyDesign.kt | 730 +++++++++++++++--- .../clash/design/adapter/ProxyAdapter.kt | 40 - .../clash/design/adapter/ProxyPageAdapter.kt | 116 --- .../kr328/clash/design/component/ProxyMenu.kt | 120 --- .../design/component/ProxyPageFactory.kt | 62 -- .../kr328/clash/design/component/ProxyView.kt | 184 ----- design/src/main/res/layout/design_proxy.xml | 134 ---- 9 files changed, 698 insertions(+), 898 deletions(-) delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/adapter/ProxyPageAdapter.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/component/ProxyMenu.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt delete mode 100644 design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt delete mode 100644 design/src/main/res/layout/design_proxy.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08f1c2760c..22c7c95916 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -109,9 +109,7 @@ android:exported="false" /> + android:exported="false" /> diff --git a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt index 794786cfd0..58930fcbfe 100644 --- a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -27,6 +28,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults @@ -158,53 +160,34 @@ private fun AccessControlScreen( var showMenu by remember { mutableStateOf(false) } if (showMenu) { - AccessControlMenuSheet( - sort = sort, - reverse = reverse, - showSystemApps = showSystemApps, - onDismiss = { showMenu = false }, - onSelectAll = { - showMenu = false - onSelectAll() - }, - onSelectNone = { - showMenu = false - onSelectNone() - }, - onSelectInvert = { - showMenu = false - onSelectInvert() - }, - onImport = { - showMenu = false - onImport() - }, - onExport = { - showMenu = false - onExport() - }, - onUpdateSort = { - sort = it - onUpdateSort(it) - }, - onUpdateReverse = { - reverse = it - onUpdateReverse(it) - }, - onUpdateShowSystemApps = { - showSystemApps = it - onUpdateShowSystemApps(it) - }, - ) + ModalBottomSheet( + onDismissRequest = { showMenu = false }, + sheetState = rememberModalBottomSheetState(), + ) { + AccessControlMenuContent( + sort = sort, + reverse = reverse, + showSystemApps = showSystemApps, + onSelectAll = onSelectAll, + onSelectNone = onSelectNone, + onSelectInvert = onSelectInvert, + onImport = onImport, + onExport = onExport, + onUpdateSort = onUpdateSort, + onUpdateReverse = onUpdateReverse, + onUpdateShowSystemApps = onUpdateShowSystemApps, + ) + Spacer(modifier = Modifier.height(16.dp)) + } } if (showSearch) { - AccessControlSearchSheet( - apps = apps, - selected = selected, - onToggleApp = onToggleApp, - onDismiss = { showSearch = false }, - ) + ModalBottomSheet( + onDismissRequest = { showSearch = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + AccessControlSearchContent(apps = apps, selected = selected, onToggleApp = onToggleApp) + } } MihomoScaffold( @@ -237,63 +220,8 @@ private fun AccessControlScreen( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AccessControlMenuSheet( - sort: AppInfoSort, - reverse: Boolean, - showSystemApps: Boolean, - modifier: Modifier = Modifier, - onDismiss: () -> Unit, - onSelectAll: () -> Unit, - onSelectNone: () -> Unit, - onSelectInvert: () -> Unit, - onImport: () -> Unit, - onExport: () -> Unit, - onUpdateSort: (AppInfoSort) -> Unit, - onUpdateReverse: (Boolean) -> Unit, - onUpdateShowSystemApps: (Boolean) -> Unit, -) { - ModalBottomSheet( - modifier = modifier, - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(), - ) { - AccessControlMenuContent( - sort = sort, - reverse = reverse, - showSystemApps = showSystemApps, - onSelectAll = onSelectAll, - onSelectNone = onSelectNone, - onSelectInvert = onSelectInvert, - onImport = onImport, - onExport = onExport, - onUpdateSort = onUpdateSort, - onUpdateReverse = onUpdateReverse, - onUpdateShowSystemApps = onUpdateShowSystemApps, - ) - Spacer(modifier = Modifier.height(16.dp)) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AccessControlSearchSheet( - apps: List, - selected: Set, - onToggleApp: (String) -> Unit, - onDismiss: () -> Unit, -) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - ) { - AccessControlSearchContent(apps = apps, selected = selected, onToggleApp = onToggleApp) - } -} - @Composable -private fun AccessControlSearchContent( +private fun ColumnScope.AccessControlSearchContent( apps: List, selected: Set, onToggleApp: (String) -> Unit, @@ -346,7 +274,7 @@ private fun AccessControlSearchContent( } @Composable -private fun AccessControlMenuContent( +private fun ColumnScope.AccessControlMenuContent( sort: AppInfoSort, reverse: Boolean, showSystemApps: Boolean, @@ -543,43 +471,51 @@ private fun AccessControlScreenPreview() = MihomoTheme { @PreviewMihomo @Composable private fun AccessControlMenuSheetPreview() = MihomoTheme { - AccessControlMenuContent( - sort = AppInfoSort.Label, - reverse = false, - showSystemApps = true, - onSelectAll = {}, - onSelectNone = {}, - onSelectInvert = {}, - onImport = {}, - onExport = {}, - onUpdateSort = {}, - onUpdateReverse = {}, - onUpdateShowSystemApps = {}, - ) + Surface { + Column { + AccessControlMenuContent( + sort = AppInfoSort.Label, + reverse = false, + showSystemApps = true, + onSelectAll = {}, + onSelectNone = {}, + onSelectInvert = {}, + onImport = {}, + onExport = {}, + onUpdateSort = {}, + onUpdateReverse = {}, + onUpdateShowSystemApps = {}, + ) + } + } } @PreviewMihomo @Composable private fun AccessControlSearchSheetPreview() = MihomoTheme { - AccessControlSearchContent( - apps = - listOf( - AppInfo( - packageName = "com.example.alpha", - label = "Alpha", - icon = Color.Gray.toArgb().toDrawable(), - installTime = 1_700_000_000_000, - updateDate = 1_710_000_000_000, - ), - AppInfo( - packageName = "com.example.beta", - label = "Beta", - icon = Color.DarkGray.toArgb().toDrawable(), - installTime = 1_690_000_000_000, - updateDate = 1_715_000_000_000, - ), - ), - selected = setOf("com.example.alpha"), - onToggleApp = {}, - ) + Surface { + Column { + AccessControlSearchContent( + apps = + listOf( + AppInfo( + packageName = "com.example.alpha", + label = "Alpha", + icon = Color.Gray.toArgb().toDrawable(), + installTime = 1_700_000_000_000, + updateDate = 1_710_000_000_000, + ), + AppInfo( + packageName = "com.example.beta", + label = "Beta", + icon = Color.DarkGray.toArgb().toDrawable(), + installTime = 1_690_000_000_000, + updateDate = 1_715_000_000_000, + ), + ), + selected = setOf("com.example.alpha"), + onToggleApp = {}, + ) + } + } } diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index dfd31c5a1e..b729ed1e36 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -1,32 +1,83 @@ package com.github.kr328.clash.design import android.content.Context -import android.content.res.ColorStateList import android.view.View import android.widget.Toast -import androidx.viewpager2.widget.ViewPager2 +import androidx.annotation.ColorInt +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import com.github.kr328.clash.core.model.Proxy +import com.github.kr328.clash.core.model.ProxySort import com.github.kr328.clash.core.model.TunnelState -import com.github.kr328.clash.design.adapter.ProxyAdapter -import com.github.kr328.clash.design.adapter.ProxyPageAdapter -import com.github.kr328.clash.design.component.ProxyMenu +import com.github.kr328.clash.design.component.MihomoScaffold import com.github.kr328.clash.design.component.ProxyViewConfig -import com.github.kr328.clash.design.databinding.DesignProxyBinding +import com.github.kr328.clash.design.component.ProxyViewState import com.github.kr328.clash.design.model.ProxyState import com.github.kr328.clash.design.store.UiStore -import com.github.kr328.clash.design.util.applyFrom -import com.github.kr328.clash.design.util.layoutInflater -import com.github.kr328.clash.design.util.resolveThemedColor -import com.github.kr328.clash.design.util.root -import com.google.android.material.tabs.TabLayoutMediator +import com.github.kr328.clash.design.ui.theme.MihomoTheme +import com.github.kr328.clash.design.ui.theme.PreviewMihomo import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class ProxyDesign( context: Context, - overrideMode: TunnelState.Mode?, - groupNames: List, - uiStore: UiStore, + private val overrideMode: TunnelState.Mode?, + private val groupNames: List, + private val uiStore: UiStore, ) : Design(context) { sealed class Request { object ReloadAll : Request() @@ -42,31 +93,58 @@ class ProxyDesign( data class UrlTest(val index: Int) : Request() } - private val binding = DesignProxyBinding.inflate(context.layoutInflater, context.root, false) - - private var config = ProxyViewConfig(context, uiStore.proxyLine) - - private val menu: ProxyMenu by lazy { - ProxyMenu(context, binding.menuView, overrideMode, uiStore, requests) { - config.proxyLine = uiStore.proxyLine + private val config = ProxyViewConfig(context, uiStore.proxyLine) + private val groups = List(groupNames.size) { ProxyGroupUiState() } + private val initialPage = groupNames.indexOf(uiStore.proxyLastGroup).coerceAtLeast(0) + + private var currentPage by + mutableIntStateOf(initialPage.coerceAtMost((groupNames.size - 1).coerceAtLeast(0))) + private var proxyLine by mutableIntStateOf(uiStore.proxyLine) + private var excludeNotSelectable by mutableStateOf(uiStore.proxyExcludeNotSelectable) + private var proxySort by mutableStateOf(uiStore.proxySort) + private var selectedMode by mutableStateOf(overrideMode) + + override val root: View by composeView { + MihomoTheme { + ProxyScreen( + groupNames = groupNames, + groups = groups, + currentPage = currentPage, + proxyLine = proxyLine, + excludeNotSelectable = excludeNotSelectable, + proxySort = proxySort, + overrideMode = selectedMode, + initialPage = initialPage, + onPageChanged = { index -> + currentPage = index + groupNames.getOrNull(index)?.let { uiStore.proxyLastGroup = it } + }, + onUrlTest = ::requestUrlTesting, + onExcludeNotSelectableChanged = { enabled -> + excludeNotSelectable = enabled + uiStore.proxyExcludeNotSelectable = enabled + requests.trySend(Request.ReLaunch) + }, + onProxyLineChanged = { line -> + proxyLine = line + uiStore.proxyLine = line + config.proxyLine = line + requests.trySend(Request.ReloadAll) + }, + onProxySortChanged = { sort -> + proxySort = sort + uiStore.proxySort = sort + requests.trySend(Request.ReloadAll) + }, + onOverrideModeSelected = { mode -> + selectedMode = mode + requests.trySend(Request.PatchMode(mode)) + }, + onProxySelected = { index, name -> requests.trySend(Request.Select(index, name)) }, + ) } } - private val adapter: ProxyPageAdapter - get() = binding.pagesView.adapter!! as ProxyPageAdapter - - private var horizontalScrolling = false - private val verticalBottomScrolled: Boolean - get() = adapter.states[binding.pagesView.currentItem].bottom - - private var urlTesting: Boolean - get() = adapter.states[binding.pagesView.currentItem].urlTesting - set(value) { - adapter.states[binding.pagesView.currentItem].urlTesting = value - } - - override val root: View = binding.root - suspend fun updateGroup( position: Int, proxies: List, @@ -74,105 +152,549 @@ class ProxyDesign( parent: ProxyState, links: Map, ) { - adapter.updateAdapter(position, proxies, selectable, parent, links) - - adapter.states[position].urlTesting = false + val states = + withContext(Dispatchers.Default) { + proxies.map { proxy -> + ProxyViewState(config, proxy, parent, if (proxy.type.group) links[proxy.name] else null) + .apply { update(true) } + } + } - updateUrlTestButtonStatus() + withContext(Dispatchers.Main) { + groups[position].apply { + rawStates = states + this.selectable = selectable + bottom = false + urlTesting = false + refresh() + } + } } - suspend fun requestRedrawVisible() { - withContext(Dispatchers.Main) { adapter.requestRedrawVisible() } - } + suspend fun requestRedrawVisible() = + withContext(Dispatchers.Main) { groups.forEach(ProxyGroupUiState::refresh) } - suspend fun showModeSwitchTips() { + suspend fun showModeSwitchTips() = withContext(Dispatchers.Main) { Toast.makeText(context, R.string.mode_switch_tips, Toast.LENGTH_LONG).show() } - } - init { - binding.self = this + fun requestUrlTesting() { + if (groups.isEmpty()) return - binding.activityBarLayout.applyFrom(context) + val page = currentPage.coerceIn(groups.indices) - binding.menuView.setOnClickListener { menu.show() } + groups[page].urlTesting = true + requests.trySend(Request.UrlTest(page)) + } +} - if (groupNames.isEmpty()) { - binding.emptyView.visibility = View.VISIBLE +private data class ProxyItemUiState( + val key: String, + val title: String, + val subtitle: String, + val delayText: String, + @get:ColorInt val background: Int, + val controls: Int, +) + +private class ProxyGroupUiState { + var items by mutableStateOf>(emptyList()) + var selectable by mutableStateOf(false) + var bottom by mutableStateOf(false) + var urlTesting by mutableStateOf(false) + var rawStates: List = emptyList() + + fun refresh() { + items = rawStates.map { state -> + state.update(true) + + ProxyItemUiState( + key = state.proxy.name, + title = state.title, + subtitle = state.subtitle, + delayText = state.delayText, + background = state.background, + controls = state.controls, + ) + } + } +} - binding.urlTestView.visibility = View.GONE - binding.tabLayoutView.visibility = View.GONE - binding.elevationView.visibility = View.GONE - binding.pagesView.visibility = View.GONE - binding.urlTestFloatView.visibility = View.GONE - } else { - binding.urlTestFloatView.supportImageTintList = - ColorStateList.valueOf( - context.resolveThemedColor(com.google.android.material.R.attr.colorOnPrimary) - ) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun ProxyScreen( + groupNames: List, + groups: List, + currentPage: Int, + proxyLine: Int, + excludeNotSelectable: Boolean, + proxySort: ProxySort, + overrideMode: TunnelState.Mode?, + initialPage: Int, + onPageChanged: (Int) -> Unit, + onUrlTest: () -> Unit, + onExcludeNotSelectableChanged: (Boolean) -> Unit, + onProxyLineChanged: (Int) -> Unit, + onProxySortChanged: (ProxySort) -> Unit, + onOverrideModeSelected: (TunnelState.Mode?) -> Unit, + onProxySelected: (Int, String) -> Unit, +) { + var menuVisible by remember { mutableStateOf(false) } + val currentGroup = groups.getOrNull(currentPage) + val showUrlTestAction = groupNames.isNotEmpty() + + if (menuVisible) { + ModalBottomSheet(onDismissRequest = { menuVisible = false }) { + ProxyMenuSheetContent( + overrideMode = overrideMode, + excludeNotSelectable = excludeNotSelectable, + proxyLine = proxyLine, + proxySort = proxySort, + onExcludeNotSelectableChanged = onExcludeNotSelectableChanged, + onProxyLineChanged = onProxyLineChanged, + onProxySortChanged = onProxySortChanged, + onOverrideModeSelected = onOverrideModeSelected, + ) + } + } - binding.pagesView.apply { - adapter = - ProxyPageAdapter( - surface, - config, - List(groupNames.size) { index -> - ProxyAdapter(config) { name -> requests.trySend(Request.Select(index, name)) } - }, - ) { - if (it == currentItem) updateUrlTestButtonStatus() + MihomoScaffold( + title = stringResource(R.string.proxy), + actions = { + if (showUrlTestAction) { + if (currentGroup?.urlTesting == true) { + CircularProgressIndicator( + modifier = Modifier.padding(horizontal = 12.dp).size(24.dp), + strokeWidth = 2.dp, + ) + } else { + IconButton(onClick = onUrlTest) { + Icon( + painter = painterResource(R.drawable.ic_baseline_flash_on), + contentDescription = stringResource(R.string.delay_test), + ) } + } + } - registerOnPageChangeCallback( - object : ViewPager2.OnPageChangeCallback() { - override fun onPageScrollStateChanged(state: Int) { - horizontalScrolling = state != ViewPager2.SCROLL_STATE_IDLE + IconButton(onClick = { menuVisible = true }) { + Icon( + painter = painterResource(R.drawable.ic_baseline_more_vert), + contentDescription = stringResource(R.string.more), + ) + } + }, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + if (groupNames.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.proxy_empty_tips), + style = MaterialTheme.typography.titleMedium, + ) + } + } else { + ProxyPagerContent( + groupNames = groupNames, + groups = groups, + proxyLine = proxyLine, + initialPage = initialPage, + currentPage = currentPage, + onPageChanged = onPageChanged, + onProxySelected = onProxySelected, + ) + } + } + } +} - updateUrlTestButtonStatus() - } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ProxyPagerContent( + groupNames: List, + groups: List, + proxyLine: Int, + initialPage: Int, + currentPage: Int, + onPageChanged: (Int) -> Unit, + onProxySelected: (Int, String) -> Unit, +) { + val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { groupNames.size }) + val scope = rememberCoroutineScope() + + LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect(onPageChanged) } + + LaunchedEffect(currentPage) { + if (currentPage != pagerState.currentPage) pagerState.scrollToPage(currentPage) + } - override fun onPageSelected(position: Int) { - uiStore.proxyLastGroup = groupNames[position] - } - } + Column(modifier = Modifier.fillMaxSize()) { + PrimaryScrollableTabRow(selectedTabIndex = pagerState.currentPage, edgePadding = 0.dp) { + groupNames.forEachIndexed { index, name -> + Tab( + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { Text(text = name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, ) } + } - TabLayoutMediator(binding.tabLayoutView, binding.pagesView) { tab, index -> - tab.text = groupNames[index] - } - .attach() + HorizontalDivider() - val initialPosition = groupNames.indexOf(uiStore.proxyLastGroup) + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> + ProxyGroupPage( + index = page, + proxyLine = proxyLine, + group = groups[page], + onBottomChanged = { bottom -> groups[page].bottom = bottom }, + onProxySelected = onProxySelected, + ) + } + } +} - binding.pagesView.post { - if (initialPosition > 0) binding.pagesView.setCurrentItem(initialPosition, false) - } +@Composable +private fun ProxyGroupPage( + index: Int, + proxyLine: Int, + group: ProxyGroupUiState, + onBottomChanged: (Boolean) -> Unit, + onProxySelected: (Int, String) -> Unit, +) { + val gridState = rememberLazyGridState() + + LaunchedEffect(gridState) { + snapshotFlow { !gridState.canScrollForward }.collect(onBottomChanged) + } + + LazyVerticalGrid( + columns = GridCells.Fixed(columnsForProxyLine(proxyLine)), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 88.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(count = group.items.size, key = { group.items[it].key }) { itemIndex -> + val item = group.items[itemIndex] + + ProxyItemCard( + item = item, + proxyLine = proxyLine, + selectable = group.selectable, + onClick = { onProxySelected(index, item.key) }, + ) } } +} - fun requestUrlTesting() { - urlTesting = true +@Composable +private fun ProxyItemCard( + item: ProxyItemUiState, + proxyLine: Int, + selectable: Boolean, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(if (proxyLine == 1) 0.dp else 5.dp) + val modifier = + Modifier.fillMaxWidth() + .then(if (proxyLine == 1) Modifier else Modifier.shadow(elevation = 2.dp, shape = shape)) + .clip(shape) + .background(Color(item.background)) + .clickable(enabled = selectable, onClick = onClick) + .padding(horizontal = if (proxyLine == 3) 12.dp else 15.dp, vertical = 14.dp) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = item.title, + color = Color(item.controls), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = item.subtitle, + color = Color(item.controls), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (item.delayText.isNotEmpty()) { + Text( + text = item.delayText, + color = Color(item.controls), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + } + } +} - requests.trySend(Request.UrlTest(binding.pagesView.currentItem)) +@Composable +private fun ColumnScope.ProxyMenuSheetContent( + overrideMode: TunnelState.Mode?, + excludeNotSelectable: Boolean, + proxyLine: Int, + proxySort: ProxySort, + onExcludeNotSelectableChanged: (Boolean) -> Unit, + onProxyLineChanged: (Int) -> Unit, + onProxySortChanged: (ProxySort) -> Unit, + onOverrideModeSelected: (TunnelState.Mode?) -> Unit, +) { + ProxyMenuSection(title = stringResource(R.string.filter)) { + ProxyMenuCheckboxRow( + title = stringResource(R.string.not_selectable), + checked = excludeNotSelectable, + onClick = { onExcludeNotSelectableChanged(!excludeNotSelectable) }, + ) + } - updateUrlTestButtonStatus() + ProxyMenuSection(title = stringResource(R.string.mode)) { + ProxyMenuRadioRow( + title = stringResource(R.string.dont_modify), + selected = overrideMode == null, + onClick = { onOverrideModeSelected(null) }, + ) + ProxyMenuRadioRow( + title = stringResource(R.string.direct_mode), + selected = overrideMode == TunnelState.Mode.Direct, + onClick = { onOverrideModeSelected(TunnelState.Mode.Direct) }, + ) + ProxyMenuRadioRow( + title = stringResource(R.string.global_mode), + selected = overrideMode == TunnelState.Mode.Global, + onClick = { onOverrideModeSelected(TunnelState.Mode.Global) }, + ) + ProxyMenuRadioRow( + title = stringResource(R.string.rule_mode), + selected = overrideMode == TunnelState.Mode.Rule, + onClick = { onOverrideModeSelected(TunnelState.Mode.Rule) }, + ) } - private fun updateUrlTestButtonStatus() { - if (verticalBottomScrolled || horizontalScrolling || urlTesting) { - binding.urlTestFloatView.hide() - } else { - binding.urlTestFloatView.show() + ProxyMenuSection(title = stringResource(R.string.layout)) { + ProxyMenuRadioRow( + title = stringResource(R.string.single), + selected = proxyLine == 1, + onClick = { onProxyLineChanged(1) }, + ) + ProxyMenuRadioRow( + title = stringResource(R.string.doubles), + selected = proxyLine == 2, + onClick = { onProxyLineChanged(2) }, + ) + ProxyMenuRadioRow( + title = stringResource(R.string.multiple), + selected = proxyLine == 3, + onClick = { onProxyLineChanged(3) }, + ) + } + + ProxyMenuSection(title = stringResource(R.string.sort)) { + ProxyMenuRadioRow( + title = stringResource(R.string.default_), + selected = proxySort == ProxySort.Default, + onClick = { onProxySortChanged(ProxySort.Default) }, + ) + ProxyMenuRadioRow( + title = stringResource(R.string.name), + selected = proxySort == ProxySort.Title, + onClick = { onProxySortChanged(ProxySort.Title) }, + ) + ProxyMenuRadioRow( + title = stringResource(R.string.delay), + selected = proxySort == ProxySort.Delay, + onClick = { onProxySortChanged(ProxySort.Delay) }, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) +} + +@Composable +private fun ProxyMenuSection(title: String, content: @Composable () -> Unit) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + ) + content() + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +private fun ProxyMenuCheckboxRow(title: String, checked: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = checked, onCheckedChange = null) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = title, style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun ProxyMenuRadioRow(title: String, selected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = null) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = title, style = MaterialTheme.typography.bodyLarge) + } +} + +private fun columnsForProxyLine(proxyLine: Int): Int = + when (proxyLine) { + 1 -> 1 + 2 -> 2 + else -> 3 + } + +@PreviewMihomo +@Composable +private fun ProxyScreenPreview() = MihomoTheme { + val selectedBackground = MaterialTheme.colorScheme.primary.toArgb() + val selectedControls = MaterialTheme.colorScheme.onPrimary.toArgb() + val unselectedBackground = MaterialTheme.colorScheme.surface.toArgb() + val unselectedControls = MaterialTheme.colorScheme.onSurface.toArgb() + val groups = + remember(selectedBackground, selectedControls, unselectedBackground, unselectedControls) { + listOf( + ProxyGroupUiState().apply { + selectable = true + items = + listOf( + ProxyItemUiState( + key = "auto", + title = "Auto", + subtitle = "URLTest(HK-01)", + delayText = "48", + background = selectedBackground, + controls = selectedControls, + ), + ProxyItemUiState( + key = "hk-01", + title = "Hong Kong 01", + subtitle = "BGP | 1.2x", + delayText = "62", + background = unselectedBackground, + controls = unselectedControls, + ), + ProxyItemUiState( + key = "jp-01", + title = "Japan 01", + subtitle = "Tokyo | IPLC", + delayText = "89", + background = unselectedBackground, + controls = unselectedControls, + ), + ProxyItemUiState( + key = "sg-01", + title = "Singapore 01", + subtitle = "Premium", + delayText = "74", + background = unselectedBackground, + controls = unselectedControls, + ), + ) + }, + ProxyGroupUiState().apply { + selectable = true + urlTesting = true + items = + listOf( + ProxyItemUiState( + key = "fallback-a", + title = "Fallback A", + subtitle = "Selector(Node-2)", + delayText = "128", + background = unselectedBackground, + controls = unselectedControls, + ), + ProxyItemUiState( + key = "fallback-b", + title = "Fallback B", + subtitle = "Selector(Node-4)", + delayText = "156", + background = unselectedBackground, + controls = unselectedControls, + ), + ) + }, + ) } - if (urlTesting) { - binding.urlTestView.visibility = View.GONE - binding.urlTestProgressView.visibility = View.VISIBLE - } else { - binding.urlTestView.visibility = View.VISIBLE - binding.urlTestProgressView.visibility = View.GONE + ProxyScreen( + groupNames = listOf("Auto", "Fallback"), + groups = groups, + currentPage = 0, + proxyLine = 2, + excludeNotSelectable = false, + proxySort = ProxySort.Delay, + overrideMode = TunnelState.Mode.Rule, + initialPage = 0, + onPageChanged = {}, + onUrlTest = {}, + onExcludeNotSelectableChanged = {}, + onProxyLineChanged = {}, + onProxySortChanged = {}, + onOverrideModeSelected = {}, + onProxySelected = { _, _ -> }, + ) +} + +@PreviewMihomo +@Composable +private fun ProxyScreenEmptyPreview() = MihomoTheme { + ProxyScreen( + groupNames = emptyList(), + groups = emptyList(), + currentPage = 0, + proxyLine = 2, + excludeNotSelectable = false, + proxySort = ProxySort.Default, + overrideMode = null, + initialPage = 0, + onPageChanged = {}, + onUrlTest = {}, + onExcludeNotSelectableChanged = {}, + onProxyLineChanged = {}, + onProxySortChanged = {}, + onOverrideModeSelected = {}, + onProxySelected = { _, _ -> }, + ) +} + +@PreviewMihomo +@Composable +private fun ProxyMenuSheetContentPreview() = MihomoTheme { + Surface { + Column { + ProxyMenuSheetContent( + overrideMode = TunnelState.Mode.Rule, + excludeNotSelectable = false, + proxyLine = 2, + proxySort = ProxySort.Delay, + onExcludeNotSelectableChanged = {}, + onProxyLineChanged = {}, + onProxySortChanged = {}, + onOverrideModeSelected = {}, + ) } } } diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt deleted file mode 100644 index a411f87c82..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.github.kr328.clash.design.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.github.kr328.clash.design.component.ProxyView -import com.github.kr328.clash.design.component.ProxyViewConfig -import com.github.kr328.clash.design.component.ProxyViewState - -class ProxyAdapter(private val config: ProxyViewConfig, private val clicked: (String) -> Unit) : - RecyclerView.Adapter() { - class Holder(val view: ProxyView) : RecyclerView.ViewHolder(view) - - var selectable: Boolean = false - var states: List = emptyList() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { - return Holder(ProxyView(config.context, config)) - } - - override fun onBindViewHolder(holder: Holder, position: Int) { - val current = states[position] - - holder.view.apply { - state = current - - setOnClickListener { clicked(current.proxy.name) } - - val isSelector = selectable - - isFocusable = isSelector - isClickable = isSelector - - current.update(true) - } - } - - override fun getItemCount(): Int { - return states.size - } -} diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyPageAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyPageAdapter.kt deleted file mode 100644 index a4fa2d6c4d..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyPageAdapter.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.github.kr328.clash.design.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.github.kr328.clash.core.model.Proxy -import com.github.kr328.clash.design.R -import com.github.kr328.clash.design.component.ProxyPageFactory -import com.github.kr328.clash.design.component.ProxyViewConfig -import com.github.kr328.clash.design.component.ProxyViewState -import com.github.kr328.clash.design.model.ProxyPageState -import com.github.kr328.clash.design.model.ProxyState -import com.github.kr328.clash.design.ui.Surface -import com.github.kr328.clash.design.util.addScrolledToBottomObserver -import com.github.kr328.clash.design.util.bindInsets -import com.github.kr328.clash.design.util.firstVisibleView -import com.github.kr328.clash.design.util.getPixels -import com.github.kr328.clash.design.util.invalidateChildren -import com.github.kr328.clash.design.util.swapDataSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -class ProxyPageAdapter( - private val surface: Surface, - private val config: ProxyViewConfig, - private val adapters: List, - private val stateChanged: (Int) -> Unit, -) : RecyclerView.Adapter() { - private val factory = ProxyPageFactory(config) - private var parent: RecyclerView? = null - - val states = List(adapters.size) { ProxyPageState() } - - suspend fun updateAdapter( - position: Int, - proxies: List, - selectable: Boolean, - parent: ProxyState, - links: Map, - ) { - val states = - withContext(Dispatchers.Default) { - proxies.map { - val link = if (it.type.group) links[it.name] else null - - ProxyViewState(config, it, parent, link) - } - } - - withContext(Dispatchers.Main) { - adapters[position].apply { - this.selectable = selectable - this.swapDataSet(this::states, states, false) - } - - requestRedrawVisible() - } - } - - fun requestRedrawVisible() { - factory.fromRoot(parent?.firstVisibleView ?: return).recyclerView.invalidateChildren() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProxyPageFactory.Holder { - val holder = factory.newInstance() - - val toolbarHeight = config.context.getPixels(R.dimen.toolbar_height) - val tabHeight = config.context.getPixels(R.dimen.tab_layout_height) - - holder.recyclerView.bindInsets(surface, toolbarHeight + tabHeight) - holder.recyclerView.addScrolledToBottomObserver { view, bottom -> - val position = view.position - val state = states[position] - - if (state.bottom != bottom) { - state.bottom = bottom - - stateChanged(position) - } - } - - return holder - } - - override fun onBindViewHolder(holder: ProxyPageFactory.Holder, position: Int) { - val adapter = adapters[position] - - states[position].bottom = false - - holder.recyclerView.apply { - this.position = position - this.swapAdapter(adapter, false) - } - } - - override fun getItemCount(): Int { - return adapters.size - } - - override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { - this.parent = recyclerView - - recyclerView.isFocusable = false - } - - override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { - this.parent = null - } - - private var RecyclerView.position: Int - get() { - return tag as? Int ?: -1 - } - set(value) { - tag = value - } -} diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyMenu.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyMenu.kt deleted file mode 100644 index 7aa363faf4..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyMenu.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.github.kr328.clash.design.component - -import android.content.Context -import android.view.MenuItem -import android.view.View -import androidx.appcompat.widget.PopupMenu -import com.github.kr328.clash.core.model.ProxySort -import com.github.kr328.clash.core.model.TunnelState -import com.github.kr328.clash.design.ProxyDesign -import com.github.kr328.clash.design.R -import com.github.kr328.clash.design.store.UiStore -import kotlinx.coroutines.channels.Channel - -class ProxyMenu( - context: Context, - menuView: View, - mode: TunnelState.Mode?, - private val uiStore: UiStore, - private val requests: Channel, - private val updateConfig: () -> Unit, -) : PopupMenu.OnMenuItemClickListener { - private val menu = PopupMenu(context, menuView) - - fun show() { - menu.show() - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - item.isChecked = !item.isChecked - - when (item.itemId) { - R.id.not_selectable -> { - uiStore.proxyExcludeNotSelectable = item.isChecked - - requests.trySend(ProxyDesign.Request.ReLaunch) - } - R.id.single -> { - uiStore.proxyLine = 1 - - updateConfig() - - requests.trySend(ProxyDesign.Request.ReloadAll) - } - R.id.doubles -> { - uiStore.proxyLine = 2 - - updateConfig() - - requests.trySend(ProxyDesign.Request.ReloadAll) - } - R.id.multiple -> { - uiStore.proxyLine = 3 - - updateConfig() - - requests.trySend(ProxyDesign.Request.ReloadAll) - } - R.id.default_ -> { - uiStore.proxySort = ProxySort.Default - - requests.trySend(ProxyDesign.Request.ReloadAll) - } - R.id.name -> { - uiStore.proxySort = ProxySort.Title - - requests.trySend(ProxyDesign.Request.ReloadAll) - } - R.id.delay -> { - uiStore.proxySort = ProxySort.Delay - - requests.trySend(ProxyDesign.Request.ReloadAll) - } - R.id.dont_modify -> { - requests.trySend(ProxyDesign.Request.PatchMode(null)) - } - R.id.direct_mode -> { - requests.trySend(ProxyDesign.Request.PatchMode(TunnelState.Mode.Direct)) - } - R.id.global_mode -> { - requests.trySend(ProxyDesign.Request.PatchMode(TunnelState.Mode.Global)) - } - R.id.rule_mode -> { - requests.trySend(ProxyDesign.Request.PatchMode(TunnelState.Mode.Rule)) - } - else -> return false - } - - return true - } - - init { - menu.menuInflater.inflate(R.menu.menu_proxy, menu.menu) - - menu.menu.apply { - findItem(R.id.not_selectable).isChecked = uiStore.proxyExcludeNotSelectable - - when (uiStore.proxyLine) { - 1 -> findItem(R.id.single).isChecked = true - 2 -> findItem(R.id.doubles).isChecked = true - 3 -> findItem(R.id.multiple).isChecked = true - } - - when (uiStore.proxySort) { - ProxySort.Default -> findItem(R.id.default_).isChecked = true - ProxySort.Title -> findItem(R.id.name).isChecked = true - ProxySort.Delay -> findItem(R.id.delay).isChecked = true - } - - when (mode) { - null -> findItem(R.id.dont_modify).isChecked = true - TunnelState.Mode.Direct -> findItem(R.id.direct_mode).isChecked = true - TunnelState.Mode.Global -> findItem(R.id.global_mode).isChecked = true - TunnelState.Mode.Rule -> findItem(R.id.rule_mode).isChecked = true - else -> {} - } - } - - menu.setOnMenuItemClickListener(this) - } -} diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt deleted file mode 100644 index 62045277eb..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.kr328.clash.design.component - -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.github.kr328.clash.design.view.VerticalScrollableHost - -class ProxyPageFactory(private val config: ProxyViewConfig) { - class Holder(val recyclerView: RecyclerView, val root: View) : RecyclerView.ViewHolder(root) - - private val childrenPool = RecyclerView.RecycledViewPool() - - fun newInstance(): Holder { - val root = - VerticalScrollableHost(config.context).apply { - layoutParams = - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - } - - val recyclerView = - RecyclerView(config.context).apply { - layoutParams = - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - } - - root.addView(recyclerView) - - recyclerView.apply { - layoutManager = - GridLayoutManager(config.context, 6).apply { - spanSizeLookup = - object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int): Int { - var grids = 0 - when (config.proxyLine) { - 2 -> grids = 3 - 3 -> grids = 2 - } - return if (config.proxyLine == 1) 6 else grids - } - } - } - - setRecycledViewPool(childrenPool) - - clipToPadding = false - } - - return Holder(recyclerView, root).apply { root.tag = this } - } - - fun fromRoot(root: View): Holder { - return root.tag!! as Holder - } -} diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt deleted file mode 100644 index c954517d77..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.github.kr328.clash.design.component - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Path -import android.view.View -import com.github.kr328.clash.common.compat.getDrawableCompat - -class ProxyView(context: Context, config: ProxyViewConfig) : View(context) { - - init { - background = context.getDrawableCompat(config.clickableBackground) - } - - var state: ProxyViewState? = null - - constructor(context: Context) : this(context, ProxyViewConfig(context, 2)) - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val state = state ?: return super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - val width = - when (MeasureSpec.getMode(widthMeasureSpec)) { - MeasureSpec.UNSPECIFIED -> resources.displayMetrics.widthPixels - MeasureSpec.AT_MOST, - MeasureSpec.EXACTLY -> MeasureSpec.getSize(widthMeasureSpec) - else -> throw IllegalArgumentException("invalid measure spec") - } - - state.paint.apply { - reset() - - textSize = state.config.textSize - - getTextBounds("Stub!", 0, 1, state.rect) - } - - val textHeight = state.rect.height() - val exceptHeight = - (state.config.layoutPadding * 2 + - state.config.contentPadding * 2 + - textHeight * 2 + - state.config.textMargin) - .toInt() - - val height = - when (MeasureSpec.getMode(heightMeasureSpec)) { - MeasureSpec.UNSPECIFIED -> exceptHeight - MeasureSpec.AT_MOST, - MeasureSpec.EXACTLY -> exceptHeight.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec)) - else -> throw IllegalArgumentException("invalid measure spec") - } - - setMeasuredDimension(width, height) - } - - override fun draw(canvas: Canvas) { - val state = state ?: return super.draw(canvas) - - if (state.update(false)) postInvalidate() - - val width = width.toFloat() - val height = height.toFloat() - - val paint = state.paint - - paint.reset() - - paint.color = state.background - paint.style = Paint.Style.FILL - - // draw background - canvas.apply { - if (state.config.proxyLine == 1) { - drawRect(0f, 0f, width, height, paint) - } else { - val path = state.path - - path.reset() - - path.addRoundRect( - state.config.layoutPadding, - state.config.layoutPadding, - width - state.config.layoutPadding, - height - state.config.layoutPadding, - state.config.cardRadius, - state.config.cardRadius, - Path.Direction.CW, - ) - - paint.setShadowLayer( - state.config.cardRadius, - state.config.cardOffset, - state.config.cardOffset, - state.config.shadow, - ) - - drawPath(path, paint) - - clipPath(path) - } - } - - super.draw(canvas) - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - val state = state ?: return - - val paint = state.paint - - val width = width.toFloat() - val height = height.toFloat() - - paint.textSize = state.config.textSize - - // measure delay text bounds - val delayCount = - paint.breakText( - state.delayText, - false, - (width - state.config.layoutPadding * 2 - state.config.contentPadding * 2).coerceAtLeast( - 0f - ), - null, - ) - - state.paint.getTextBounds(state.delayText, 0, delayCount, state.rect) - - val delayWidth = state.rect.width() - - val mainTextWidth = - (width - - state.config.layoutPadding * 2 - - state.config.contentPadding * 2 - - delayWidth - - state.config.textMargin * 2) - .coerceAtLeast(0f) - - // measure title text bounds - val titleCount = paint.breakText(state.title, false, mainTextWidth, null) - - // measure subtitle text bounds - val subtitleCount = paint.breakText(state.subtitle, false, mainTextWidth, null) - - // text draw measure - val textOffset = (paint.descent() + paint.ascent()) / 2 - - paint.reset() - - paint.textSize = state.config.textSize - paint.isAntiAlias = true - paint.color = state.controls - - // draw delay - canvas.apply { - val x = width - state.config.layoutPadding - state.config.contentPadding - delayWidth - val y = height / 2f - textOffset - - drawText(state.delayText, 0, delayCount, x, y, paint) - } - - // draw title - canvas.apply { - val x = state.config.layoutPadding + state.config.contentPadding - val y = - state.config.layoutPadding + (height - state.config.layoutPadding * 2) / 3f - textOffset - - drawText(state.title, 0, titleCount, x, y, paint) - } - - // draw subtitle - canvas.apply { - val x = state.config.layoutPadding + state.config.contentPadding - val y = - state.config.layoutPadding + (height - state.config.layoutPadding * 2) / 3f * 2 - textOffset - - drawText(state.subtitle, 0, subtitleCount, x, y, paint) - } - } -} diff --git a/design/src/main/res/layout/design_proxy.xml b/design/src/main/res/layout/design_proxy.xml deleted file mode 100644 index 306444f971..0000000000 --- a/design/src/main/res/layout/design_proxy.xml +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From fefc36797b391291a9f9fc24a5e66576ee20b833 Mon Sep 17 00:00:00 2001 From: Zongle Wang Date: Tue, 21 Apr 2026 12:53:06 +0800 Subject: [PATCH 02/18] Update design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/main/java/com/github/kr328/clash/design/ProxyDesign.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index b729ed1e36..b655d2264a 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -372,7 +372,7 @@ private fun ProxyGroupPage( columns = GridCells.Fixed(columnsForProxyLine(proxyLine)), state = gridState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 88.dp), + contentPadding = PaddingValues(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { From 097ef916c3976347d5d24cbc93cab5ab33b0ab1f Mon Sep 17 00:00:00 2001 From: Zongle Wang Date: Tue, 21 Apr 2026 12:53:59 +0800 Subject: [PATCH 03/18] Update design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../kr328/clash/design/AccessControlDesign.kt | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt index 58930fcbfe..e6f954c1ad 100644 --- a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt @@ -168,14 +168,38 @@ private fun AccessControlScreen( sort = sort, reverse = reverse, showSystemApps = showSystemApps, - onSelectAll = onSelectAll, - onSelectNone = onSelectNone, - onSelectInvert = onSelectInvert, - onImport = onImport, - onExport = onExport, - onUpdateSort = onUpdateSort, - onUpdateReverse = onUpdateReverse, - onUpdateShowSystemApps = onUpdateShowSystemApps, + onSelectAll = { + showMenu = false + onSelectAll() + }, + onSelectNone = { + showMenu = false + onSelectNone() + }, + onSelectInvert = { + showMenu = false + onSelectInvert() + }, + onImport = { + showMenu = false + onImport() + }, + onExport = { + showMenu = false + onExport() + }, + onUpdateSort = { + showMenu = false + onUpdateSort(it) + }, + onUpdateReverse = { + showMenu = false + onUpdateReverse(it) + }, + onUpdateShowSystemApps = { + showMenu = false + onUpdateShowSystemApps(it) + }, ) Spacer(modifier = Modifier.height(16.dp)) } From 78c69227d706acbb98473aa2c279e1d05684d877 Mon Sep 17 00:00:00 2001 From: Zongle Wang Date: Tue, 21 Apr 2026 12:54:29 +0800 Subject: [PATCH 04/18] Update design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/main/java/com/github/kr328/clash/design/ProxyDesign.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index b655d2264a..95ea040175 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -156,7 +156,6 @@ class ProxyDesign( withContext(Dispatchers.Default) { proxies.map { proxy -> ProxyViewState(config, proxy, parent, if (proxy.type.group) links[proxy.name] else null) - .apply { update(true) } } } From 5e559aa8954b920108ec164a3126dcefb9b0497f Mon Sep 17 00:00:00 2001 From: Zongle Wang Date: Tue, 21 Apr 2026 12:54:53 +0800 Subject: [PATCH 05/18] Update design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/main/java/com/github/kr328/clash/design/ProxyDesign.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 95ea040175..59cd13c9d3 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -200,7 +200,6 @@ private data class ProxyItemUiState( private class ProxyGroupUiState { var items by mutableStateOf>(emptyList()) var selectable by mutableStateOf(false) - var bottom by mutableStateOf(false) var urlTesting by mutableStateOf(false) var rawStates: List = emptyList() From da4022d9966e33b304f137706a1b865901bdad88 Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 21 Apr 2026 12:55:58 +0800 Subject: [PATCH 06/18] Fix build --- .../java/com/github/kr328/clash/design/ProxyDesign.kt | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 59cd13c9d3..a4f491cc51 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -163,7 +163,6 @@ class ProxyDesign( groups[position].apply { rawStates = states this.selectable = selectable - bottom = false urlTesting = false refresh() } @@ -345,7 +344,6 @@ private fun ProxyPagerContent( index = page, proxyLine = proxyLine, group = groups[page], - onBottomChanged = { bottom -> groups[page].bottom = bottom }, onProxySelected = onProxySelected, ) } @@ -357,18 +355,11 @@ private fun ProxyGroupPage( index: Int, proxyLine: Int, group: ProxyGroupUiState, - onBottomChanged: (Boolean) -> Unit, onProxySelected: (Int, String) -> Unit, ) { - val gridState = rememberLazyGridState() - - LaunchedEffect(gridState) { - snapshotFlow { !gridState.canScrollForward }.collect(onBottomChanged) - } - LazyVerticalGrid( columns = GridCells.Fixed(columnsForProxyLine(proxyLine)), - state = gridState, + state = rememberLazyGridState(), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), From 8fa7a5b679f229174e1bd5cb6f5acf60c5a12170 Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 21 Apr 2026 12:57:33 +0800 Subject: [PATCH 07/18] 12.dp for contentPadding --- .../src/main/java/com/github/kr328/clash/design/ProxyDesign.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index a4f491cc51..98252751e9 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -361,7 +361,7 @@ private fun ProxyGroupPage( columns = GridCells.Fixed(columnsForProxyLine(proxyLine)), state = rememberLazyGridState(), modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 12.dp), + contentPadding = PaddingValues(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { From d0a52c6cd7caefbd8f349d7b3e3062e7939e703e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:59:52 +0000 Subject: [PATCH 08/18] Improve Proxy menu accessibility semantics Agent-Logs-Url: https://github.com/Goooler/MihomoForAndroid/sessions/0803198a-2cdb-4086-979b-3f9cab618f5c Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../com/github/kr328/clash/design/ProxyDesign.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 98252751e9..ef4d559681 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -25,6 +25,8 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator @@ -56,6 +58,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -526,7 +529,10 @@ private fun ProxyMenuSection(title: String, content: @Composable () -> Unit) { @Composable private fun ProxyMenuCheckboxRow(title: String, checked: Boolean, onClick: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp), + modifier = + Modifier.fillMaxWidth() + .toggleable(value = checked, onValueChange = { onClick() }, role = Role.Checkbox) + .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Checkbox(checked = checked, onCheckedChange = null) @@ -538,7 +544,10 @@ private fun ProxyMenuCheckboxRow(title: String, checked: Boolean, onClick: () -> @Composable private fun ProxyMenuRadioRow(title: String, selected: Boolean, onClick: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp), + modifier = + Modifier.fillMaxWidth() + .selectable(selected = selected, onClick = onClick, role = Role.RadioButton) + .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { RadioButton(selected = selected, onClick = null) From 4bdd23e58cf5e9c92014312769a45a3992822a4e Mon Sep 17 00:00:00 2001 From: Zongle Wang Date: Tue, 21 Apr 2026 13:07:59 +0800 Subject: [PATCH 09/18] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../kr328/clash/design/AccessControlDesign.kt | 3 +++ .../github/kr328/clash/design/ProxyDesign.kt | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt index e6f954c1ad..e736a50d8a 100644 --- a/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/AccessControlDesign.kt @@ -189,14 +189,17 @@ private fun AccessControlScreen( onExport() }, onUpdateSort = { + sort = it showMenu = false onUpdateSort(it) }, onUpdateReverse = { + reverse = it showMenu = false onUpdateReverse(it) }, onUpdateShowSystemApps = { + showSystemApps = it showMenu = false onUpdateShowSystemApps(it) }, diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index ef4d559681..54c2cee553 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -132,6 +132,7 @@ class ProxyDesign( proxyLine = line uiStore.proxyLine = line config.proxyLine = line + groups.forEach { it.refresh() } requests.trySend(Request.ReloadAll) }, onProxySortChanged = { sort -> @@ -173,7 +174,7 @@ class ProxyDesign( } suspend fun requestRedrawVisible() = - withContext(Dispatchers.Main) { groups.forEach(ProxyGroupUiState::refresh) } + withContext(Dispatchers.Main) { groups.getOrNull(currentPage)?.refresh() } suspend fun showModeSwitchTips() = withContext(Dispatchers.Main) { @@ -251,10 +252,22 @@ private fun ProxyScreen( excludeNotSelectable = excludeNotSelectable, proxyLine = proxyLine, proxySort = proxySort, - onExcludeNotSelectableChanged = onExcludeNotSelectableChanged, - onProxyLineChanged = onProxyLineChanged, - onProxySortChanged = onProxySortChanged, - onOverrideModeSelected = onOverrideModeSelected, + onExcludeNotSelectableChanged = { + menuVisible = false + onExcludeNotSelectableChanged(it) + }, + onProxyLineChanged = { + menuVisible = false + onProxyLineChanged(it) + }, + onProxySortChanged = { + menuVisible = false + onProxySortChanged(it) + }, + onOverrideModeSelected = { + menuVisible = false + onOverrideModeSelected(it) + }, ) } } From a3ee6044c1af10d2800f30d709d456af814455b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:16:55 +0000 Subject: [PATCH 10/18] Optimize Proxy group refresh to visible-item updates Agent-Logs-Url: https://github.com/Goooler/MihomoForAndroid/sessions/51f03a89-2494-40cd-a017-9c1ffba7fbbb Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../github/kr328/clash/design/ProxyDesign.kt | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 54c2cee553..6cd3305faf 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -166,6 +166,7 @@ class ProxyDesign( withContext(Dispatchers.Main) { groups[position].apply { rawStates = states + items = emptyList() this.selectable = selectable urlTesting = false refresh() @@ -205,20 +206,10 @@ private class ProxyGroupUiState { var selectable by mutableStateOf(false) var urlTesting by mutableStateOf(false) var rawStates: List = emptyList() + var refreshVersion by mutableIntStateOf(0) fun refresh() { - items = rawStates.map { state -> - state.update(true) - - ProxyItemUiState( - key = state.proxy.name, - title = state.title, - subtitle = state.subtitle, - delayText = state.delayText, - background = state.background, - controls = state.controls, - ) - } + refreshVersion++ } } @@ -373,6 +364,9 @@ private fun ProxyGroupPage( group: ProxyGroupUiState, onProxySelected: (Int, String) -> Unit, ) { + val refreshVersion = group.refreshVersion + val useRawStates = remember(refreshVersion, group.rawStates) { group.rawStates.isNotEmpty() } + LazyVerticalGrid( columns = GridCells.Fixed(columnsForProxyLine(proxyLine)), state = rememberLazyGridState(), @@ -381,8 +375,31 @@ private fun ProxyGroupPage( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - items(count = group.items.size, key = { group.items[it].key }) { itemIndex -> - val item = group.items[itemIndex] + items( + count = if (useRawStates) group.rawStates.size else group.items.size, + key = { itemIndex -> + if (useRawStates) { + group.rawStates[itemIndex].proxy.name + } else { + group.items[itemIndex].key + } + }, + ) { itemIndex -> + val item = + if (useRawStates) { + group.rawStates[itemIndex].apply { update(true) }.run { + ProxyItemUiState( + key = proxy.name, + title = title, + subtitle = subtitle, + delayText = delayText, + background = background, + controls = controls, + ) + } + } else { + group.items[itemIndex] + } ProxyItemCard( item = item, From 7ae088640ca9eec094e0544a447c86fd20c88689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:17:47 +0000 Subject: [PATCH 11/18] Cache visible proxy item updates per refresh cycle Agent-Logs-Url: https://github.com/Goooler/MihomoForAndroid/sessions/51f03a89-2494-40cd-a017-9c1ffba7fbbb Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../github/kr328/clash/design/ProxyDesign.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 6cd3305faf..7db5fee6d7 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -365,7 +365,7 @@ private fun ProxyGroupPage( onProxySelected: (Int, String) -> Unit, ) { val refreshVersion = group.refreshVersion - val useRawStates = remember(refreshVersion, group.rawStates) { group.rawStates.isNotEmpty() } + val useRawStates = group.rawStates.isNotEmpty() LazyVerticalGrid( columns = GridCells.Fixed(columnsForProxyLine(proxyLine)), @@ -387,15 +387,17 @@ private fun ProxyGroupPage( ) { itemIndex -> val item = if (useRawStates) { - group.rawStates[itemIndex].apply { update(true) }.run { - ProxyItemUiState( - key = proxy.name, - title = title, - subtitle = subtitle, - delayText = delayText, - background = background, - controls = controls, - ) + remember(refreshVersion, itemIndex) { + group.rawStates[itemIndex].apply { update(true) }.run { + ProxyItemUiState( + key = proxy.name, + title = title, + subtitle = subtitle, + delayText = delayText, + background = background, + controls = controls, + ) + } } } else { group.items[itemIndex] From d798b8613aae71c9c95a30f0d2c14261a5df161a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:18:22 +0000 Subject: [PATCH 12/18] Stabilize Proxy grid item source snapshot Agent-Logs-Url: https://github.com/Goooler/MihomoForAndroid/sessions/51f03a89-2494-40cd-a017-9c1ffba7fbbb Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../com/github/kr328/clash/design/ProxyDesign.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 7db5fee6d7..0aa98b5dfe 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -365,7 +365,9 @@ private fun ProxyGroupPage( onProxySelected: (Int, String) -> Unit, ) { val refreshVersion = group.refreshVersion - val useRawStates = group.rawStates.isNotEmpty() + val rawStates = group.rawStates + val previewItems = group.items + val useRawStates = rawStates.isNotEmpty() LazyVerticalGrid( columns = GridCells.Fixed(columnsForProxyLine(proxyLine)), @@ -376,19 +378,19 @@ private fun ProxyGroupPage( verticalArrangement = Arrangement.spacedBy(12.dp), ) { items( - count = if (useRawStates) group.rawStates.size else group.items.size, + count = if (useRawStates) rawStates.size else previewItems.size, key = { itemIndex -> if (useRawStates) { - group.rawStates[itemIndex].proxy.name + rawStates[itemIndex].proxy.name } else { - group.items[itemIndex].key + previewItems[itemIndex].key } }, ) { itemIndex -> val item = if (useRawStates) { remember(refreshVersion, itemIndex) { - group.rawStates[itemIndex].apply { update(true) }.run { + rawStates[itemIndex].apply { update(true) }.run { ProxyItemUiState( key = proxy.name, title = title, @@ -400,7 +402,7 @@ private fun ProxyGroupPage( } } } else { - group.items[itemIndex] + previewItems[itemIndex] } ProxyItemCard( From d1df817674a1301b0319439f927996a8db0f3c44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:19:03 +0000 Subject: [PATCH 13/18] Key visible proxy cache by proxy identity Agent-Logs-Url: https://github.com/Goooler/MihomoForAndroid/sessions/51f03a89-2494-40cd-a017-9c1ffba7fbbb Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../main/java/com/github/kr328/clash/design/ProxyDesign.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 0aa98b5dfe..c9794da62a 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -166,7 +166,6 @@ class ProxyDesign( withContext(Dispatchers.Main) { groups[position].apply { rawStates = states - items = emptyList() this.selectable = selectable urlTesting = false refresh() @@ -389,8 +388,9 @@ private fun ProxyGroupPage( ) { itemIndex -> val item = if (useRawStates) { - remember(refreshVersion, itemIndex) { - rawStates[itemIndex].apply { update(true) }.run { + val state = rawStates[itemIndex] + remember(refreshVersion, state.proxy.name) { + state.apply { update(true) }.run { ProxyItemUiState( key = proxy.name, title = title, From 52459f59d2e2813722a012351bb4484fb35ed63b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:19:54 +0000 Subject: [PATCH 14/18] Use effect-driven visible proxy UI state updates Agent-Logs-Url: https://github.com/Goooler/MihomoForAndroid/sessions/51f03a89-2494-40cd-a017-9c1ffba7fbbb Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../github/kr328/clash/design/ProxyDesign.kt | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index c9794da62a..a477334093 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -200,6 +200,16 @@ private data class ProxyItemUiState( val controls: Int, ) +private fun ProxyViewState.toUiState() = + ProxyItemUiState( + key = proxy.name, + title = title, + subtitle = subtitle, + delayText = delayText, + background = background, + controls = controls, + ) + private class ProxyGroupUiState { var items by mutableStateOf>(emptyList()) var selectable by mutableStateOf(false) @@ -389,18 +399,14 @@ private fun ProxyGroupPage( val item = if (useRawStates) { val state = rawStates[itemIndex] - remember(refreshVersion, state.proxy.name) { - state.apply { update(true) }.run { - ProxyItemUiState( - key = proxy.name, - title = title, - subtitle = subtitle, - delayText = delayText, - background = background, - controls = controls, - ) - } + var uiState by remember(state.proxy.name) { mutableStateOf(state.toUiState()) } + + LaunchedEffect(refreshVersion) { + state.update(true) + uiState = state.toUiState() } + + uiState } else { previewItems[itemIndex] } From ef62963fefed639603825cafe6e27265af7031c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:20:33 +0000 Subject: [PATCH 15/18] Key remembered proxy item state by refresh version Agent-Logs-Url: https://github.com/Goooler/MihomoForAndroid/sessions/51f03a89-2494-40cd-a017-9c1ffba7fbbb Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../src/main/java/com/github/kr328/clash/design/ProxyDesign.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index a477334093..a677f7c1da 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -399,7 +399,8 @@ private fun ProxyGroupPage( val item = if (useRawStates) { val state = rawStates[itemIndex] - var uiState by remember(state.proxy.name) { mutableStateOf(state.toUiState()) } + var uiState by + remember(refreshVersion, state.proxy.name) { mutableStateOf(state.toUiState()) } LaunchedEffect(refreshVersion) { state.update(true) From 229b4ec76b402fc5445c3d449a2e8d3ad68fa3b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:21:03 +0000 Subject: [PATCH 16/18] Avoid unnecessary remember state recreation Agent-Logs-Url: https://github.com/Goooler/MihomoForAndroid/sessions/51f03a89-2494-40cd-a017-9c1ffba7fbbb Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../src/main/java/com/github/kr328/clash/design/ProxyDesign.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index a677f7c1da..a477334093 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -399,8 +399,7 @@ private fun ProxyGroupPage( val item = if (useRawStates) { val state = rawStates[itemIndex] - var uiState by - remember(refreshVersion, state.proxy.name) { mutableStateOf(state.toUiState()) } + var uiState by remember(state.proxy.name) { mutableStateOf(state.toUiState()) } LaunchedEffect(refreshVersion) { state.update(true) From b4fea468401e44c6e9305e9f1622012aee16e9c1 Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 21 Apr 2026 13:29:24 +0800 Subject: [PATCH 17/18] Mark private --- .../src/main/java/com/github/kr328/clash/design/ProxyDesign.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index a477334093..0c1473cd4e 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -181,7 +181,7 @@ class ProxyDesign( Toast.makeText(context, R.string.mode_switch_tips, Toast.LENGTH_LONG).show() } - fun requestUrlTesting() { + private fun requestUrlTesting() { if (groups.isEmpty()) return val page = currentPage.coerceIn(groups.indices) From 306ad35fe73268e62a66a4f1064429bf36fd6726 Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 21 Apr 2026 13:30:15 +0800 Subject: [PATCH 18/18] Fix first frame --- .../src/main/java/com/github/kr328/clash/design/ProxyDesign.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 0c1473cd4e..2d6b24828d 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -399,7 +399,8 @@ private fun ProxyGroupPage( val item = if (useRawStates) { val state = rawStates[itemIndex] - var uiState by remember(state.proxy.name) { mutableStateOf(state.toUiState()) } + var uiState by + remember(state.proxy.name) { mutableStateOf(state.apply { update(true) }.toUiState()) } LaunchedEffect(refreshVersion) { state.update(true)