mirror of
https://github.com/revanced/revanced-manager.git
synced 2025-05-02 14:54:25 +02:00
feat: implement more patch option types (#2015)
This commit is contained in:
parent
a22158d070
commit
ec0a077539
@ -188,6 +188,9 @@ dependencies {
|
||||
// Scrollbars
|
||||
implementation(libs.scrollbars)
|
||||
|
||||
// Reorderable lists
|
||||
implementation(libs.reorderable)
|
||||
|
||||
// Compose Icons
|
||||
implementation(libs.compose.icons.fontawesome)
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ package app.revanced.manager.data.room
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import app.revanced.manager.data.room.bundles.Source
|
||||
import io.ktor.http.*
|
||||
import app.revanced.manager.data.room.options.Option.SerializedValue
|
||||
import java.io.File
|
||||
|
||||
class Converters {
|
||||
@ -17,4 +17,10 @@ class Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun fileToString(file: File): String = file.path
|
||||
|
||||
@TypeConverter
|
||||
fun serializedOptionFromString(value: String) = SerializedValue.fromJsonString(value)
|
||||
|
||||
@TypeConverter
|
||||
fun serializedOptionToString(value: SerializedValue) = value.toJsonString()
|
||||
}
|
@ -3,6 +3,23 @@ package app.revanced.manager.data.room.options
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.float
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Entity(
|
||||
tableName = "options",
|
||||
@ -19,5 +36,74 @@ data class Option(
|
||||
@ColumnInfo(name = "patch_name") val patchName: String,
|
||||
@ColumnInfo(name = "key") val key: String,
|
||||
// Encoded as Json.
|
||||
@ColumnInfo(name = "value") val value: String,
|
||||
)
|
||||
@ColumnInfo(name = "value") val value: SerializedValue,
|
||||
) {
|
||||
@Serializable
|
||||
data class SerializedValue(val raw: JsonElement) {
|
||||
fun toJsonString() = json.encodeToString(raw)
|
||||
fun deserializeFor(option: Option<*>): Any? {
|
||||
if (raw is JsonNull) return null
|
||||
|
||||
val errorMessage = "Cannot deserialize value as ${option.type}"
|
||||
try {
|
||||
if (option.type.endsWith("Array")) {
|
||||
val elementType = option.type.removeSuffix("Array")
|
||||
return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) }
|
||||
}
|
||||
|
||||
return deserializeBasicType(option.type, raw.jsonPrimitive)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw SerializationException(errorMessage, e)
|
||||
} catch (e: IllegalStateException) {
|
||||
throw SerializationException(errorMessage, e)
|
||||
} catch (e: kotlinx.serialization.SerializationException) {
|
||||
throw SerializationException(errorMessage, e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val json = Json {
|
||||
// Patcher does not forbid the use of these values, so we should support them.
|
||||
allowSpecialFloatingPointValues = true
|
||||
}
|
||||
|
||||
private fun deserializeBasicType(type: String, value: JsonPrimitive) = when (type) {
|
||||
"Boolean" -> value.boolean
|
||||
"Int" -> value.int
|
||||
"Long" -> value.long
|
||||
"Float" -> value.float
|
||||
"String" -> value.content.also { if (!value.isString) throw SerializationException("Expected value to be a string: $value") }
|
||||
else -> throw SerializationException("Unknown type: $type")
|
||||
}
|
||||
|
||||
fun fromJsonString(value: String) = SerializedValue(json.decodeFromString(value))
|
||||
fun fromValue(value: Any?) = SerializedValue(when (value) {
|
||||
null -> JsonNull
|
||||
is Number -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is String -> JsonPrimitive(value)
|
||||
is List<*> -> buildJsonArray {
|
||||
var elementClass: KClass<out Any>? = null
|
||||
|
||||
value.forEach {
|
||||
when (it) {
|
||||
null -> throw SerializationException("List elements must not be null")
|
||||
is Number -> add(it)
|
||||
is Boolean -> add(it)
|
||||
is String -> add(it)
|
||||
else -> throw SerializationException("Unknown element type: ${it::class.simpleName}")
|
||||
}
|
||||
|
||||
if (elementClass == null) elementClass = it::class
|
||||
else if (elementClass != it::class) throw SerializationException("List elements must have the same type")
|
||||
}
|
||||
}
|
||||
|
||||
else -> throw SerializationException("Unknown type: ${value::class.simpleName}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class SerializationException(message: String, cause: Throwable? = null) :
|
||||
Exception(message, cause)
|
||||
}
|
||||
|
@ -1,18 +1,14 @@
|
||||
package app.revanced.manager.domain.repository
|
||||
|
||||
import android.util.Log
|
||||
import app.revanced.manager.data.room.AppDatabase
|
||||
import app.revanced.manager.data.room.options.Option
|
||||
import app.revanced.manager.data.room.options.OptionGroup
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.tag
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
|
||||
class PatchOptionsRepository(db: AppDatabase) {
|
||||
private val dao = db.optionDao()
|
||||
@ -24,19 +20,37 @@ class PatchOptionsRepository(db: AppDatabase) {
|
||||
packageName = packageName
|
||||
).also { dao.createOptionGroup(it) }.uid
|
||||
|
||||
suspend fun getOptions(packageName: String): Options {
|
||||
suspend fun getOptions(
|
||||
packageName: String,
|
||||
bundlePatches: Map<Int, Map<String, PatchInfo>>
|
||||
): Options {
|
||||
val options = dao.getOptions(packageName)
|
||||
// Bundle -> Patches
|
||||
return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) {
|
||||
options.forEach { (sourceUid, bundlePatchOptionsList) ->
|
||||
// Patches -> Patch options
|
||||
this[sourceUid] = bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, option ->
|
||||
val patchOptions = bundlePatchOptions.getOrPut(option.patchName, ::mutableMapOf)
|
||||
this[sourceUid] =
|
||||
bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, dbOption ->
|
||||
val deserializedPatchOptions =
|
||||
bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf)
|
||||
|
||||
patchOptions[option.key] = deserialize(option.value)
|
||||
val option =
|
||||
bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.key == dbOption.key }
|
||||
if (option != null) {
|
||||
try {
|
||||
deserializedPatchOptions[option.key] =
|
||||
dbOption.value.deserializeFor(option)
|
||||
} catch (e: Option.SerializationException) {
|
||||
Log.w(
|
||||
tag,
|
||||
"Option ${dbOption.patchName}:${option.key} could not be deserialized",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
bundlePatchOptions
|
||||
}
|
||||
bundlePatchOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -47,8 +61,12 @@ class PatchOptionsRepository(db: AppDatabase) {
|
||||
|
||||
groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) ->
|
||||
patchOptions.mapNotNull { (key, value) ->
|
||||
val serialized = serialize(value)
|
||||
?: return@mapNotNull null // Don't save options that we can't serialize.
|
||||
val serialized = try {
|
||||
Option.SerializedValue.fromValue(value)
|
||||
} catch (e: Option.SerializationException) {
|
||||
Log.e(tag, "Option $patchName:$key could not be serialized", e)
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
Option(groupId, patchName, key, serialized)
|
||||
}
|
||||
@ -61,29 +79,4 @@ class PatchOptionsRepository(db: AppDatabase) {
|
||||
suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
|
||||
suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
|
||||
suspend fun reset() = dao.reset()
|
||||
|
||||
private companion object {
|
||||
fun deserialize(value: String): Any? {
|
||||
val primitive = Json.decodeFromString<JsonPrimitive>(value)
|
||||
|
||||
return when {
|
||||
primitive.isString -> primitive.content
|
||||
primitive is JsonNull -> null
|
||||
else -> primitive.booleanOrNull ?: primitive.intOrNull ?: primitive.floatOrNull
|
||||
}
|
||||
}
|
||||
|
||||
fun serialize(value: Any?): String? {
|
||||
val primitive = when (value) {
|
||||
null -> JsonNull
|
||||
is String -> JsonPrimitive(value)
|
||||
is Int -> JsonPrimitive(value)
|
||||
is Float -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return Json.encodeToString(primitive)
|
||||
}
|
||||
}
|
||||
}
|
@ -15,7 +15,7 @@ data class PatchInfo(
|
||||
val description: String?,
|
||||
val include: Boolean,
|
||||
val compatiblePackages: ImmutableList<CompatiblePackage>?,
|
||||
val options: ImmutableList<Option>?
|
||||
val options: ImmutableList<Option<*>>?
|
||||
) {
|
||||
constructor(patch: Patch<*>) : this(
|
||||
patch.name.orEmpty(),
|
||||
@ -78,20 +78,24 @@ data class CompatiblePackage(
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Option(
|
||||
data class Option<T>(
|
||||
val title: String,
|
||||
val key: String,
|
||||
val description: String,
|
||||
val required: Boolean,
|
||||
val type: String,
|
||||
val default: Any?
|
||||
val default: T?,
|
||||
val presets: Map<String, T?>?,
|
||||
val validator: (T?) -> Boolean,
|
||||
) {
|
||||
constructor(option: PatchOption<*>) : this(
|
||||
constructor(option: PatchOption<T>) : this(
|
||||
option.title ?: option.key,
|
||||
option.key,
|
||||
option.description.orEmpty(),
|
||||
option.required,
|
||||
option.valueType,
|
||||
option.default,
|
||||
option.values,
|
||||
{ option.validator(option, it) },
|
||||
)
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
package app.revanced.manager.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
|
||||
@Composable
|
||||
private inline fun <T> NumberInputDialog(
|
||||
current: T?,
|
||||
name: String,
|
||||
crossinline onSubmit: (T?) -> Unit,
|
||||
crossinline validator: @DisallowComposableCalls (T) -> Boolean,
|
||||
crossinline toNumberOrNull: @DisallowComposableCalls String.() -> T?
|
||||
) {
|
||||
var fieldValue by rememberSaveable {
|
||||
mutableStateOf(current?.toString().orEmpty())
|
||||
}
|
||||
val numberFieldValue by remember {
|
||||
derivedStateOf { fieldValue.toNumberOrNull() }
|
||||
}
|
||||
val validatorFailed by remember {
|
||||
derivedStateOf { numberFieldValue?.let { !validator(it) } ?: false }
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onSubmit(null) },
|
||||
title = { Text(name) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.dialog_input_placeholder))
|
||||
},
|
||||
isError = validatorFailed,
|
||||
supportingText = {
|
||||
if (validatorFailed) {
|
||||
Text(
|
||||
stringResource(R.string.input_dialog_value_invalid),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { numberFieldValue?.let(onSubmit) },
|
||||
enabled = numberFieldValue != null && !validatorFailed,
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { onSubmit(null) }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IntInputDialog(
|
||||
current: Int?,
|
||||
name: String,
|
||||
validator: (Int) -> Boolean = { true },
|
||||
onSubmit: (Int?) -> Unit
|
||||
) = NumberInputDialog(current, name, onSubmit, validator, String::toIntOrNull)
|
||||
|
||||
@Composable
|
||||
fun LongInputDialog(
|
||||
current: Long?,
|
||||
name: String,
|
||||
validator: (Long) -> Boolean = { true },
|
||||
onSubmit: (Long?) -> Unit
|
||||
) = NumberInputDialog(current, name, onSubmit, validator, String::toLongOrNull)
|
||||
|
||||
@Composable
|
||||
fun FloatInputDialog(
|
||||
current: Float?,
|
||||
name: String,
|
||||
validator: (Float) -> Boolean = { true },
|
||||
onSubmit: (Float?) -> Unit
|
||||
) = NumberInputDialog(current, name, onSubmit, validator, String::toFloatOrNull)
|
@ -1,204 +1,655 @@
|
||||
package app.revanced.manager.ui.component.patches
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.Restore
|
||||
import androidx.compose.material.icons.outlined.SelectAll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.toMutableStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.AlertDialogExtended
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.FloatInputDialog
|
||||
import app.revanced.manager.ui.component.IntInputDialog
|
||||
import app.revanced.manager.ui.component.LongInputDialog
|
||||
import app.revanced.manager.util.isScrollingUp
|
||||
import app.revanced.manager.util.mutableStateSetOf
|
||||
import app.revanced.manager.util.saver.snapshotStateListSaver
|
||||
import app.revanced.manager.util.saver.snapshotStateSetSaver
|
||||
import app.revanced.manager.util.toast
|
||||
import kotlinx.parcelize.Parcelize
|
||||
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
|
||||
import java.io.Serializable
|
||||
import kotlin.random.Random
|
||||
|
||||
// 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 : Any>(
|
||||
private val editor: OptionEditor<T>,
|
||||
val option: Option<T>,
|
||||
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 submitDialog(value: T?) {
|
||||
setValue(value)
|
||||
dismissDialog()
|
||||
}
|
||||
|
||||
fun clickAction() = editor.clickAction(this)
|
||||
|
||||
@Composable
|
||||
fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
|
||||
|
||||
@Composable
|
||||
fun Dialog() = editor.Dialog(this)
|
||||
}
|
||||
|
||||
@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 : Any> {
|
||||
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 = { clickAction(scope) }) {
|
||||
Icon(Icons.Outlined.Edit, 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)
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
@Composable
|
||||
fun Dialog(scope: OptionEditorScope<T>)
|
||||
}
|
||||
|
||||
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 val optionEditors = mapOf(
|
||||
"Boolean" to BooleanOptionEditor,
|
||||
"String" to StringOptionEditor,
|
||||
"Int" to IntOptionEditor,
|
||||
"Long" to LongOptionEditor,
|
||||
"Float" to FloatOptionEditor,
|
||||
"BooleanArray" to ListOptionEditor(BooleanOptionEditor),
|
||||
"StringArray" to ListOptionEditor(StringOptionEditor),
|
||||
"IntArray" to ListOptionEditor(IntOptionEditor),
|
||||
"LongArray" to ListOptionEditor(LongOptionEditor),
|
||||
"FloatArray" to ListOptionEditor(FloatOptionEditor),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
|
||||
val implementation = remember(option.type) {
|
||||
optionImplementations.getOrDefault(
|
||||
option.type,
|
||||
unknownOption
|
||||
private inline fun <T : Any> WithOptionEditor(
|
||||
editor: OptionEditor<T>,
|
||||
option: Option<T>,
|
||||
value: T?,
|
||||
noinline setValue: (T?) -> Unit,
|
||||
crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {},
|
||||
block: OptionEditorScope<T>.() -> Unit
|
||||
) {
|
||||
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val scope = remember(editor, option, value, setValue) {
|
||||
OptionEditorScope(
|
||||
editor,
|
||||
option,
|
||||
openDialog = { showDialog = true },
|
||||
dismissDialog = {
|
||||
showDialog = false
|
||||
onDismissDialog()
|
||||
},
|
||||
value,
|
||||
setValue
|
||||
)
|
||||
}
|
||||
|
||||
implementation(option, value, setValue)
|
||||
if (showDialog) scope.Dialog()
|
||||
|
||||
scope.block()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) {
|
||||
val editor = remember(option.type, option.presets) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val baseOptionEditor =
|
||||
optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor<T>
|
||||
|
||||
if (option.type != "Boolean" && option.presets != null) PresetOptionEditor(baseOptionEditor)
|
||||
else baseOptionEditor
|
||||
}
|
||||
|
||||
WithOptionEditor(editor, option, value, setValue) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = ::clickAction),
|
||||
headlineContent = { Text(option.title) },
|
||||
supportingContent = { Text(option.description) },
|
||||
trailingContent = { ListItemTrailingContent() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 validatorFailed by remember {
|
||||
derivedStateOf { !scope.option.validator(fieldValue) }
|
||||
}
|
||||
|
||||
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))
|
||||
},
|
||||
isError = validatorFailed,
|
||||
supportingText = {
|
||||
if (validatorFailed) {
|
||||
Text(
|
||||
stringResource(R.string.input_dialog_value_invalid),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
|
||||
IconButton(
|
||||
onClick = { showDropdownMenu = true }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.MoreVert,
|
||||
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(
|
||||
enabled = !validatorFailed,
|
||||
onClick = { scope.submitDialog(fieldValue) }) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = scope.dismissDialog) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
|
||||
@Composable
|
||||
protected abstract fun NumberDialog(
|
||||
title: String,
|
||||
current: T?,
|
||||
validator: (T?) -> Boolean,
|
||||
onSubmit: (T?) -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<T>) {
|
||||
NumberDialog(scope.option.title, scope.value, scope.option.validator) {
|
||||
if (it == null) return@NumberDialog scope.dismissDialog()
|
||||
|
||||
scope.submitDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object IntOptionEditor : NumberOptionEditor<Int>() {
|
||||
@Composable
|
||||
override fun NumberDialog(
|
||||
title: String,
|
||||
current: Int?,
|
||||
validator: (Int?) -> Boolean,
|
||||
onSubmit: (Int?) -> Unit
|
||||
) = IntInputDialog(current, title, validator, onSubmit)
|
||||
}
|
||||
|
||||
private object LongOptionEditor : NumberOptionEditor<Long>() {
|
||||
@Composable
|
||||
override fun NumberDialog(
|
||||
title: String,
|
||||
current: Long?,
|
||||
validator: (Long?) -> Boolean,
|
||||
onSubmit: (Long?) -> Unit
|
||||
) = LongInputDialog(current, title, validator, onSubmit)
|
||||
}
|
||||
|
||||
private object FloatOptionEditor : NumberOptionEditor<Float>() {
|
||||
@Composable
|
||||
override fun NumberDialog(
|
||||
title: String,
|
||||
current: Float?,
|
||||
validator: (Float?) -> Boolean,
|
||||
onSubmit: (Float?) -> Unit
|
||||
) = FloatInputDialog(current, title, validator, onSubmit)
|
||||
}
|
||||
|
||||
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 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>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper for [OptionEditor]s that shows selectable presets.
|
||||
*
|
||||
* @param innerEditor The [OptionEditor] for [T].
|
||||
*/
|
||||
private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<T>) :
|
||||
OptionEditor<T> {
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<T>) {
|
||||
var selectedPreset by rememberSaveable(scope.value, scope.option.presets) {
|
||||
val presets = scope.option.presets!!
|
||||
|
||||
mutableStateOf(presets.entries.find { it.value == scope.value }?.key)
|
||||
}
|
||||
|
||||
WithOptionEditor(
|
||||
innerEditor,
|
||||
scope.option,
|
||||
scope.value,
|
||||
scope.setValue,
|
||||
onDismissDialog = scope.dismissDialog
|
||||
) inner@{
|
||||
var hidePresetsDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
if (hidePresetsDialog) return@inner
|
||||
|
||||
// TODO: add a divider for scrollable content
|
||||
AlertDialogExtended(
|
||||
onDismissRequest = scope.dismissDialog,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (selectedPreset != null) scope.submitDialog(
|
||||
scope.option.presets?.get(
|
||||
selectedPreset
|
||||
)
|
||||
)
|
||||
else {
|
||||
this@inner.openDialog()
|
||||
// Hide the presets dialog so it doesn't show up in the background.
|
||||
hidePresetsDialog = true
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = scope.dismissDialog) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
title = { Text(scope.option.title) },
|
||||
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
|
||||
text = {
|
||||
val presets = remember(scope.option.presets) {
|
||||
scope.option.presets?.entries?.toList().orEmpty()
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
@Composable
|
||||
fun Item(title: String, value: Any?, presetKey: String?) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { selectedPreset = presetKey },
|
||||
headlineContent = { Text(title) },
|
||||
supportingContent = value?.toString()?.let { { Text(it) } },
|
||||
leadingContent = {
|
||||
RadioButton(
|
||||
selected = selectedPreset == presetKey,
|
||||
onClick = { selectedPreset = presetKey }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
items(presets, key = { it.key }) {
|
||||
Item(it.key, it.value, it.key)
|
||||
}
|
||||
|
||||
item(key = null) {
|
||||
Item(stringResource(R.string.option_preset_custom_value), null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ListOptionEditor<T : Serializable>(private val elementEditor: OptionEditor<T>) :
|
||||
OptionEditor<List<T>> {
|
||||
private fun createElementOption(option: Option<List<T>>) = Option<T>(
|
||||
option.title,
|
||||
option.key,
|
||||
option.description,
|
||||
option.required,
|
||||
option.type.removeSuffix("Array"),
|
||||
null,
|
||||
null
|
||||
) { true }
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<List<T>>) {
|
||||
val items =
|
||||
rememberSaveable(scope.value, saver = snapshotStateListSaver()) {
|
||||
// We need a key for each element in order to support dragging.
|
||||
scope.value?.map(::Item)?.toMutableStateList() ?: mutableStateListOf()
|
||||
}
|
||||
val listIsDirty by remember {
|
||||
derivedStateOf {
|
||||
val current = scope.value.orEmpty()
|
||||
if (current.size != items.size) return@derivedStateOf true
|
||||
|
||||
current.forEachIndexed { index, value ->
|
||||
if (value != items[index].value) return@derivedStateOf true
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableLazyColumnState =
|
||||
rememberReorderableLazyColumnState(lazyListState) { from, to ->
|
||||
// Update the list
|
||||
items.add(to.index, items.removeAt(from.index))
|
||||
}
|
||||
|
||||
var deleteMode by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val deletionTargets = rememberSaveable(saver = snapshotStateSetSaver()) {
|
||||
mutableStateSetOf<Int>()
|
||||
}
|
||||
|
||||
val back = back@{
|
||||
if (deleteMode) {
|
||||
deletionTargets.clear()
|
||||
deleteMode = false
|
||||
return@back
|
||||
}
|
||||
|
||||
if (!listIsDirty) {
|
||||
scope.dismissDialog()
|
||||
return@back
|
||||
}
|
||||
|
||||
scope.submitDialog(items.mapNotNull { it.value })
|
||||
}
|
||||
|
||||
ComposeDialog(
|
||||
onDismissRequest = back,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true
|
||||
),
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = if (deleteMode) pluralStringResource(
|
||||
R.plurals.selected_count,
|
||||
deletionTargets.size,
|
||||
deletionTargets.size
|
||||
) else scope.option.title,
|
||||
onBackClick = back,
|
||||
backIcon = {
|
||||
if (deleteMode) {
|
||||
return@AppTopBar Icon(
|
||||
Icons.Filled.Close,
|
||||
stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||
},
|
||||
actions = {
|
||||
if (deleteMode) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (items.size == deletionTargets.size) deletionTargets.clear()
|
||||
else deletionTargets.addAll(items.map { it.key })
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.SelectAll,
|
||||
stringResource(R.string.select_deselect_all)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
items.removeIf { it.key in deletionTargets }
|
||||
deletionTargets.clear()
|
||||
deleteMode = false
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.delete)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = items::clear) {
|
||||
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (deleteMode) return@Scaffold
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(stringResource(R.string.add)) },
|
||||
icon = { Icon(Icons.Outlined.Add, null) },
|
||||
expanded = lazyListState.isScrollingUp,
|
||||
onClick = { items.add(Item(null)) }
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
val elementOption = remember(scope.option) { createElementOption(scope.option) }
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
itemsIndexed(items, key = { _, item -> item.key }) { index, item ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
ReorderableItem(reorderableLazyColumnState, key = item.key) {
|
||||
WithOptionEditor(
|
||||
elementEditor,
|
||||
elementOption,
|
||||
value = item.value,
|
||||
setValue = { items[index] = item.copy(value = it) }
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.combinedClickable(
|
||||
indication = LocalIndication.current,
|
||||
interactionSource = interactionSource,
|
||||
onLongClickLabel = stringResource(R.string.select),
|
||||
onLongClick = {
|
||||
deletionTargets.add(item.key)
|
||||
deleteMode = true
|
||||
},
|
||||
onClick = {
|
||||
if (!deleteMode) {
|
||||
clickAction()
|
||||
return@combinedClickable
|
||||
}
|
||||
|
||||
if (item.key in deletionTargets) {
|
||||
deletionTargets.remove(
|
||||
item.key
|
||||
)
|
||||
deleteMode = deletionTargets.isNotEmpty()
|
||||
} else deletionTargets.add(item.key)
|
||||
},
|
||||
),
|
||||
tonalElevation = if (deleteMode && item.key in deletionTargets) 8.dp else 0.dp,
|
||||
leadingContent = {
|
||||
IconButton(
|
||||
modifier = Modifier.draggableHandle(interactionSource = interactionSource),
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.DragHandle,
|
||||
stringResource(R.string.drag_handle)
|
||||
)
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
if (item.value == null) return@ListItem Text(
|
||||
stringResource(R.string.empty),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
|
||||
Text(item.value.toString())
|
||||
},
|
||||
trailingContent = {
|
||||
ListItemTrailingContent()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
private data class Item<T : Serializable>(val value: T?, val key: Int = Random.nextInt()) :
|
||||
Parcelable
|
||||
}
|
@ -4,17 +4,11 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
@ -22,6 +16,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.manager.base.Preference
|
||||
import app.revanced.manager.ui.component.IntInputDialog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ -57,7 +52,7 @@ fun IntegerItem(
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
IntegerItemDialog(current = value, name = headline) { new ->
|
||||
IntInputDialog(current = value, name = stringResource(headline)) { new ->
|
||||
dialogOpen = false
|
||||
new?.let(onValueChange)
|
||||
}
|
||||
@ -78,44 +73,4 @@ fun IntegerItem(
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IntegerItemDialog(current: Int, @StringRes name: Int, onSubmit: (Int?) -> Unit) {
|
||||
var fieldValue by rememberSaveable {
|
||||
mutableStateOf(current.toString())
|
||||
}
|
||||
|
||||
val integerFieldValue by remember {
|
||||
derivedStateOf {
|
||||
fieldValue.toIntOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onSubmit(null) },
|
||||
title = { Text(stringResource(name)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.dialog_input_placeholder))
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { integerFieldValue?.let(onSubmit) },
|
||||
enabled = integerFieldValue != null,
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { onSubmit(null) }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
@ -54,6 +54,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.Countdown
|
||||
@ -519,7 +520,8 @@ fun OptionsDialog(
|
||||
val value =
|
||||
if (values == null || !values.contains(key)) option.default else values[key]
|
||||
|
||||
OptionItem(option = option, value = value, setValue = { set(key, it) })
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
OptionItem(option = option as Option<Any>, value = value, setValue = { set(key, it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package app.revanced.manager.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.MutableState
|
||||
@ -22,6 +23,7 @@ import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@ -31,10 +33,12 @@ import org.koin.core.component.get
|
||||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
val bundlesRepo: PatchBundleRepository = get()
|
||||
private val bundleRepository: PatchBundleRepository = get()
|
||||
private val selectionRepository: PatchSelectionRepository = get()
|
||||
private val optionsRepository: PatchOptionsRepository = get()
|
||||
private val pm: PM = get()
|
||||
private val savedStateHandle: SavedStateHandle = get()
|
||||
private val app: Application = get()
|
||||
val prefs: PreferencesManager = get()
|
||||
|
||||
private val persistConfiguration = input.patches == null
|
||||
@ -62,8 +66,15 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
viewModelScope.launch {
|
||||
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
|
||||
|
||||
val packageName = selectedApp.packageName // Accessing this from another thread may cause crashes.
|
||||
state.value = withContext(Dispatchers.Default) { optionsRepository.getOptions(packageName) }
|
||||
val packageName =
|
||||
selectedApp.packageName // Accessing this from another thread may cause crashes.
|
||||
|
||||
state.value = withContext(Dispatchers.Default) {
|
||||
val bundlePatches = bundleRepository.bundles.first()
|
||||
.mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } }
|
||||
|
||||
optionsRepository.getOptions(packageName, bundlePatches)
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
|
@ -8,4 +8,7 @@
|
||||
<item quantity="one">Executed %d patch</item>
|
||||
<item quantity="other">Executed %d patches</item>
|
||||
</plurals>
|
||||
<plurals name="selected_count">
|
||||
<item quantity="other">%d selected</item>
|
||||
</plurals>
|
||||
</resources>
|
@ -236,6 +236,7 @@
|
||||
<string name="patch_selector_sheet_filter_compat_title">Compatibility</string>
|
||||
|
||||
<string name="string_option_menu_description">More options</string>
|
||||
<string name="option_preset_custom_value">Custom value</string>
|
||||
|
||||
<string name="path_selector">Select from storage</string>
|
||||
<string name="path_selector_parent_dir">Previous directory</string>
|
||||
@ -276,6 +277,7 @@
|
||||
|
||||
<string name="expand_content">expand</string>
|
||||
<string name="collapse_content">collapse</string>
|
||||
<string name="drag_handle">reorder</string>
|
||||
|
||||
<string name="more">More</string>
|
||||
<string name="continue_">Continue</string>
|
||||
@ -321,6 +323,7 @@
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="update">Update</string>
|
||||
<string name="empty">Empty</string>
|
||||
<string name="installing_message">Tap on <b>Update</b> when prompted. \n ReVanced Manager will close when updating.</string>
|
||||
<string name="no_changelogs_found">No changelogs found</string>
|
||||
<string name="just_now">Just now</string>
|
||||
@ -329,6 +332,7 @@
|
||||
<string name="days_ago">%sd ago</string>
|
||||
<string name="invalid_date">Invalid date</string>
|
||||
<string name="disable_battery_optimization">Disable battery optimization</string>
|
||||
<string name="input_dialog_value_invalid">Invalid value</string>
|
||||
|
||||
<string name="failed_to_check_updates">Failed to check for updates: %s</string>
|
||||
<string name="no_update_available">No update available</string>
|
||||
@ -341,6 +345,7 @@
|
||||
<string name="download_update_confirmation">Download update?</string>
|
||||
<string name="no_contributors_found">No contributors found</string>
|
||||
<string name="select">Select</string>
|
||||
<string name="select_deselect_all">Select or deselect all</string>
|
||||
<string name="select_bundle_type_dialog_title">Select bundle type</string>
|
||||
<string name="select_bundle_type_dialog_description">Select the type that is right for you.</string>
|
||||
<string name="local_bundle_description">Import local files from your storage, does not automatically update</string>
|
||||
|
@ -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