manager: Modify the batch selection ui on the superuser page

- Add more convenient buttons for it
This commit is contained in:
ShirkNeko
2025-05-16 16:00:51 +08:00
parent f708e583c3
commit 72361ab8bf
5 changed files with 431 additions and 84 deletions

View File

@@ -1,13 +1,15 @@
package com.sukisu.ultra.ui.screen
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -40,7 +42,6 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.sukisu.ultra.Natives import com.sukisu.ultra.Natives
import com.sukisu.ultra.ui.component.SearchAppBar import com.sukisu.ultra.ui.component.SearchAppBar
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
import com.sukisu.ultra.ui.util.ModuleModify import com.sukisu.ultra.ui.util.ModuleModify
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
@@ -68,7 +69,8 @@ fun SuperUserScreen(navigator: DestinationsNavigator) {
LaunchedEffect(viewModel.search) { LaunchedEffect(viewModel.search) {
if (viewModel.search.isEmpty()) { if (viewModel.search.isEmpty()) {
listState.scrollToItem(0) // 取消自动滚动到顶部的行为
// listState.scrollToItem(0)
} }
} }
@@ -165,77 +167,309 @@ fun SuperUserScreen(navigator: DestinationsNavigator) {
}, },
snackbarHost = { SnackbarHost(snackBarHostState) }, snackbarHost = { SnackbarHost(snackBarHostState) },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
bottomBar = { floatingActionButton = {
AnimatedVisibility( // 侧边悬浮按钮集合
visible = viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty(), Column(
enter = slideInVertically(initialOffsetY = { it }), verticalArrangement = Arrangement.spacedBy(8.dp),
exit = slideOutVertically(targetOffsetY = { it }) horizontalAlignment = Alignment.End
) { ) {
Surface( // 批量操作相关按钮
color = MaterialTheme.colorScheme.surfaceContainerHighest, // 只有在批量模式且有选中应用时才显示批量操作按钮
tonalElevation = cardElevation, if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) {
shadowElevation = cardElevation // 取消按钮
) { val cancelInteractionSource = remember { MutableInteractionSource() }
Row( val isCancelPressed by cancelInteractionSource.collectIsPressedAsState()
modifier = Modifier
.fillMaxWidth() FloatingActionButton(
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
OutlinedButton(
onClick = { onClick = {
viewModel.selectedApps = emptySet() viewModel.selectedApps = emptySet()
viewModel.showBatchActions = false viewModel.showBatchActions = false
}, },
colors = ButtonDefaults.outlinedButtonColors( modifier = Modifier.size(if (isCancelPressed) 56.dp else 40.dp),
contentColor = MaterialTheme.colorScheme.primary containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
) contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
shape = CircleShape,
interactionSource = cancelInteractionSource,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Close, imageVector = Icons.Filled.Close,
contentDescription = null, contentDescription = stringResource(android.R.string.cancel),
modifier = Modifier.size(16.dp) modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(android.R.string.cancel))
}
Button(
onClick = {
scope.launch {
viewModel.updateBatchPermissions(true)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
) )
AnimatedVisibility(
visible = isCancelPressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) { ) {
Icon( Text(
imageVector = Icons.Filled.Check, stringResource(android.R.string.cancel),
contentDescription = null, modifier = Modifier.padding(end = 4.dp),
modifier = Modifier.size(16.dp) style = MaterialTheme.typography.labelMedium
) )
Spacer(modifier = Modifier.width(6.dp)) }
Text(stringResource(R.string.batch_authorization)) }
} }
Button( // 取消授权按钮
val unauthorizeInteractionSource = remember { MutableInteractionSource() }
val isUnauthorizePressed by unauthorizeInteractionSource.collectIsPressedAsState()
FloatingActionButton(
onClick = { onClick = {
scope.launch { scope.launch {
viewModel.updateBatchPermissions(false) viewModel.updateBatchPermissions(false)
} }
}, },
colors = ButtonDefaults.buttonColors( modifier = Modifier.size(if (isUnauthorizePressed) 56.dp else 40.dp),
containerColor = MaterialTheme.colorScheme.error containerColor = MaterialTheme.colorScheme.errorContainer,
) contentColor = MaterialTheme.colorScheme.onErrorContainer,
shape = CircleShape,
interactionSource = unauthorizeInteractionSource,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Block, imageVector = Icons.Filled.Block,
contentDescription = null, contentDescription = stringResource(R.string.batch_cancel_authorization),
modifier = Modifier.size(16.dp) modifier = Modifier.size(24.dp)
)
AnimatedVisibility(
visible = isUnauthorizePressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) {
Text(
stringResource(R.string.batch_cancel_authorization),
modifier = Modifier.padding(end = 4.dp),
style = MaterialTheme.typography.labelMedium
)
}
}
}
// 授权按钮
val authorizeInteractionSource = remember { MutableInteractionSource() }
val isAuthorizePressed by authorizeInteractionSource.collectIsPressedAsState()
FloatingActionButton(
onClick = {
scope.launch {
viewModel.updateBatchPermissions(true)
}
},
modifier = Modifier.size(if (isAuthorizePressed) 56.dp else 40.dp),
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shape = CircleShape,
interactionSource = authorizeInteractionSource,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.batch_authorization),
modifier = Modifier.size(24.dp)
)
AnimatedVisibility(
visible = isAuthorizePressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) {
Text(
stringResource(R.string.batch_authorization),
modifier = Modifier.padding(end = 4.dp),
style = MaterialTheme.typography.labelMedium
)
}
}
}
// 添加分隔
Spacer(modifier = Modifier.height(8.dp))
}
if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) {
// 在批量操作按钮组中添加卸载模块的按钮
// 卸载模块启用按钮
val umountEnableInteractionSource = remember { MutableInteractionSource() }
val isUmountEnablePressed by umountEnableInteractionSource.collectIsPressedAsState()
FloatingActionButton(
onClick = {
scope.launch {
viewModel.updateBatchPermissions(
allowSu = false, // 不改变ROOT权限状态
umountModules = true // 启用卸载模块
)
}
},
modifier = Modifier.size(if (isUmountEnablePressed) 56.dp else 40.dp),
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
shape = CircleShape,
interactionSource = umountEnableInteractionSource,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.FolderOff,
contentDescription = stringResource(R.string.profile_umount_modules),
modifier = Modifier.size(24.dp)
)
AnimatedVisibility(
visible = isUmountEnablePressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) {
Text(
stringResource(R.string.profile_umount_modules),
modifier = Modifier.padding(end = 4.dp),
style = MaterialTheme.typography.labelMedium
)
}
}
}
// 卸载模块禁用按钮
val umountDisableInteractionSource = remember { MutableInteractionSource() }
val isUmountDisablePressed by umountDisableInteractionSource.collectIsPressedAsState()
FloatingActionButton(
onClick = {
scope.launch {
viewModel.updateBatchPermissions(
allowSu = false, // 不改变ROOT权限状态
umountModules = false // 禁用卸载模块
)
}
},
modifier = Modifier.size(if (isUmountDisablePressed) 56.dp else 40.dp),
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
shape = CircleShape,
interactionSource = umountDisableInteractionSource,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = stringResource(R.string.profile_umount_modules_disable),
modifier = Modifier.size(24.dp)
)
AnimatedVisibility(
visible = isUmountDisablePressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) {
Text(
stringResource(R.string.profile_umount_modules_disable),
modifier = Modifier.padding(end = 4.dp),
style = MaterialTheme.typography.labelMedium
)
}
}
// 添加分隔
Spacer(modifier = Modifier.height(8.dp))
}
}
// 向上导航按钮
val topBtnInteractionSource = remember { MutableInteractionSource() }
val isTopBtnPressed by topBtnInteractionSource.collectIsPressedAsState()
FloatingActionButton(
onClick = {
scope.launch {
listState.animateScrollToItem(0)
}
},
modifier = Modifier.size(if (isTopBtnPressed) 56.dp else 40.dp),
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 1f),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shape = CircleShape,
interactionSource = topBtnInteractionSource,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(R.string.scroll_to_top_description),
modifier = Modifier.size(24.dp)
)
AnimatedVisibility(
visible = isTopBtnPressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) {
Text(
stringResource(R.string.scroll_to_top),
modifier = Modifier.padding(end = 4.dp),
style = MaterialTheme.typography.labelMedium
)
}
}
}
// 向下导航按钮
val bottomBtnInteractionSource = remember { MutableInteractionSource() }
val isBottomBtnPressed by bottomBtnInteractionSource.collectIsPressedAsState()
FloatingActionButton(
onClick = {
scope.launch {
val lastIndex = viewModel.appList.size - 1
if (lastIndex >= 0) {
listState.animateScrollToItem(lastIndex)
}
}
},
modifier = Modifier.size(if (isBottomBtnPressed) 56.dp else 40.dp),
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 1f),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shape = CircleShape,
interactionSource = bottomBtnInteractionSource,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.scroll_to_bottom_description),
modifier = Modifier.size(24.dp)
)
AnimatedVisibility(
visible = isBottomBtnPressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) {
Text(
stringResource(R.string.scroll_to_bottom),
modifier = Modifier.padding(end = 4.dp),
style = MaterialTheme.typography.labelMedium
) )
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.batch_cancel_authorization))
} }
} }
} }
@@ -256,7 +490,7 @@ fun SuperUserScreen(navigator: DestinationsNavigator) {
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = 8.dp, top = 8.dp,
bottom = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) 88.dp else 16.dp bottom = 16.dp
) )
) { ) {
// 获取分组后的应用列表 // 获取分组后的应用列表
@@ -280,7 +514,10 @@ fun SuperUserScreen(navigator: DestinationsNavigator) {
val profile = Natives.getAppProfile(app.packageName, app.uid) val profile = Natives.getAppProfile(app.packageName, app.uid)
val updatedProfile = profile.copy(allowSu = allowSu) val updatedProfile = profile.copy(allowSu = allowSu)
if (Natives.setAppProfile(updatedProfile)) { if (Natives.setAppProfile(updatedProfile)) {
viewModel.fetchAppList() // 不重新获取应用列表,避免滚动位置重置
// viewModel.fetchAppList()
// 仅更新当前应用的配置
viewModel.updateAppProfileLocally(app.packageName, updatedProfile)
} }
} }
}, },
@@ -319,7 +556,10 @@ fun SuperUserScreen(navigator: DestinationsNavigator) {
val profile = Natives.getAppProfile(app.packageName, app.uid) val profile = Natives.getAppProfile(app.packageName, app.uid)
val updatedProfile = profile.copy(allowSu = allowSu) val updatedProfile = profile.copy(allowSu = allowSu)
if (Natives.setAppProfile(updatedProfile)) { if (Natives.setAppProfile(updatedProfile)) {
viewModel.fetchAppList() // 不重新获取应用列表,避免滚动位置重置
// viewModel.fetchAppList()
// 仅更新当前应用的配置
viewModel.updateAppProfileLocally(app.packageName, updatedProfile)
} }
} }
}, },
@@ -358,7 +598,10 @@ fun SuperUserScreen(navigator: DestinationsNavigator) {
val profile = Natives.getAppProfile(app.packageName, app.uid) val profile = Natives.getAppProfile(app.packageName, app.uid)
val updatedProfile = profile.copy(allowSu = allowSu) val updatedProfile = profile.copy(allowSu = allowSu)
if (Natives.setAppProfile(updatedProfile)) { if (Natives.setAppProfile(updatedProfile)) {
viewModel.fetchAppList() // 不重新获取应用列表,避免滚动位置重置
// viewModel.fetchAppList()
// 仅更新当前应用的配置
viewModel.updateAppProfileLocally(app.packageName, updatedProfile)
} }
} }
}, },
@@ -539,9 +782,31 @@ private fun AppItem(
} }
if (!viewModel.showBatchActions) { if (!viewModel.showBatchActions) {
// 开关交互源
val switchInteractionSource = remember { MutableInteractionSource() }
val isSwitchPressed by switchInteractionSource.collectIsPressedAsState()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
AnimatedVisibility(
visible = isSwitchPressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) {
Text(
text = if (app.allowSu) stringResource(R.string.authorized) else stringResource(R.string.unauthorized),
style = MaterialTheme.typography.labelMedium,
color = if (app.allowSu) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(end = 4.dp)
)
}
Switch( Switch(
checked = app.allowSu, checked = app.allowSu,
onCheckedChange = onSwitchChange, onCheckedChange = onSwitchChange,
interactionSource = switchInteractionSource,
colors = SwitchDefaults.colors( colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary, checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary, checkedTrackColor = MaterialTheme.colorScheme.primary,
@@ -551,10 +816,33 @@ private fun AppItem(
uncheckedIconColor = MaterialTheme.colorScheme.surfaceVariant uncheckedIconColor = MaterialTheme.colorScheme.surfaceVariant
) )
) )
}
} else { } else {
// 复选框交互源
val checkboxInteractionSource = remember { MutableInteractionSource() }
val isCheckboxPressed by checkboxInteractionSource.collectIsPressedAsState()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
AnimatedVisibility(
visible = isCheckboxPressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) {
Text(
text = if (isSelected) stringResource(R.string.selected) else stringResource(R.string.select),
style = MaterialTheme.typography.labelMedium,
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(end = 4.dp)
)
}
Checkbox( Checkbox(
checked = isSelected, checked = isSelected,
onCheckedChange = { onToggleSelection() }, onCheckedChange = { onToggleSelection() },
interactionSource = checkboxInteractionSource,
colors = CheckboxDefaults.colors( colors = CheckboxDefaults.colors(
checkedColor = MaterialTheme.colorScheme.primary, checkedColor = MaterialTheme.colorScheme.primary,
uncheckedColor = MaterialTheme.colorScheme.outline uncheckedColor = MaterialTheme.colorScheme.outline
@@ -564,6 +852,7 @@ private fun AppItem(
} }
} }
} }
}
@Composable @Composable
fun LabelText(label: String, backgroundColor: Color) { fun LabelText(label: String, backgroundColor: Color) {

View File

@@ -1,5 +1,6 @@
package com.sukisu.ultra.ui.screen package com.sukisu.ultra.ui.screen
import LabelText
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.widget.Toast import android.widget.Toast

View File

@@ -139,6 +139,43 @@ class SuperUserViewModel : ViewModel() {
fetchAppList() // 刷新列表以显示最新状态 fetchAppList() // 刷新列表以显示最新状态
} }
// 批量更新权限和umount模块设置
suspend fun updateBatchPermissions(allowSu: Boolean, umountModules: Boolean? = null) {
selectedApps.forEach { packageName ->
val app = apps.find { it.packageName == packageName }
app?.let {
val profile = Natives.getAppProfile(packageName, it.uid)
val updatedProfile = profile.copy(
allowSu = allowSu,
umountModules = umountModules ?: profile.umountModules,
nonRootUseDefault = false
)
if (Natives.setAppProfile(updatedProfile)) {
apps = apps.map { app ->
if (app.packageName == packageName) {
app.copy(profile = updatedProfile)
} else {
app
}
}
}
}
}
clearSelection()
showBatchActions = false // 批量操作完成后退出批量模式
fetchAppList() // 刷新列表以显示最新状态
}
// 仅更新本地应用配置,避免重新获取整个列表导致滚动位置重置
fun updateAppProfileLocally(packageName: String, updatedProfile: Natives.Profile) {
apps = apps.map { app ->
if (app.packageName == packageName) {
app.copy(profile = updatedProfile)
} else {
app
}
}
}
suspend fun fetchAppList() { suspend fun fetchAppList() {
isRefreshing = true isRefreshing = true

View File

@@ -347,4 +347,14 @@
<string name="language_follow_system">跟随系统</string> <string name="language_follow_system">跟随系统</string>
<string name="language_changed">语言已更改,重启应用以应用更改</string> <string name="language_changed">语言已更改,重启应用以应用更改</string>
<string name="settings_card_dim">卡片暗度调节</string> <string name="settings_card_dim">卡片暗度调节</string>
<!-- 超级用户相关 -->
<string name="scroll_to_top">顶部</string>
<string name="scroll_to_bottom">底部</string>
<string name="scroll_to_top_description">滚动到顶部</string>
<string name="scroll_to_bottom_description">滚动到底部</string>
<string name="authorized">已授权</string>
<string name="unauthorized">未授权</string>
<string name="selected">已选择</string>
<string name="select">选择</string>
<string name="profile_umount_modules_disable">禁用自定义卸载模块</string>
</resources> </resources>

View File

@@ -351,4 +351,14 @@
<string name="language_follow_system">Follow System</string> <string name="language_follow_system">Follow System</string>
<string name="language_changed">Language changed, restarting to apply changes</string> <string name="language_changed">Language changed, restarting to apply changes</string>
<string name="settings_card_dim">Card Darkness Adjustment</string> <string name="settings_card_dim">Card Darkness Adjustment</string>
<!-- Super User Related -->
<string name="scroll_to_top">Top</string>
<string name="scroll_to_bottom">Bottom</string>
<string name="scroll_to_top_description">Scroll to top</string>
<string name="scroll_to_bottom_description">Scroll to the bottom</string>
<string name="authorized">authorized</string>
<string name="unauthorized">unauthorized</string>
<string name="selected">Selected</string>
<string name="select">option</string>
<string name="profile_umount_modules_disable">Disable custom uninstallation module</string>
</resources> </resources>