feat: Add patch selection filters (#2956)

Co-authored-by: planshim <100317079+planshim@users.noreply.github.com>
This commit is contained in:
AunAli K.
2026-02-28 09:25:03 +05:30
committed by GitHub
parent 8f23114bc3
commit 0be493da66
4 changed files with 280 additions and 12 deletions

View File

@@ -0,0 +1,31 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.CheckboxColors
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.state.ToggleableState
import app.revanced.manager.util.withHapticFeedback
@Composable
fun HapticTriStateCheckbox(
state: ToggleableState,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: CheckboxColors = CheckboxDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
TriStateCheckbox(
state = state,
onClick = onClick?.withHapticFeedback(HapticFeedbackConstants.CLOCK_TICK),
modifier = modifier,
enabled = enabled,
colors = colors,
interactionSource = interactionSource
)
}

View File

@@ -15,9 +15,12 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
@@ -27,13 +30,16 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Deselect
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SwapHoriz
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
@@ -60,7 +66,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.patcher.patch.Option
@@ -74,6 +82,7 @@ import app.revanced.manager.ui.component.SearchBar
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.haptics.HapticTriStateCheckbox
import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.component.patches.SelectionWarningDialog
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
@@ -124,7 +133,39 @@ fun PatchesSelectorScreen(
val patchLazyListStates = remember(bundles) { List(bundles.size) { LazyListState() } }
var showSelectionWarning by rememberSaveable { mutableStateOf(false) }
var showUniversalWarning by rememberSaveable { mutableStateOf(false) }
var pendingScopeAction by remember { mutableStateOf<((Int?) -> Unit)?>(null) }
fun executeScopedAction(action: (Int?) -> Unit) {
if (bundles.size > 1) {
pendingScopeAction = action
} else {
action(bundles.firstOrNull()?.uid)
}
}
pendingScopeAction?.let { action ->
val currentBundle = bundles.getOrNull(pagerState.currentPage) ?: return@let
ScopeDialog(
bundleName = currentBundle.name,
onDismissRequest = { pendingScopeAction = null },
onAllPatches = {
action(null)
pendingScopeAction = null
},
onBundleOnly = {
action(currentBundle.uid)
pendingScopeAction = null
}
)
}
if (showBottomSheet) {
val currentBundle = bundles.getOrNull(pagerState.currentPage)
ModalBottomSheet(
onDismissRequest = {
showBottomSheet = false
@@ -161,6 +202,71 @@ fun PatchesSelectorScreen(
label = { Text(stringResource(R.string.universal)) },
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
Text(
text = stringResource(R.string.patch_selector_sheet_actions_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 8.dp)
)
fun guardedAction(action: () -> Unit) {
showBottomSheet = false
if (viewModel.selectionWarningEnabled) {
showSelectionWarning = true
} else {
action()
}
}
ActionItem(
icon = Icons.Outlined.Restore,
text = stringResource(R.string.restore_default_selection),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.restoreDefaults(uid)
}
}
}
)
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.deselectAll(bundles, uid)
}
}
}
)
ActionItem(
icon = Icons.Outlined.SwapHoriz,
text = stringResource(R.string.invert_selection),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.invertSelection(bundles, uid)
}
}
}
)
if (bundles.size > 1 && currentBundle != null) {
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all_except, currentBundle.name),
onClick = {
guardedAction {
viewModel.deselectAllExcept(bundles, currentBundle.uid)
}
}
)
}
}
}
}
@@ -191,9 +297,6 @@ fun PatchesSelectorScreen(
)
}
var showSelectionWarning by rememberSaveable { mutableStateOf(false) }
var showUniversalWarning by rememberSaveable { mutableStateOf(false) }
if (showSelectionWarning)
SelectionWarningDialog(onDismiss = { showSelectionWarning = false })
@@ -426,16 +529,38 @@ fun PatchesSelectorScreen(
}
},
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = bundle.name,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = bundle.version.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
Row(verticalAlignment = Alignment.CenterVertically) {
val selectionState = viewModel.getBundleSelectionState(bundle)
val toggleableState = when (selectionState) {
true -> ToggleableState.On
false -> ToggleableState.Off
null -> ToggleableState.Indeterminate
}
HapticTriStateCheckbox(
state = toggleableState,
onClick = {
when {
viewModel.selectionWarningEnabled -> showSelectionWarning = true
selectionState == false -> viewModel.restoreDefaults(bundle.uid)
else -> viewModel.deselectAll(bundles, bundle.uid)
}
}
)
Spacer(modifier = Modifier.width(4.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = bundle.name,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = bundle.version.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
selectedContentColor = MaterialTheme.colorScheme.primary,
@@ -612,6 +737,41 @@ private fun IncompatiblePatchDialog(
}
)
@Composable
private fun ActionItem(
icon: ImageVector,
text: String,
onClick: () -> Unit
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
leadingContent = { Icon(icon, contentDescription = null) },
headlineContent = { Text(text) },
colors = transparentListItemColors
)
}
@Composable
private fun ScopeDialog(
bundleName: String,
onDismissRequest: () -> Unit,
onAllPatches: () -> Unit,
onBundleOnly: () -> Unit
) = AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(R.string.scope_dialog_title)) },
confirmButton = {
TextButton(onClick = onAllPatches) {
Text(stringResource(R.string.scope_all_patches))
}
},
dismissButton = {
TextButton(onClick = onBundleOnly) {
Text(stringResource(R.string.scope_bundle_patches, bundleName))
}
}
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OptionsDialog(

View File

@@ -209,6 +209,74 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
filter = filter xor flag
}
fun getBundleSelectionState(bundle: PatchBundleInfo.Scoped): Boolean? {
val patches = bundle.patchSequence(allowIncompatiblePatches).toList()
if (patches.isEmpty()) return false
val selectedCount = patches.count { isSelected(bundle.uid, it) }
return when (selectedCount) {
patches.size -> true
0 -> false
else -> null
}
}
private suspend fun currentSelection(): PersistentPatchSelection =
customPatchSelection ?: defaultPatchSelection.first()
private suspend fun updateSelection(
update: (PersistentPatchSelection) -> PersistentPatchSelection
) {
hasModifiedSelection = true
customPatchSelection = update(currentSelection())
}
fun deselectAll(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
updateSelection { selection ->
bundles.fold(selection) { acc, bundle ->
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
acc.put(bundle.uid, persistentSetOf())
}
}
}
fun invertSelection(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
updateSelection { selection ->
bundles.fold(selection) { acc, bundle ->
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
val currentSelected = acc[bundle.uid] ?: persistentSetOf()
val inverted = bundle.patchSequence(allowIncompatiblePatches)
.filter { it.name !in currentSelected }
.map { it.name }
.toPersistentSet()
acc.put(bundle.uid, inverted)
}
}
}
fun restoreDefaults(bundleUid: Int?) = viewModelScope.launch {
if (bundleUid == null) {
customPatchSelection = null
hasModifiedSelection = false
return@launch
}
val defaults = defaultPatchSelection.first()
updateSelection { selection ->
selection.put(bundleUid, defaults[bundleUid] ?: persistentSetOf())
}
}
fun deselectAllExcept(bundles: List<PatchBundleInfo.Scoped>, keepBundleUid: Int) = viewModelScope.launch {
updateSelection { selection ->
bundles.fold(selection) { acc, bundle ->
if (bundle.uid == keepBundleUid) return@fold acc
acc.put(bundle.uid, persistentSetOf())
}
}
}
companion object {
const val SHOW_INCOMPATIBLE = 1 // 2^0
const val SHOW_UNIVERSAL = 2 // 2^1

View File

@@ -329,6 +329,15 @@ It is only compatible with the following version(s): %2$s</string>
<string name="patch_selector_sheet_filter_title">Filter</string>
<string name="patch_selector_sheet_filter_compat_title">Compatibility</string>
<string name="patch_selector_sheet_actions_title">Actions</string>
<string name="restore_default_selection">Restore default selection</string>
<string name="deselect_all">Deselect all</string>
<string name="invert_selection">Invert selection</string>
<string name="deselect_all_except">Deselect all except %s</string>
<string name="scope_dialog_title">Apply to</string>
<string name="scope_all_patches">All patches</string>
<string name="scope_bundle_patches">%s only</string>
<string name="string_option_menu_description">More options</string>
<string name="option_preset_custom_value">Custom value</string>