mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2026-03-13 08:41:57 +08:00
feat: Add patch selection filters (#2956)
Co-authored-by: planshim <100317079+planshim@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user