mirror of
https://github.com/revanced/revanced-manager
synced 2024-05-14 13:56:57 +02:00
option ui abstractions
This commit is contained in:
parent
ac0a036035
commit
7747f4d6d3
@ -188,6 +188,9 @@ dependencies {
|
||||
// Scrollbars
|
||||
implementation(libs.scrollbars)
|
||||
|
||||
// Reorderable lists
|
||||
implementation(libs.reorderable)
|
||||
|
||||
// Compose Icons
|
||||
implementation(libs.compose.icons.fontawesome)
|
||||
}
|
||||
|
@ -1,204 +1,346 @@
|
||||
package app.revanced.manager.ui.component.patches
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
||||
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.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.mutableStateListOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.Dialog as ComposeDialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.data.platform.Filesystem
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.util.saver.snapshotStateListSaver
|
||||
import app.revanced.manager.util.toast
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyColumnState
|
||||
|
||||
// Composable functions do not support function references, so we have to use composable lambdas instead.
|
||||
private typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit
|
||||
|
||||
@Composable
|
||||
private fun OptionListItem(
|
||||
option: Option,
|
||||
onClick: () -> Unit,
|
||||
trailingContent: @Composable () -> Unit
|
||||
private class OptionEditorScope<T>(
|
||||
val option: Option,
|
||||
val openDialog: () -> Unit,
|
||||
val dismissDialog: () -> Unit,
|
||||
val value: T?,
|
||||
val setValue: (T?) -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
headlineContent = { Text(option.title) },
|
||||
supportingContent = { Text(option.description) },
|
||||
trailingContent = trailingContent
|
||||
)
|
||||
fun dialogSubmit(value: T) {
|
||||
setValue(value)
|
||||
dismissDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@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())
|
||||
}
|
||||
private interface OptionEditor<T> {
|
||||
fun clickAction(scope: OptionEditorScope<T>) = scope.openDialog()
|
||||
|
||||
val fs: Filesystem = koinInject()
|
||||
val (contract, permissionName) = fs.permissionContract()
|
||||
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
|
||||
showFileDialog = it
|
||||
}
|
||||
|
||||
if (showFileDialog) {
|
||||
PathSelectorDialog(
|
||||
root = fs.externalFilesDir()
|
||||
) {
|
||||
showFileDialog = false
|
||||
it?.let { path ->
|
||||
fieldValue = path.toString()
|
||||
}
|
||||
@Composable
|
||||
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
|
||||
IconButton(onClick = scope.openDialog) {
|
||||
Icon(
|
||||
Icons.Outlined.Edit,
|
||||
contentDescription = stringResource(R.string.edit)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(name) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.dialog_input_placeholder))
|
||||
},
|
||||
trailingIcon = {
|
||||
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
|
||||
IconButton(
|
||||
onClick = { showDropdownMenu = true }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.MoreVert,
|
||||
contentDescription = stringResource(R.string.string_option_menu_description)
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
fun Dialog(scope: OptionEditorScope<T>)
|
||||
}
|
||||
|
||||
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)
|
||||
private object StringOptionEditor : OptionEditor<String> {
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<String>) {
|
||||
var showFileDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var fieldValue by rememberSaveable(scope.value) {
|
||||
mutableStateOf(scope.value.orEmpty())
|
||||
}
|
||||
|
||||
val fs: Filesystem = koinInject()
|
||||
val (contract, permissionName) = fs.permissionContract()
|
||||
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
|
||||
showFileDialog = it
|
||||
}
|
||||
|
||||
if (showFileDialog) {
|
||||
PathSelectorDialog(
|
||||
root = fs.externalFilesDir()
|
||||
) {
|
||||
showFileDialog = false
|
||||
it?.let { path ->
|
||||
fieldValue = path.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = scope.dismissDialog,
|
||||
title = { Text(scope.option.title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.dialog_input_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 = { scope.dialogSubmit(fieldValue) }) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = scope.dismissDialog) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object BooleanOptionEditor : OptionEditor<Boolean> {
|
||||
override fun clickAction(scope: OptionEditorScope<Boolean>) {
|
||||
scope.setValue(!scope.current)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) {
|
||||
Switch(checked = scope.current, onCheckedChange = scope.setValue)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<Boolean>) {
|
||||
}
|
||||
|
||||
private val OptionEditorScope<Boolean>.current get() = value ?: false
|
||||
}
|
||||
|
||||
private class ArrayOptionEditor<T>(private val elementEditor: OptionEditor<T>) :
|
||||
OptionEditor<Array<T>> {
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<Array<T>>) {
|
||||
val items: SnapshotStateList<T?> =
|
||||
rememberSaveable(scope.value, saver = snapshotStateListSaver()) {
|
||||
scope.value?.let { mutableStateListOf(*it) } ?: mutableStateListOf()
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableLazyColumnState =
|
||||
rememberReorderableLazyColumnState(lazyListState) { from, to ->
|
||||
// Update the list
|
||||
items.add(to.index, items.removeAt(from.index))
|
||||
}
|
||||
|
||||
ComposeDialog(
|
||||
onDismissRequest = scope.dismissDialog,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true
|
||||
),
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = scope.option.title,
|
||||
onBackClick = scope.dismissDialog,
|
||||
backIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.close)
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
items.add(null)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Add,
|
||||
stringResource(R.string.add)
|
||||
)
|
||||
}
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
TODO("implement deletion")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.DeleteOutline,
|
||||
stringResource(R.string.delete)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
scope.dialogSubmit(items.toTypedArray<Any?>() as Array<T>)
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Save, stringResource(R.string.save))
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
ReorderableItem(reorderableLazyColumnState, key = index) {
|
||||
OptionListItem(
|
||||
scope.option,
|
||||
elementEditor,
|
||||
value = item,
|
||||
setValue = { items[index] = it as T },
|
||||
headlineContent = { Text(item.toString()) }, // TODO: improve this.
|
||||
supportingContent = null,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.DragHandle,
|
||||
null,
|
||||
modifier = Modifier.draggableHandle()
|
||||
) // TODO: accessibility description
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSubmit(fieldValue) }) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val unknownOption: OptionImpl = { option, _, _ ->
|
||||
val context = LocalContext.current
|
||||
OptionListItem(
|
||||
option = option,
|
||||
onClick = { context.toast("Unknown type: ${option.type}") },
|
||||
trailingContent = {})
|
||||
}
|
||||
|
||||
private val optionImplementations = mapOf<String, OptionImpl>(
|
||||
// These are the only two types that are currently used by the official patches
|
||||
"Boolean" to { option, value, setValue ->
|
||||
val current = (value as? Boolean) ?: false
|
||||
|
||||
OptionListItem(
|
||||
option = option,
|
||||
onClick = { setValue(!current) }
|
||||
) {
|
||||
Switch(checked = current, onCheckedChange = setValue)
|
||||
}
|
||||
},
|
||||
"String" to { 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.edit)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object UnknownTypeEditor : OptionEditor<Any>, KoinComponent {
|
||||
override fun clickAction(scope: OptionEditorScope<Any>) =
|
||||
get<Application>().toast("Unknown type: ${scope.option.type}")
|
||||
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<Any>) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> OptionListItem(
|
||||
option: Option,
|
||||
editor: OptionEditor<T>,
|
||||
value: Any?,
|
||||
setValue: (Any?) -> Unit,
|
||||
headlineContent: @Composable () -> Unit = { Text(option.title) },
|
||||
supportingContent: (@Composable () -> Unit)? = { Text(option.description) },
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val scope = OptionEditorScope(
|
||||
option,
|
||||
openDialog = { showDialog = true },
|
||||
dismissDialog = { showDialog = false },
|
||||
value as T?,
|
||||
setValue = setValue,
|
||||
)
|
||||
|
||||
if (showDialog)
|
||||
editor.Dialog(scope)
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = { editor.clickAction(scope) }),
|
||||
leadingContent = leadingContent,
|
||||
headlineContent = headlineContent,
|
||||
supportingContent = supportingContent,
|
||||
trailingContent = { editor.ListItemTrailingContent(scope) }
|
||||
)
|
||||
}
|
||||
|
||||
private val optionEditors = mapOf<String, OptionEditor<*>>(
|
||||
"Boolean" to BooleanOptionEditor,
|
||||
"String" to StringOptionEditor,
|
||||
"StringArray" to ArrayOptionEditor(StringOptionEditor),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
|
||||
val implementation = remember(option.type) {
|
||||
optionImplementations.getOrDefault(
|
||||
option.type,
|
||||
unknownOption
|
||||
)
|
||||
val editor = remember(option.type) {
|
||||
optionEditors.getOrDefault(option.type, UnknownTypeEditor)
|
||||
}
|
||||
|
||||
implementation(option, value, setValue)
|
||||
OptionListItem(option, editor, value, setValue)
|
||||
}
|
@ -11,6 +11,7 @@ work-runtime = "2.9.0"
|
||||
compose-bom = "2024.03.00"
|
||||
accompanist = "0.34.0"
|
||||
placeholder = "1.1.2"
|
||||
reorderable = "1.5.2"
|
||||
serialization = "1.6.3"
|
||||
collection = "0.3.7"
|
||||
room-version = "2.6.1"
|
||||
@ -120,6 +121,9 @@ libsu-nio = { group = "com.github.topjohnwu.libsu", name = "nio", version.ref =
|
||||
# Scrollbars
|
||||
scrollbars = { group = "com.github.GIGAMOLE", name = "ComposeScrollbars", version.ref = "scrollbars" }
|
||||
|
||||
# Reorderable lists
|
||||
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
|
||||
|
||||
# Compose Icons
|
||||
# switch to br.com.devsrsouza.compose.icons after DevSrSouza/compose-icons#30 is merged
|
||||
compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", name = "font-awesome", version.ref = "compose-icons" }
|
||||
|
Loading…
x
Reference in New Issue
Block a user