option ui abstractions

This commit is contained in:
Ax333l 2024-04-24 18:41:10 +02:00
parent ac0a036035
commit 7747f4d6d3
No known key found for this signature in database
GPG Key ID: D2B4D85271127D23
3 changed files with 296 additions and 147 deletions

View File

@ -188,6 +188,9 @@ dependencies {
// Scrollbars
implementation(libs.scrollbars)
// Reorderable lists
implementation(libs.reorderable)
// Compose Icons
implementation(libs.compose.icons.fontawesome)
}

View File

@ -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)
}

View File

@ -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" }