From 7747f4d6d3a2c2dd913b478777cbf42da436dc10 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Wed, 24 Apr 2024 18:41:10 +0200 Subject: [PATCH] option ui abstractions --- app/build.gradle.kts | 3 + .../ui/component/patches/OptionFields.kt | 436 ++++++++++++------ gradle/libs.versions.toml | 4 + 3 files changed, 296 insertions(+), 147 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e11dd0e6..86509473 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -188,6 +188,9 @@ dependencies { // Scrollbars implementation(libs.scrollbars) + // Reorderable lists + implementation(libs.reorderable) + // Compose Icons implementation(libs.compose.icons.fontawesome) } diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt index a291c46d..46aeb7bb 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt @@ -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( + 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 { + fun clickAction(scope: OptionEditorScope) = 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) { + 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) +} - 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 { + @Composable + override fun Dialog(scope: OptionEditorScope) { + 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 { + override fun clickAction(scope: OptionEditorScope) { + scope.setValue(!scope.current) + } + + @Composable + override fun ListItemTrailingContent(scope: OptionEditorScope) { + Switch(checked = scope.current, onCheckedChange = scope.setValue) + } + + @Composable + override fun Dialog(scope: OptionEditorScope) { + } + + private val OptionEditorScope.current get() = value ?: false +} + +private class ArrayOptionEditor(private val elementEditor: OptionEditor) : + OptionEditor> { + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) + @Composable + override fun Dialog(scope: OptionEditorScope>) { + val items: SnapshotStateList = + 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() as Array) + } + ) { + 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( - // 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, KoinComponent { + override fun clickAction(scope: OptionEditorScope) = + get().toast("Unknown type: ${scope.option.type}") + + @Composable + override fun Dialog(scope: OptionEditorScope) { + } +} + +@Composable +private fun OptionListItem( + option: Option, + editor: OptionEditor, + 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>( + "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) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b78ce77e..e448af17 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }