feat: patch options UI (#80)

This commit is contained in:
Ax333l 2023-08-12 10:41:22 +02:00 committed by GitHub
parent 3f059d7748
commit 7aea9473de
6 changed files with 260 additions and 113 deletions

View File

@ -36,6 +36,13 @@ fun AppScaffold(
fun AppTopBar( fun AppTopBar(
title: String, title: String,
onBackClick: (() -> Unit)? = null, onBackClick: (() -> Unit)? = null,
backIcon: @Composable (() -> Unit) = @Composable {
Icon(
imageVector = Icons.Default.ArrowBack, contentDescription = stringResource(
R.string.back
)
)
},
actions: @Composable (RowScope.() -> Unit) = {}, actions: @Composable (RowScope.() -> Unit) = {},
scrollBehavior: TopAppBarScrollBehavior? = null scrollBehavior: TopAppBarScrollBehavior? = null
) { ) {
@ -47,10 +54,7 @@ fun AppTopBar(
navigationIcon = { navigationIcon = {
if (onBackClick != null) { if (onBackClick != null) {
IconButton(onClick = onBackClick) { IconButton(onClick = onBackClick) {
Icon( backIcon()
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(R.string.back)
)
} }
} }
}, },

View File

@ -1,38 +1,71 @@
package app.revanced.manager.ui.component.patches package app.revanced.manager.ui.component.patches
import androidx.activity.compose.rememberLauncherForActivityResult 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.Icons
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.Button 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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable 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.data.platform.FileSystem
import app.revanced.manager.patcher.patch.Option import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.util.toast
import app.revanced.patcher.patch.PatchOption import app.revanced.patcher.patch.PatchOption
import org.koin.compose.rememberKoinInject import org.koin.compose.rememberKoinInject
/** // Composable functions do not support function references, so we have to use composable lambdas instead.
* [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 typealias OptionField = @Composable (Any?, (Any?) -> Unit) -> Unit
private val StringField: OptionField = { value, setValue -> @Composable
val fs: FileSystem = rememberKoinInject() 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 showFileDialog by rememberSaveable { mutableStateOf(false) }
var fieldValue by rememberSaveable(value) {
mutableStateOf(value.orEmpty())
}
val fs: FileSystem = rememberKoinInject()
val (contract, permissionName) = fs.permissionContract() val (contract, permissionName) = fs.permissionContract()
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) { val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
showFileDialog = it showFileDialog = it
} }
val current = value as? String
if (showFileDialog) { if (showFileDialog) {
PathSelectorDialog( PathSelectorDialog(
@ -40,45 +73,133 @@ private val StringField: OptionField = { value, setValue ->
) { ) {
showFileDialog = false showFileDialog = false
it?.let { path -> it?.let { path ->
setValue(path.toString()) fieldValue = path.toString()
} }
} }
} }
Column { AlertDialog(
TextField(value = current ?: "", onValueChange = setValue) onDismissRequest = onDismissRequest,
Button(onClick = { title = { Text(name) },
if (fs.hasStoragePermission()) { text = {
showFileDialog = true OutlinedTextField(
} else { value = fieldValue,
permissionLauncher.launch(permissionName) 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)
}
}
)
}
}
)
},
confirmButton = {
TextButton(onClick = { onSubmit(fieldValue) }) {
Text(stringResource(R.string.save))
} }
}) { },
Icon(Icons.Filled.FileOpen, null) dismissButton = {
Text("Select file or folder") 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 -> private val BooleanOption: OptionImpl = { option, value, setValue ->
val current = value as? Boolean val current = (value as? Boolean) ?: false
Switch(checked = current ?: false, onCheckedChange = setValue)
OptionListItem(
option = option,
onClick = { setValue(!current) }
) {
Switch(checked = current, onCheckedChange = setValue)
}
} }
private val UnknownField: OptionField = { _, _ -> private val UnknownOption: OptionImpl = { option, _, _ ->
Text("This type has not been implemented") val context = LocalContext.current
OptionListItem(
option = option,
onClick = { context.toast("Unknown type: ${option.type.name}") },
trailingContent = {})
} }
@Composable @Composable
fun OptionField(option: Option, value: Any?, setValue: (Any?) -> Unit) { fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
val implementation = remember(option.type) { val implementation = remember(option.type) {
when (option.type) { when (option.type) {
// These are the only two types that are currently used by the official patches. // These are the only two types that are currently used by the official patches.
PatchOption.StringOption::class.java -> StringField PatchOption.StringOption::class.java -> StringOption
PatchOption.BooleanOption::class.java -> BooleanField PatchOption.BooleanOption::class.java -> BooleanOption
else -> UnknownField else -> UnknownOption
} }
} }
implementation(value, setValue) implementation(option, value, setValue)
} }

View File

@ -2,18 +2,20 @@ package app.revanced.manager.ui.component.patches
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable 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.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Folder 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ListItem
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -21,15 +23,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.util.saver.PathSaver import app.revanced.manager.util.saver.PathSaver
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.isDirectory import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile import kotlin.io.path.isReadable
import kotlin.io.path.listDirectoryEntries import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name import kotlin.io.path.name
@ -40,14 +45,8 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
val notAtRootDir = remember(currentDirectory) { val notAtRootDir = remember(currentDirectory) {
currentDirectory != root currentDirectory != root
} }
val everything = remember(currentDirectory) { val (directories, files) = remember(currentDirectory) {
currentDirectory.listDirectoryEntries() currentDirectory.listDirectoryEntries().filter(Path::isReadable).partition(Path::isDirectory)
}
val directories = remember(everything) {
everything.filter { it.isDirectory() }
}
val files = remember(everything) {
everything.filter { it.isRegularFile() }
} }
Dialog( Dialog(
@ -60,51 +59,78 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
Scaffold( Scaffold(
topBar = { topBar = {
AppTopBar( AppTopBar(
title = stringResource(R.string.select_file), title = stringResource(R.string.path_selector),
onBackClick = { onSelect(null) } onBackClick = { onSelect(null) },
backIcon = {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
}
) )
} },
) { paddingValues -> ) { paddingValues ->
BackHandler(enabled = notAtRootDir) { BackHandler(enabled = notAtRootDir) {
currentDirectory = currentDirectory.parent currentDirectory = currentDirectory.parent
} }
Column( LazyColumn(
modifier = Modifier modifier = Modifier.padding(paddingValues)
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) { ) {
Text(text = currentDirectory.toString()) item(key = "current") {
Row( PathItem(
modifier = Modifier.clickable { onSelect(currentDirectory) } onClick = { onSelect(currentDirectory) },
) { icon = Icons.Outlined.Folder,
Text("(Use this directory)") name = currentDirectory.toString()
)
} }
if (notAtRootDir) { if (notAtRootDir) {
Row( item(key = "parent") {
modifier = Modifier.clickable { currentDirectory = currentDirectory.parent } PathItem(
) { onClick = { currentDirectory = currentDirectory.parent },
Text("Previous directory") icon = Icons.Outlined.ArrowBack,
name = stringResource(R.string.path_selector_parent_dir)
)
} }
} }
directories.forEach { if (directories.isNotEmpty()) {
Row( item(key = "dirs_header") {
modifier = Modifier.clickable { currentDirectory = it } GroupHeader(title = stringResource(R.string.path_selector_dirs))
) {
Icon(Icons.Filled.Folder, null)
Text(text = it.name)
} }
} }
files.forEach { items(directories, key = { it.absolutePathString() }) {
Row( PathItem(
modifier = Modifier.clickable { onSelect(it) } onClick = { currentDirectory = it },
) { icon = Icons.Outlined.Folder,
Icon(Icons.Filled.FileOpen, null) name = it.name
Text(text = 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) }
)
} }

View File

@ -13,15 +13,13 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState 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.Icons
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.outlined.HelpOutline 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.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@ -49,7 +47,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.AppTopBar 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
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
@ -82,8 +80,8 @@ fun PatchesSelectorScreen(
onDismissRequest = vm::dismissDialogs, onDismissRequest = vm::dismissDialogs,
patch = patch, patch = patch,
values = vm.getOptions(bundle, patch), values = vm.getOptions(bundle, patch),
set = { key, value -> vm.setOption(bundle, patch, key, value) }, reset = { vm.resetOptions(bundle, patch) },
unset = { vm.unsetOption(bundle, patch, it) } set = { key, value -> vm.setOption(bundle, patch, key, value) }
) )
} }
@ -336,7 +334,7 @@ fun UnsupportedDialog(
fun OptionsDialog( fun OptionsDialog(
patch: PatchInfo, patch: PatchInfo,
values: Map<String, Any?>?, values: Map<String, Any?>?,
unset: (String) -> Unit, reset: () -> Unit,
set: (String, Any?) -> Unit, set: (String, Any?) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) = Dialog( ) = Dialog(
@ -350,36 +348,26 @@ fun OptionsDialog(
topBar = { topBar = {
AppTopBar( AppTopBar(
title = patch.name, title = patch.name,
onBackClick = onDismissRequest onBackClick = onDismissRequest,
actions = {
IconButton(onClick = reset) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
}
) )
} }
) { paddingValues -> ) { paddingValues ->
Column( LazyColumn(
modifier = Modifier modifier = Modifier.padding(paddingValues)
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) { ) {
patch.options?.forEach { if (patch.options == null) return@LazyColumn
ListItem(
headlineContent = { Text(it.title) },
supportingContent = { Text(it.description) },
overlineContent = {
Button(onClick = { unset(it.key) }) {
Text("reset")
}
},
trailingContent = {
val key = it.key
val value =
if (values == null || !values.contains(key)) it.defaultValue else values[key]
OptionField(option = it, value = value, setValue = { set(key, it) }) items(patch.options, key = { it.key }) { option ->
} val key = option.key
) val value =
} if (values == null || !values.contains(key)) option.defaultValue else values[key]
TextButton(onClick = onDismissRequest) { OptionItem(option = option, value = value, setValue = { set(key, it) })
Text(stringResource(R.string.apply))
} }
} }
} }

View File

@ -145,8 +145,8 @@ class PatchesSelectorViewModel(
patchOptions.getOrCreate(bundle).getOrCreate(patch.name)[key] = value patchOptions.getOrCreate(bundle).getOrCreate(patch.name)[key] = value
} }
fun unsetOption(bundle: Int, patch: PatchInfo, key: String) { fun resetOptions(bundle: Int, patch: PatchInfo) {
patchOptions[bundle]?.get(patch.name)?.remove(key) patchOptions[bundle]?.remove(patch.name)
} }
fun dismissDialogs() { fun dismissDialogs() {

View File

@ -90,6 +90,7 @@
<string name="options">Options</string> <string name="options">Options</string>
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="reset">Reset</string>
<string name="patch">Patch</string> <string name="patch">Patch</string>
<string name="select_from_storage">Select from storage</string> <string name="select_from_storage">Select from storage</string>
<string name="search">Search</string> <string name="search">Search</string>
@ -156,7 +157,14 @@
<string name="error_occurred">An error occurred</string> <string name="error_occurred">An error occurred</string>
<string name="already_downloaded">Already downloaded</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="show_password_field">Show password</string>
<string name="hide_password_field">Hide password</string> <string name="hide_password_field">Hide password</string>