mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2024-11-10 01:01:56 +01:00
feat: patch options UI (#80)
This commit is contained in:
parent
3f059d7748
commit
7aea9473de
6 changed files with 260 additions and 113 deletions
|
@ -36,6 +36,13 @@ fun AppScaffold(
|
|||
fun AppTopBar(
|
||||
title: String,
|
||||
onBackClick: (() -> Unit)? = null,
|
||||
backIcon: @Composable (() -> Unit) = @Composable {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack, contentDescription = stringResource(
|
||||
R.string.back
|
||||
)
|
||||
)
|
||||
},
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
|
@ -47,10 +54,7 @@ fun AppTopBar(
|
|||
navigationIcon = {
|
||||
if (onBackClick != null) {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
backIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,38 +1,71 @@
|
|||
package app.revanced.manager.ui.component.patches
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FileOpen
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.data.platform.FileSystem
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.util.toast
|
||||
import app.revanced.patcher.patch.PatchOption
|
||||
import org.koin.compose.rememberKoinInject
|
||||
|
||||
/**
|
||||
* [Composable] functions do not support function references, so we have to use composable lambdas instead.
|
||||
*/
|
||||
private typealias OptionField = @Composable (Any?, (Any?) -> Unit) -> Unit
|
||||
// Composable functions do not support function references, so we have to use composable lambdas instead.
|
||||
private typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit
|
||||
|
||||
private val StringField: OptionField = { value, setValue ->
|
||||
val fs: FileSystem = rememberKoinInject()
|
||||
@Composable
|
||||
private fun OptionListItem(
|
||||
option: Option,
|
||||
onClick: () -> Unit,
|
||||
trailingContent: @Composable () -> Unit
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
headlineContent = { Text(option.title) },
|
||||
supportingContent = { Text(option.description) },
|
||||
trailingContent = trailingContent
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StringOptionDialog(
|
||||
name: String,
|
||||
value: String?,
|
||||
onSubmit: (String) -> Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
var showFileDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var fieldValue by rememberSaveable(value) {
|
||||
mutableStateOf(value.orEmpty())
|
||||
}
|
||||
|
||||
val fs: FileSystem = rememberKoinInject()
|
||||
val (contract, permissionName) = fs.permissionContract()
|
||||
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
|
||||
showFileDialog = it
|
||||
}
|
||||
val current = value as? String
|
||||
|
||||
if (showFileDialog) {
|
||||
PathSelectorDialog(
|
||||
|
@ -40,45 +73,133 @@ private val StringField: OptionField = { value, setValue ->
|
|||
) {
|
||||
showFileDialog = false
|
||||
it?.let { path ->
|
||||
setValue(path.toString())
|
||||
fieldValue = path.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
TextField(value = current ?: "", onValueChange = setValue)
|
||||
Button(onClick = {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(name) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.string_option_placeholder))
|
||||
},
|
||||
trailingIcon = {
|
||||
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
|
||||
IconButton(
|
||||
onClick = { showDropdownMenu = true }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.MoreVert,
|
||||
contentDescription = stringResource(R.string.string_option_menu_description)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropdownMenu,
|
||||
onDismissRequest = { showDropdownMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(Icons.Outlined.Folder, null)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.path_selector))
|
||||
},
|
||||
onClick = {
|
||||
showDropdownMenu = false
|
||||
if (fs.hasStoragePermission()) {
|
||||
showFileDialog = true
|
||||
} else {
|
||||
permissionLauncher.launch(permissionName)
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.FileOpen, null)
|
||||
Text("Select file or folder")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSubmit(fieldValue) }) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val StringOption: OptionImpl = { option, value, setValue ->
|
||||
var showInputDialog by rememberSaveable { mutableStateOf(false) }
|
||||
fun showInputDialog() {
|
||||
showInputDialog = true
|
||||
}
|
||||
|
||||
fun dismissInputDialog() {
|
||||
showInputDialog = false
|
||||
}
|
||||
|
||||
if (showInputDialog) {
|
||||
StringOptionDialog(
|
||||
name = option.title,
|
||||
value = value as? String,
|
||||
onSubmit = {
|
||||
dismissInputDialog()
|
||||
setValue(it)
|
||||
},
|
||||
onDismissRequest = ::dismissInputDialog
|
||||
)
|
||||
}
|
||||
|
||||
OptionListItem(
|
||||
option = option,
|
||||
onClick = ::showInputDialog
|
||||
) {
|
||||
IconButton(onClick = ::showInputDialog) {
|
||||
Icon(
|
||||
Icons.Outlined.Edit,
|
||||
contentDescription = stringResource(R.string.string_option_icon_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val BooleanField: OptionField = { value, setValue ->
|
||||
val current = value as? Boolean
|
||||
Switch(checked = current ?: false, onCheckedChange = setValue)
|
||||
private val BooleanOption: OptionImpl = { option, value, setValue ->
|
||||
val current = (value as? Boolean) ?: false
|
||||
|
||||
OptionListItem(
|
||||
option = option,
|
||||
onClick = { setValue(!current) }
|
||||
) {
|
||||
Switch(checked = current, onCheckedChange = setValue)
|
||||
}
|
||||
}
|
||||
|
||||
private val UnknownField: OptionField = { _, _ ->
|
||||
Text("This type has not been implemented")
|
||||
private val UnknownOption: OptionImpl = { option, _, _ ->
|
||||
val context = LocalContext.current
|
||||
OptionListItem(
|
||||
option = option,
|
||||
onClick = { context.toast("Unknown type: ${option.type.name}") },
|
||||
trailingContent = {})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OptionField(option: Option, value: Any?, setValue: (Any?) -> Unit) {
|
||||
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
|
||||
val implementation = remember(option.type) {
|
||||
when (option.type) {
|
||||
// These are the only two types that are currently used by the official patches.
|
||||
PatchOption.StringOption::class.java -> StringField
|
||||
PatchOption.BooleanOption::class.java -> BooleanField
|
||||
else -> UnknownField
|
||||
PatchOption.StringOption::class.java -> StringOption
|
||||
PatchOption.BooleanOption::class.java -> BooleanOption
|
||||
else -> UnknownOption
|
||||
}
|
||||
}
|
||||
|
||||
implementation(value, setValue)
|
||||
implementation(option, value, setValue)
|
||||
}
|
|
@ -2,18 +2,20 @@ package app.revanced.manager.ui.component.patches
|
|||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FileOpen
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.DocumentScanner
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.InsertDriveFile
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
@ -21,15 +23,18 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.GroupHeader
|
||||
import app.revanced.manager.util.saver.PathSaver
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.io.path.isDirectory
|
||||
import kotlin.io.path.isRegularFile
|
||||
import kotlin.io.path.isReadable
|
||||
import kotlin.io.path.listDirectoryEntries
|
||||
import kotlin.io.path.name
|
||||
|
||||
|
@ -40,14 +45,8 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
|
|||
val notAtRootDir = remember(currentDirectory) {
|
||||
currentDirectory != root
|
||||
}
|
||||
val everything = remember(currentDirectory) {
|
||||
currentDirectory.listDirectoryEntries()
|
||||
}
|
||||
val directories = remember(everything) {
|
||||
everything.filter { it.isDirectory() }
|
||||
}
|
||||
val files = remember(everything) {
|
||||
everything.filter { it.isRegularFile() }
|
||||
val (directories, files) = remember(currentDirectory) {
|
||||
currentDirectory.listDirectoryEntries().filter(Path::isReadable).partition(Path::isDirectory)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
|
@ -60,51 +59,78 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
|
|||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.select_file),
|
||||
onBackClick = { onSelect(null) }
|
||||
)
|
||||
title = stringResource(R.string.path_selector),
|
||||
onBackClick = { onSelect(null) },
|
||||
backIcon = {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
|
||||
}
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
BackHandler(enabled = notAtRootDir) {
|
||||
currentDirectory = currentDirectory.parent
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
Text(text = currentDirectory.toString())
|
||||
Row(
|
||||
modifier = Modifier.clickable { onSelect(currentDirectory) }
|
||||
) {
|
||||
Text("(Use this directory)")
|
||||
item(key = "current") {
|
||||
PathItem(
|
||||
onClick = { onSelect(currentDirectory) },
|
||||
icon = Icons.Outlined.Folder,
|
||||
name = currentDirectory.toString()
|
||||
)
|
||||
}
|
||||
|
||||
if (notAtRootDir) {
|
||||
Row(
|
||||
modifier = Modifier.clickable { currentDirectory = currentDirectory.parent }
|
||||
) {
|
||||
Text("Previous directory")
|
||||
item(key = "parent") {
|
||||
PathItem(
|
||||
onClick = { currentDirectory = currentDirectory.parent },
|
||||
icon = Icons.Outlined.ArrowBack,
|
||||
name = stringResource(R.string.path_selector_parent_dir)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
directories.forEach {
|
||||
Row(
|
||||
modifier = Modifier.clickable { currentDirectory = it }
|
||||
) {
|
||||
Icon(Icons.Filled.Folder, null)
|
||||
Text(text = it.name)
|
||||
if (directories.isNotEmpty()) {
|
||||
item(key = "dirs_header") {
|
||||
GroupHeader(title = stringResource(R.string.path_selector_dirs))
|
||||
}
|
||||
}
|
||||
files.forEach {
|
||||
Row(
|
||||
modifier = Modifier.clickable { onSelect(it) }
|
||||
) {
|
||||
Icon(Icons.Filled.FileOpen, null)
|
||||
Text(text = it.name)
|
||||
items(directories, key = { it.absolutePathString() }) {
|
||||
PathItem(
|
||||
onClick = { currentDirectory = it },
|
||||
icon = Icons.Outlined.Folder,
|
||||
name = it.name
|
||||
)
|
||||
}
|
||||
|
||||
if (files.isNotEmpty()) {
|
||||
item(key = "files_header") {
|
||||
GroupHeader(title = stringResource(R.string.path_selector_files))
|
||||
}
|
||||
}
|
||||
items(files, key = { it.absolutePathString() }) {
|
||||
PathItem(
|
||||
onClick = { onSelect(it) },
|
||||
icon = Icons.Outlined.InsertDriveFile,
|
||||
name = it.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PathItem(
|
||||
onClick: () -> Unit,
|
||||
icon: ImageVector,
|
||||
name: String
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
headlineContent = { Text(name) },
|
||||
leadingContent = { Icon(icon, contentDescription = null) }
|
||||
)
|
||||
}
|
|
@ -13,15 +13,13 @@ import androidx.compose.foundation.lazy.LazyListScope
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
import androidx.compose.material.icons.outlined.Restore
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
|
@ -49,7 +47,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import app.revanced.manager.R
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.patches.OptionField
|
||||
import app.revanced.manager.ui.component.patches.OptionItem
|
||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
|
||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
|
||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
|
||||
|
@ -82,8 +80,8 @@ fun PatchesSelectorScreen(
|
|||
onDismissRequest = vm::dismissDialogs,
|
||||
patch = patch,
|
||||
values = vm.getOptions(bundle, patch),
|
||||
set = { key, value -> vm.setOption(bundle, patch, key, value) },
|
||||
unset = { vm.unsetOption(bundle, patch, it) }
|
||||
reset = { vm.resetOptions(bundle, patch) },
|
||||
set = { key, value -> vm.setOption(bundle, patch, key, value) }
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -336,7 +334,7 @@ fun UnsupportedDialog(
|
|||
fun OptionsDialog(
|
||||
patch: PatchInfo,
|
||||
values: Map<String, Any?>?,
|
||||
unset: (String) -> Unit,
|
||||
reset: () -> Unit,
|
||||
set: (String, Any?) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
) = Dialog(
|
||||
|
@ -350,36 +348,26 @@ fun OptionsDialog(
|
|||
topBar = {
|
||||
AppTopBar(
|
||||
title = patch.name,
|
||||
onBackClick = onDismissRequest
|
||||
onBackClick = onDismissRequest,
|
||||
actions = {
|
||||
IconButton(onClick = reset) {
|
||||
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
patch.options?.forEach {
|
||||
ListItem(
|
||||
headlineContent = { Text(it.title) },
|
||||
supportingContent = { Text(it.description) },
|
||||
overlineContent = {
|
||||
Button(onClick = { unset(it.key) }) {
|
||||
Text("reset")
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
val key = it.key
|
||||
if (patch.options == null) return@LazyColumn
|
||||
|
||||
items(patch.options, key = { it.key }) { option ->
|
||||
val key = option.key
|
||||
val value =
|
||||
if (values == null || !values.contains(key)) it.defaultValue else values[key]
|
||||
if (values == null || !values.contains(key)) option.defaultValue else values[key]
|
||||
|
||||
OptionField(option = it, value = value, setValue = { set(key, it) })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.apply))
|
||||
OptionItem(option = option, value = value, setValue = { set(key, it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -145,8 +145,8 @@ class PatchesSelectorViewModel(
|
|||
patchOptions.getOrCreate(bundle).getOrCreate(patch.name)[key] = value
|
||||
}
|
||||
|
||||
fun unsetOption(bundle: Int, patch: PatchInfo, key: String) {
|
||||
patchOptions[bundle]?.get(patch.name)?.remove(key)
|
||||
fun resetOptions(bundle: Int, patch: PatchInfo) {
|
||||
patchOptions[bundle]?.remove(patch.name)
|
||||
}
|
||||
|
||||
fun dismissDialogs() {
|
||||
|
|
|
@ -90,6 +90,7 @@
|
|||
|
||||
<string name="options">Options</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="reset">Reset</string>
|
||||
<string name="patch">Patch</string>
|
||||
<string name="select_from_storage">Select from storage</string>
|
||||
<string name="search">Search</string>
|
||||
|
@ -156,7 +157,14 @@
|
|||
<string name="error_occurred">An error occurred</string>
|
||||
<string name="already_downloaded">Already downloaded</string>
|
||||
|
||||
<string name="select_file">Select file</string>
|
||||
<string name="string_option_icon_description">Edit</string>
|
||||
<string name="string_option_menu_description">More options</string>
|
||||
<string name="string_option_placeholder">Value</string>
|
||||
|
||||
<string name="path_selector">Select from storage</string>
|
||||
<string name="path_selector_parent_dir">Previous directory</string>
|
||||
<string name="path_selector_dirs">Directories</string>
|
||||
<string name="path_selector_files">Files</string>
|
||||
|
||||
<string name="show_password_field">Show password</string>
|
||||
<string name="hide_password_field">Hide password</string>
|
||||
|
|
Loading…
Reference in a new issue