Skip to content

Commit 0be493d

Browse files
Aunali321planshim
andauthored
feat: Add patch selection filters (#2956)
Co-authored-by: planshim <100317079+planshim@users.noreply.github.com>
1 parent 8f23114 commit 0be493d

File tree

4 files changed

+280
-12
lines changed

4 files changed

+280
-12
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package app.revanced.manager.ui.component.haptics
2+
3+
import android.view.HapticFeedbackConstants
4+
import androidx.compose.foundation.interaction.MutableInteractionSource
5+
import androidx.compose.material3.CheckboxColors
6+
import androidx.compose.material3.CheckboxDefaults
7+
import androidx.compose.material3.TriStateCheckbox
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.state.ToggleableState
12+
import app.revanced.manager.util.withHapticFeedback
13+
14+
@Composable
15+
fun HapticTriStateCheckbox(
16+
state: ToggleableState,
17+
onClick: (() -> Unit)?,
18+
modifier: Modifier = Modifier,
19+
enabled: Boolean = true,
20+
colors: CheckboxColors = CheckboxDefaults.colors(),
21+
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
22+
) {
23+
TriStateCheckbox(
24+
state = state,
25+
onClick = onClick?.withHapticFeedback(HapticFeedbackConstants.CLOCK_TICK),
26+
modifier = modifier,
27+
enabled = enabled,
28+
colors = colors,
29+
interactionSource = interactionSource
30+
)
31+
}

app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt

Lines changed: 172 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ import androidx.compose.foundation.layout.Arrangement
1515
import androidx.compose.foundation.layout.Column
1616
import androidx.compose.foundation.layout.ExperimentalLayoutApi
1717
import androidx.compose.foundation.layout.FlowRow
18+
import androidx.compose.foundation.layout.Row
19+
import androidx.compose.foundation.layout.Spacer
1820
import androidx.compose.foundation.layout.fillMaxSize
1921
import androidx.compose.foundation.layout.fillMaxWidth
2022
import androidx.compose.foundation.layout.padding
23+
import androidx.compose.foundation.layout.width
2124
import androidx.compose.foundation.lazy.LazyListScope
2225
import androidx.compose.foundation.lazy.LazyListState
2326
import androidx.compose.foundation.lazy.items
@@ -27,13 +30,16 @@ import androidx.compose.material.icons.Icons
2730
import androidx.compose.material.icons.automirrored.filled.ArrowBack
2831
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
2932
import androidx.compose.material.icons.filled.Close
33+
import androidx.compose.material.icons.outlined.Deselect
3034
import androidx.compose.material.icons.outlined.FilterList
3135
import androidx.compose.material.icons.outlined.Restore
3236
import androidx.compose.material.icons.outlined.Save
3337
import androidx.compose.material.icons.outlined.Settings
38+
import androidx.compose.material.icons.outlined.SwapHoriz
3439
import androidx.compose.material.icons.outlined.WarningAmber
3540
import androidx.compose.material3.AlertDialog
3641
import androidx.compose.material3.ExperimentalMaterial3Api
42+
import androidx.compose.material3.HorizontalDivider
3743
import androidx.compose.material3.Icon
3844
import androidx.compose.material3.IconButton
3945
import androidx.compose.material3.ListItem
@@ -60,7 +66,9 @@ import androidx.compose.ui.Modifier
6066
import androidx.compose.ui.draw.alpha
6167
import androidx.compose.ui.draw.rotate
6268
import androidx.compose.ui.res.stringResource
69+
import androidx.compose.ui.state.ToggleableState
6370
import androidx.compose.ui.unit.dp
71+
import androidx.compose.ui.graphics.vector.ImageVector
6472
import androidx.lifecycle.compose.collectAsStateWithLifecycle
6573
import app.revanced.manager.R
6674
import app.revanced.manager.patcher.patch.Option
@@ -74,6 +82,7 @@ import app.revanced.manager.ui.component.SearchBar
7482
import app.revanced.manager.ui.component.haptics.HapticCheckbox
7583
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
7684
import app.revanced.manager.ui.component.haptics.HapticTab
85+
import app.revanced.manager.ui.component.haptics.HapticTriStateCheckbox
7786
import app.revanced.manager.ui.component.patches.OptionItem
7887
import app.revanced.manager.ui.component.patches.SelectionWarningDialog
7988
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
@@ -124,7 +133,39 @@ fun PatchesSelectorScreen(
124133

125134
val patchLazyListStates = remember(bundles) { List(bundles.size) { LazyListState() } }
126135

136+
var showSelectionWarning by rememberSaveable { mutableStateOf(false) }
137+
var showUniversalWarning by rememberSaveable { mutableStateOf(false) }
138+
139+
var pendingScopeAction by remember { mutableStateOf<((Int?) -> Unit)?>(null) }
140+
141+
fun executeScopedAction(action: (Int?) -> Unit) {
142+
if (bundles.size > 1) {
143+
pendingScopeAction = action
144+
} else {
145+
action(bundles.firstOrNull()?.uid)
146+
}
147+
}
148+
149+
pendingScopeAction?.let { action ->
150+
val currentBundle = bundles.getOrNull(pagerState.currentPage) ?: return@let
151+
152+
ScopeDialog(
153+
bundleName = currentBundle.name,
154+
onDismissRequest = { pendingScopeAction = null },
155+
onAllPatches = {
156+
action(null)
157+
pendingScopeAction = null
158+
},
159+
onBundleOnly = {
160+
action(currentBundle.uid)
161+
pendingScopeAction = null
162+
}
163+
)
164+
}
165+
127166
if (showBottomSheet) {
167+
val currentBundle = bundles.getOrNull(pagerState.currentPage)
168+
128169
ModalBottomSheet(
129170
onDismissRequest = {
130171
showBottomSheet = false
@@ -161,6 +202,71 @@ fun PatchesSelectorScreen(
161202
label = { Text(stringResource(R.string.universal)) },
162203
)
163204
}
205+
206+
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
207+
208+
Text(
209+
text = stringResource(R.string.patch_selector_sheet_actions_title),
210+
style = MaterialTheme.typography.headlineSmall,
211+
modifier = Modifier.padding(bottom = 8.dp)
212+
)
213+
214+
fun guardedAction(action: () -> Unit) {
215+
showBottomSheet = false
216+
if (viewModel.selectionWarningEnabled) {
217+
showSelectionWarning = true
218+
} else {
219+
action()
220+
}
221+
}
222+
223+
ActionItem(
224+
icon = Icons.Outlined.Restore,
225+
text = stringResource(R.string.restore_default_selection),
226+
onClick = {
227+
guardedAction {
228+
executeScopedAction { uid ->
229+
viewModel.restoreDefaults(uid)
230+
}
231+
}
232+
}
233+
)
234+
235+
ActionItem(
236+
icon = Icons.Outlined.Deselect,
237+
text = stringResource(R.string.deselect_all),
238+
onClick = {
239+
guardedAction {
240+
executeScopedAction { uid ->
241+
viewModel.deselectAll(bundles, uid)
242+
}
243+
}
244+
}
245+
)
246+
247+
ActionItem(
248+
icon = Icons.Outlined.SwapHoriz,
249+
text = stringResource(R.string.invert_selection),
250+
onClick = {
251+
guardedAction {
252+
executeScopedAction { uid ->
253+
viewModel.invertSelection(bundles, uid)
254+
}
255+
}
256+
}
257+
)
258+
259+
if (bundles.size > 1 && currentBundle != null) {
260+
ActionItem(
261+
icon = Icons.Outlined.Deselect,
262+
text = stringResource(R.string.deselect_all_except, currentBundle.name),
263+
onClick = {
264+
guardedAction {
265+
viewModel.deselectAllExcept(bundles, currentBundle.uid)
266+
}
267+
}
268+
)
269+
}
164270
}
165271
}
166272
}
@@ -191,9 +297,6 @@ fun PatchesSelectorScreen(
191297
)
192298
}
193299

194-
var showSelectionWarning by rememberSaveable { mutableStateOf(false) }
195-
var showUniversalWarning by rememberSaveable { mutableStateOf(false) }
196-
197300
if (showSelectionWarning)
198301
SelectionWarningDialog(onDismiss = { showSelectionWarning = false })
199302

@@ -426,16 +529,38 @@ fun PatchesSelectorScreen(
426529
}
427530
},
428531
text = {
429-
Column(horizontalAlignment = Alignment.CenterHorizontally) {
430-
Text(
431-
text = bundle.name,
432-
style = MaterialTheme.typography.bodyMedium
433-
)
434-
Text(
435-
text = bundle.version.orEmpty(),
436-
style = MaterialTheme.typography.bodySmall,
437-
color = MaterialTheme.colorScheme.onSurfaceVariant
532+
Row(verticalAlignment = Alignment.CenterVertically) {
533+
val selectionState = viewModel.getBundleSelectionState(bundle)
534+
val toggleableState = when (selectionState) {
535+
true -> ToggleableState.On
536+
false -> ToggleableState.Off
537+
null -> ToggleableState.Indeterminate
538+
}
539+
540+
HapticTriStateCheckbox(
541+
state = toggleableState,
542+
onClick = {
543+
when {
544+
viewModel.selectionWarningEnabled -> showSelectionWarning = true
545+
selectionState == false -> viewModel.restoreDefaults(bundle.uid)
546+
else -> viewModel.deselectAll(bundles, bundle.uid)
547+
}
548+
}
438549
)
550+
551+
Spacer(modifier = Modifier.width(4.dp))
552+
553+
Column(horizontalAlignment = Alignment.CenterHorizontally) {
554+
Text(
555+
text = bundle.name,
556+
style = MaterialTheme.typography.bodyMedium
557+
)
558+
Text(
559+
text = bundle.version.orEmpty(),
560+
style = MaterialTheme.typography.bodySmall,
561+
color = MaterialTheme.colorScheme.onSurfaceVariant
562+
)
563+
}
439564
}
440565
},
441566
selectedContentColor = MaterialTheme.colorScheme.primary,
@@ -612,6 +737,41 @@ private fun IncompatiblePatchDialog(
612737
}
613738
)
614739

740+
@Composable
741+
private fun ActionItem(
742+
icon: ImageVector,
743+
text: String,
744+
onClick: () -> Unit
745+
) {
746+
ListItem(
747+
modifier = Modifier.clickable(onClick = onClick),
748+
leadingContent = { Icon(icon, contentDescription = null) },
749+
headlineContent = { Text(text) },
750+
colors = transparentListItemColors
751+
)
752+
}
753+
754+
@Composable
755+
private fun ScopeDialog(
756+
bundleName: String,
757+
onDismissRequest: () -> Unit,
758+
onAllPatches: () -> Unit,
759+
onBundleOnly: () -> Unit
760+
) = AlertDialog(
761+
onDismissRequest = onDismissRequest,
762+
title = { Text(stringResource(R.string.scope_dialog_title)) },
763+
confirmButton = {
764+
TextButton(onClick = onAllPatches) {
765+
Text(stringResource(R.string.scope_all_patches))
766+
}
767+
},
768+
dismissButton = {
769+
TextButton(onClick = onBundleOnly) {
770+
Text(stringResource(R.string.scope_bundle_patches, bundleName))
771+
}
772+
}
773+
)
774+
615775
@OptIn(ExperimentalMaterial3Api::class)
616776
@Composable
617777
private fun OptionsDialog(

app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,74 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
209209
filter = filter xor flag
210210
}
211211

212+
fun getBundleSelectionState(bundle: PatchBundleInfo.Scoped): Boolean? {
213+
val patches = bundle.patchSequence(allowIncompatiblePatches).toList()
214+
if (patches.isEmpty()) return false
215+
216+
val selectedCount = patches.count { isSelected(bundle.uid, it) }
217+
return when (selectedCount) {
218+
patches.size -> true
219+
0 -> false
220+
else -> null
221+
}
222+
}
223+
224+
private suspend fun currentSelection(): PersistentPatchSelection =
225+
customPatchSelection ?: defaultPatchSelection.first()
226+
227+
private suspend fun updateSelection(
228+
update: (PersistentPatchSelection) -> PersistentPatchSelection
229+
) {
230+
hasModifiedSelection = true
231+
customPatchSelection = update(currentSelection())
232+
}
233+
234+
fun deselectAll(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
235+
updateSelection { selection ->
236+
bundles.fold(selection) { acc, bundle ->
237+
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
238+
acc.put(bundle.uid, persistentSetOf())
239+
}
240+
}
241+
}
242+
243+
fun invertSelection(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
244+
updateSelection { selection ->
245+
bundles.fold(selection) { acc, bundle ->
246+
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
247+
248+
val currentSelected = acc[bundle.uid] ?: persistentSetOf()
249+
val inverted = bundle.patchSequence(allowIncompatiblePatches)
250+
.filter { it.name !in currentSelected }
251+
.map { it.name }
252+
.toPersistentSet()
253+
acc.put(bundle.uid, inverted)
254+
}
255+
}
256+
}
257+
258+
fun restoreDefaults(bundleUid: Int?) = viewModelScope.launch {
259+
if (bundleUid == null) {
260+
customPatchSelection = null
261+
hasModifiedSelection = false
262+
return@launch
263+
}
264+
265+
val defaults = defaultPatchSelection.first()
266+
updateSelection { selection ->
267+
selection.put(bundleUid, defaults[bundleUid] ?: persistentSetOf())
268+
}
269+
}
270+
271+
fun deselectAllExcept(bundles: List<PatchBundleInfo.Scoped>, keepBundleUid: Int) = viewModelScope.launch {
272+
updateSelection { selection ->
273+
bundles.fold(selection) { acc, bundle ->
274+
if (bundle.uid == keepBundleUid) return@fold acc
275+
acc.put(bundle.uid, persistentSetOf())
276+
}
277+
}
278+
}
279+
212280
companion object {
213281
const val SHOW_INCOMPATIBLE = 1 // 2^0
214282
const val SHOW_UNIVERSAL = 2 // 2^1

app/src/main/res/values/strings.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,15 @@ It is only compatible with the following version(s): %2$s</string>
329329
<string name="patch_selector_sheet_filter_title">Filter</string>
330330
<string name="patch_selector_sheet_filter_compat_title">Compatibility</string>
331331

332+
<string name="patch_selector_sheet_actions_title">Actions</string>
333+
<string name="restore_default_selection">Restore default selection</string>
334+
<string name="deselect_all">Deselect all</string>
335+
<string name="invert_selection">Invert selection</string>
336+
<string name="deselect_all_except">Deselect all except %s</string>
337+
<string name="scope_dialog_title">Apply to</string>
338+
<string name="scope_all_patches">All patches</string>
339+
<string name="scope_bundle_patches">%s only</string>
340+
332341
<string name="string_option_menu_description">More options</string>
333342
<string name="option_preset_custom_value">Custom value</string>
334343

0 commit comments

Comments
 (0)