From 7aea9473dee6283b76482a2f1699a7c7c6399938 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sat, 12 Aug 2023 10:41:22 +0200 Subject: [PATCH] feat: patch options UI (#80) --- .../manager/ui/component/AppScaffold.kt | 12 +- .../ui/component/patches/OptionFields.kt | 185 +++++++++++++++--- .../component/patches/PathSelectorDialog.kt | 112 +++++++---- .../ui/screen/PatchesSelectorScreen.kt | 50 ++--- .../ui/viewmodel/PatchesSelectorViewModel.kt | 4 +- app/src/main/res/values/strings.xml | 10 +- 6 files changed, 260 insertions(+), 113 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/AppScaffold.kt b/app/src/main/java/app/revanced/manager/ui/component/AppScaffold.kt index 98872a9e..1785e780 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/AppScaffold.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/AppScaffold.kt @@ -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() } } }, 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 e3d46573..bd822ad0 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,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 = { - if (fs.hasStoragePermission()) { - showFileDialog = true - } else { - permissionLauncher.launch(permissionName) + 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) + } + } + ) + } + } + ) + }, + confirmButton = { + TextButton(onClick = { onSubmit(fieldValue) }) { + Text(stringResource(R.string.save)) } - }) { - Icon(Icons.Filled.FileOpen, null) - Text("Select file or folder") + }, + 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) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/PathSelectorDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/PathSelectorDialog.kt index 356493ca..6818da14 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patches/PathSelectorDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patches/PathSelectorDialog.kt @@ -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) } + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index d7849c55..ee758b14 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -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?, - 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 - val value = - if (values == null || !values.contains(key)) it.defaultValue else values[key] + if (patch.options == null) return@LazyColumn - 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) { - Text(stringResource(R.string.apply)) + OptionItem(option = option, value = value, setValue = { set(key, it) }) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index 407520f1..8090c307 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -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() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ad09c51..eaf954d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,6 +90,7 @@ Options OK + Reset Patch Select from storage Search @@ -156,7 +157,14 @@ An error occurred Already downloaded - Select file + Edit + More options + Value + + Select from storage + Previous directory + Directories + Files Show password Hide password