|
|
|
|
@@ -1,7 +1,7 @@
|
|
|
|
|
package me.eternal.purrfectsnap.ui.manager.pages.features
|
|
|
|
|
|
|
|
|
|
import android.net.Uri
|
|
|
|
|
import androidx.compose.animation.*
|
|
|
|
|
import androidx.compose.animation.AnimatedContentTransitionScope
|
|
|
|
|
import androidx.compose.animation.core.tween
|
|
|
|
|
import androidx.compose.foundation.BorderStroke
|
|
|
|
|
import androidx.compose.foundation.LocalIndication
|
|
|
|
|
@@ -11,6 +11,7 @@ import androidx.compose.foundation.clickable
|
|
|
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
|
|
|
import androidx.compose.foundation.layout.*
|
|
|
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
|
|
|
import androidx.compose.foundation.lazy.LazyListState
|
|
|
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
|
|
|
import androidx.compose.foundation.lazy.items
|
|
|
|
|
import androidx.compose.foundation.lazy.itemsIndexed
|
|
|
|
|
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
|
|
|
|
import androidx.compose.material.icons.filled.*
|
|
|
|
|
import androidx.compose.material3.*
|
|
|
|
|
import androidx.compose.runtime.*
|
|
|
|
|
import androidx.compose.runtime.key
|
|
|
|
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
|
|
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
|
|
|
import androidx.compose.ui.Alignment
|
|
|
|
|
@@ -52,7 +54,9 @@ import androidx.compose.ui.unit.DpOffset
|
|
|
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
|
import androidx.compose.ui.unit.sp
|
|
|
|
|
import androidx.compose.ui.window.Dialog
|
|
|
|
|
import androidx.compose.ui.window.DialogProperties
|
|
|
|
|
import androidx.compose.ui.zIndex
|
|
|
|
|
import androidx.core.net.toUri
|
|
|
|
|
import androidx.lifecycle.Lifecycle
|
|
|
|
|
import androidx.navigation.NavBackStackEntry
|
|
|
|
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
|
|
|
|
@@ -65,14 +69,15 @@ import kotlinx.coroutines.Job
|
|
|
|
|
import kotlinx.coroutines.delay
|
|
|
|
|
import kotlinx.coroutines.launch
|
|
|
|
|
import me.eternal.purrfectsnap.common.config.*
|
|
|
|
|
import me.eternal.purrfectsnap.common.config.FeatureNotice
|
|
|
|
|
import com.google.gson.Gson
|
|
|
|
|
import com.google.gson.reflect.TypeToken
|
|
|
|
|
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
|
|
|
|
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
|
|
|
|
import me.eternal.purrfectsnap.ui.manager.Routes
|
|
|
|
|
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
|
|
|
|
import me.eternal.purrfectsnap.ui.manager.rememberRouteLazyListState
|
|
|
|
|
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
|
|
|
|
import me.eternal.purrfectsnap.ui.manager.theme.purrfectSwitchColors
|
|
|
|
|
import me.eternal.purrfectsnap.ui.util.*
|
|
|
|
|
import org.json.JSONArray
|
|
|
|
|
import org.json.JSONObject
|
|
|
|
|
@@ -119,7 +124,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
it.key.dataType.type == DataProcessors.Type.CONTAINER &&
|
|
|
|
|
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
|
|
|
|
|
) {
|
|
|
|
|
containers[it.key.name] = (it.key to it.value).toPropertyPair()
|
|
|
|
|
containers[it.key.name] = PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>)
|
|
|
|
|
queryContainerRecursive(it.value.get() as ConfigContainer)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -143,85 +148,6 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
return !propertyKey.params.flags.contains(ConfigFlag.HIDDEN)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal data class SearchEntry(val keyword: String, val tokens: List<String>)
|
|
|
|
|
|
|
|
|
|
internal fun buildSearchEntries(): List<SearchEntry> {
|
|
|
|
|
return allProperties.keys.mapNotNull { key ->
|
|
|
|
|
if (!isSearchVisibleProperty(key)) return@mapNotNull null
|
|
|
|
|
val name = context.translation[key.propertyName()]
|
|
|
|
|
val description = context.translation[key.propertyDescription()]
|
|
|
|
|
val tokens = listOfNotNull(name, description, key.name).map { it.trim() }.filter { it.isNotEmpty() }
|
|
|
|
|
if (tokens.isEmpty()) null else SearchEntry(keyword = name ?: key.name, tokens = tokens)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun levenshtein(a: String, b: String): Int {
|
|
|
|
|
if (a == b) return 0
|
|
|
|
|
if (a.isEmpty()) return b.length
|
|
|
|
|
if (b.isEmpty()) return a.length
|
|
|
|
|
val prev = IntArray(b.length + 1) { it }
|
|
|
|
|
val curr = IntArray(b.length + 1)
|
|
|
|
|
for (i in a.indices) {
|
|
|
|
|
curr[0] = i + 1
|
|
|
|
|
for (j in b.indices) {
|
|
|
|
|
val cost = if (a[i] == b[j]) 0 else 1
|
|
|
|
|
curr[j + 1] = min(
|
|
|
|
|
min(curr[j] + 1, prev[j + 1] + 1),
|
|
|
|
|
prev[j] + cost
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
prev.indices.forEach { prev[it] = curr[it] }
|
|
|
|
|
}
|
|
|
|
|
return curr[b.length]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun similarityScore(query: String, target: String): Float {
|
|
|
|
|
val q = query.lowercase()
|
|
|
|
|
val t = target.lowercase()
|
|
|
|
|
val maxLen = max(q.length, t.length)
|
|
|
|
|
if (maxLen == 0) return 1f
|
|
|
|
|
val dist = levenshtein(q, t)
|
|
|
|
|
return 1f - (dist.toFloat() / maxLen.toFloat())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun fuzzySuggest(query: String, entries: List<SearchEntry>): List<String> {
|
|
|
|
|
val q = query.trim()
|
|
|
|
|
if (q.length < 2) return emptyList()
|
|
|
|
|
return entries.map { entry ->
|
|
|
|
|
val best = entry.tokens.maxOfOrNull { similarityScore(q, it) } ?: 0f
|
|
|
|
|
best to entry.keyword
|
|
|
|
|
}.filter { it.first >= 0.45f }
|
|
|
|
|
.sortedWith(compareByDescending<Pair<Float, String>> { it.first }.thenBy { it.second.length })
|
|
|
|
|
.map { it.second }
|
|
|
|
|
.distinct()
|
|
|
|
|
.take(6)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun loadSearchHistory(): List<String> {
|
|
|
|
|
return context.sharedPreferences
|
|
|
|
|
.getString("features_search_history", "")
|
|
|
|
|
?.split("|")
|
|
|
|
|
?.map { it.trim() }
|
|
|
|
|
?.filter { it.isNotEmpty() }
|
|
|
|
|
?: emptyList()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun saveSearchHistory(history: List<String>) {
|
|
|
|
|
context.sharedPreferences.edit()
|
|
|
|
|
.putString("features_search_history", history.joinToString("|"))
|
|
|
|
|
.apply()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun upsertHistory(term: String, history: SnapshotStateList<String>) {
|
|
|
|
|
val cleaned = term.trim()
|
|
|
|
|
if (cleaned.isEmpty()) return
|
|
|
|
|
val existingIndex = history.indexOfFirst { it.equals(cleaned, ignoreCase = true) }
|
|
|
|
|
if (existingIndex >= 0) history.removeAt(existingIndex)
|
|
|
|
|
history.add(0, cleaned)
|
|
|
|
|
while (history.size > 12) history.removeLast()
|
|
|
|
|
saveSearchHistory(history)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun navigateToMainRoot() {
|
|
|
|
|
routes.navController.navigate(routeInfo.id, NavOptions.Builder()
|
|
|
|
|
.setPopUpTo(routes.navController.graph.findStartDestination().id, false)
|
|
|
|
|
@@ -230,7 +156,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) {
|
|
|
|
|
private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) {
|
|
|
|
|
routes.activityLauncher.let(block)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -243,6 +169,9 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
key(themeId) {
|
|
|
|
|
LaunchedEffect(themeId) {
|
|
|
|
|
routes.navigation?.globalScrollOffset = 0
|
|
|
|
|
}
|
|
|
|
|
with(ManagerTheme.fromId(themeId).theme) {
|
|
|
|
|
this@FeaturesRootSection.FeaturesScreen(nav)
|
|
|
|
|
}
|
|
|
|
|
@@ -286,7 +215,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
|
|
|
|
|
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
|
|
|
|
|
)
|
|
|
|
|
}.map { (it.key to it.value).toPropertyPair() }
|
|
|
|
|
}.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
|
|
|
|
|
|
|
|
|
|
PropertiesView(
|
|
|
|
|
properties = properties,
|
|
|
|
|
@@ -387,7 +316,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
internal fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) {
|
|
|
|
|
internal fun PropertyAction(property: PropertyPair<*>, registerClickCallback: ( () -> Unit ) -> (() -> Unit)) {
|
|
|
|
|
var showDialog by remember { mutableStateOf(false) }
|
|
|
|
|
var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
|
|
|
|
|
|
|
|
|
|
@@ -407,6 +336,21 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
val propertyValue = property.value
|
|
|
|
|
fun persistConfig() = context.config.writeConfig()
|
|
|
|
|
|
|
|
|
|
fun getFolderReadablePath(context: android.content.Context, folderUri: String?): String? {
|
|
|
|
|
if (folderUri == null) return null
|
|
|
|
|
return try {
|
|
|
|
|
val uri = android.net.Uri.parse(folderUri)
|
|
|
|
|
val path = uri.path ?: return folderUri
|
|
|
|
|
if (path.contains("tree/")) {
|
|
|
|
|
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
|
|
|
|
|
} else {
|
|
|
|
|
folderUri
|
|
|
|
|
}
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
folderUri
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
|
|
|
|
|
registerDialogOnClickCallback()
|
|
|
|
|
dialogComposable = {
|
|
|
|
|
@@ -554,21 +498,24 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Icon(Icons.Filled.AttachFile, contentDescription = null)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
|
|
|
|
|
IconButton(onClick = registerClickCallback {
|
|
|
|
|
activityLauncher {
|
|
|
|
|
chooseFolder { uri ->
|
|
|
|
|
val folderUri = propertyValue.get() as? String
|
|
|
|
|
val readablePath = remember(folderUri) { getFolderReadablePath(context.androidContext, folderUri) }
|
|
|
|
|
|
|
|
|
|
ValueGlowChip(
|
|
|
|
|
text = readablePath ?: folderUri ?: "None",
|
|
|
|
|
leadingIcon = Icons.Filled.FolderOpen,
|
|
|
|
|
modifier = Modifier.widthIn(min = 52.dp, max = 160.dp),
|
|
|
|
|
onClick = registerClickCallback {
|
|
|
|
|
routes.activityLauncher.chooseFolder { uri ->
|
|
|
|
|
propertyValue.setAny(uri)
|
|
|
|
|
persistConfig()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}.let { { it.invoke(true) } }) {
|
|
|
|
|
Icon(Icons.Filled.FolderOpen, contentDescription = null)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -578,7 +525,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
val hapticFeedback = LocalHapticFeedback.current
|
|
|
|
|
Switch(
|
|
|
|
|
checked = state,
|
|
|
|
|
onCheckedChange = registerClickCallback {
|
|
|
|
|
onCheckedChange = {
|
|
|
|
|
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
|
|
|
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
|
|
|
}
|
|
|
|
|
@@ -601,7 +548,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
Text(
|
|
|
|
|
overflow = TextOverflow.Ellipsis,
|
|
|
|
|
maxLines = 1,
|
|
|
|
|
modifier = Modifier.widthIn(0.dp, 120.dp),
|
|
|
|
|
modifier = Modifier.widthIn(0.dp, 120.dp).clickable { showDialog = true },
|
|
|
|
|
text = (propertyValue.get() as Pair<*, *>).let {
|
|
|
|
|
"${it.first.toString().toFloatOrNull() ?: 0F}, ${it.second.toString().toFloatOrNull() ?: 0F}"
|
|
|
|
|
}
|
|
|
|
|
@@ -618,7 +565,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
Text(
|
|
|
|
|
overflow = TextOverflow.Ellipsis,
|
|
|
|
|
maxLines = 1,
|
|
|
|
|
modifier = Modifier.widthIn(0.dp, 120.dp),
|
|
|
|
|
modifier = Modifier.widthIn(0.dp, 120.dp).clickable { showDialog = true },
|
|
|
|
|
text = (propertyValue.getNullable() as? String ?: "null").let {
|
|
|
|
|
property.key.propertyOption(context.translation, it)
|
|
|
|
|
}
|
|
|
|
|
@@ -643,41 +590,39 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
|
|
|
|
|
if (dataType == DataProcessors.Type.INTEGER ||
|
|
|
|
|
dataType == DataProcessors.Type.FLOAT) {
|
|
|
|
|
ValueGlowChip(
|
|
|
|
|
text = propertyValue.get().toString(),
|
|
|
|
|
onClick = it
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
val isMessageListProperty = property.key.name.endsWith("_messages")
|
|
|
|
|
if (isMessageListProperty) {
|
|
|
|
|
val messageCount = try {
|
|
|
|
|
val messageList: List<String> = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList()
|
|
|
|
|
messageList.size
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
1
|
|
|
|
|
}
|
|
|
|
|
val click = registerClickCallback { showDialog = true }
|
|
|
|
|
if (dataType == DataProcessors.Type.INTEGER ||
|
|
|
|
|
dataType == DataProcessors.Type.FLOAT) {
|
|
|
|
|
ValueGlowChip(
|
|
|
|
|
text = propertyValue.get().toString(),
|
|
|
|
|
onClick = click
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
val isMessageListProperty = property.key.name.endsWith("_messages")
|
|
|
|
|
if (isMessageListProperty) {
|
|
|
|
|
val messageCount = try {
|
|
|
|
|
val messageList: List<String> = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList()
|
|
|
|
|
messageList.size
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Surface(
|
|
|
|
|
shape = RoundedCornerShape(14.dp),
|
|
|
|
|
color = Color.White.copy(alpha = 0.06f),
|
|
|
|
|
tonalElevation = 0.dp,
|
|
|
|
|
shadowElevation = 0.dp,
|
|
|
|
|
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
|
|
|
|
) {
|
|
|
|
|
Text(
|
|
|
|
|
text = translation.format("search_results_count", "count" to messageCount.toString()),
|
|
|
|
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
|
|
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
|
|
|
color = Color.White
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
IconButton(onClick = it) {
|
|
|
|
|
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
|
|
|
|
|
}
|
|
|
|
|
Surface(
|
|
|
|
|
shape = RoundedCornerShape(14.dp),
|
|
|
|
|
color = Color.White.copy(alpha = 0.06f),
|
|
|
|
|
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
|
|
|
|
modifier = Modifier.clickable { click() }
|
|
|
|
|
) {
|
|
|
|
|
Text(
|
|
|
|
|
text = translation.format("search_results_count", "count" to messageCount.toString()),
|
|
|
|
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
|
|
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
|
|
|
color = Color.White
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
IconButton(onClick = click) {
|
|
|
|
|
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -690,9 +635,8 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
|
|
|
|
|
CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) })
|
|
|
|
|
}
|
|
|
|
|
val click = registerClickCallback { showDialog = true }
|
|
|
|
|
CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DataProcessors.Type.CONTAINER -> {
|
|
|
|
|
@@ -741,52 +685,49 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
@Composable
|
|
|
|
|
internal fun ValueGlowChip(
|
|
|
|
|
text: String,
|
|
|
|
|
leadingIcon: ImageVector? = null,
|
|
|
|
|
modifier: Modifier = Modifier.size(52.dp),
|
|
|
|
|
onClick: () -> Unit
|
|
|
|
|
) {
|
|
|
|
|
Surface(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.size(52.dp)
|
|
|
|
|
modifier = modifier
|
|
|
|
|
.clip(CircleShape)
|
|
|
|
|
.clickable { onClick() },
|
|
|
|
|
shape = CircleShape,
|
|
|
|
|
color = Color.White.copy(alpha = 0.08f),
|
|
|
|
|
tonalElevation = 0.dp,
|
|
|
|
|
shadowElevation = 10.dp,
|
|
|
|
|
border = BorderStroke(
|
|
|
|
|
1.dp,
|
|
|
|
|
Brush.linearGradient(
|
|
|
|
|
listOf(
|
|
|
|
|
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
|
|
|
|
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.5f), PurrfectPalette.glowSecondary.copy(alpha = 0.35f))))
|
|
|
|
|
) {
|
|
|
|
|
Box(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.fillMaxSize()
|
|
|
|
|
.background(
|
|
|
|
|
Brush.radialGradient(
|
|
|
|
|
listOf(
|
|
|
|
|
PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
|
|
|
|
|
Color.Transparent
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
.background(Brush.radialGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.25f), Color.Transparent))),
|
|
|
|
|
contentAlignment = Alignment.Center
|
|
|
|
|
) {
|
|
|
|
|
Text(
|
|
|
|
|
text = text,
|
|
|
|
|
color = Color.White,
|
|
|
|
|
fontWeight = FontWeight.Bold
|
|
|
|
|
)
|
|
|
|
|
Row(
|
|
|
|
|
modifier = Modifier.padding(horizontal = 12.dp),
|
|
|
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
|
|
|
horizontalArrangement = Arrangement.Center
|
|
|
|
|
) {
|
|
|
|
|
if (leadingIcon != null) {
|
|
|
|
|
Icon(imageVector = leadingIcon, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp))
|
|
|
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
|
|
|
}
|
|
|
|
|
Text(
|
|
|
|
|
text = text,
|
|
|
|
|
color = Color.White,
|
|
|
|
|
fontWeight = FontWeight.Bold,
|
|
|
|
|
fontSize = 13.sp,
|
|
|
|
|
maxLines = 1,
|
|
|
|
|
overflow = TextOverflow.Ellipsis
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
internal fun PropertyCard(property: PropertyPair<*>, onOpen: (() -> Unit)? = null) {
|
|
|
|
|
var clickCallback by remember { mutableStateOf<ClickCallback?>(null) }
|
|
|
|
|
var clickCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
|
|
|
|
|
val noticeColorMap = remember {
|
|
|
|
|
mapOf(
|
|
|
|
|
FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87),
|
|
|
|
|
@@ -821,7 +762,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
indication = null
|
|
|
|
|
) {
|
|
|
|
|
onOpen?.invoke()
|
|
|
|
|
clickCallback?.invoke(true)
|
|
|
|
|
clickCallback?.invoke()
|
|
|
|
|
}
|
|
|
|
|
.scaleOnPress(interactionSource),
|
|
|
|
|
shape = cardShape,
|
|
|
|
|
@@ -916,15 +857,17 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
) {
|
|
|
|
|
PropertyAction(property, registerClickCallback = { callback ->
|
|
|
|
|
if (property.key.propertyTranslationPath().startsWith("rules.properties")) {
|
|
|
|
|
clickCallback = {
|
|
|
|
|
val ruleCallback: () -> Unit = {
|
|
|
|
|
routes.manageRuleFeature.navigate {
|
|
|
|
|
put("rule_type", property.key.name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return@PropertyAction clickCallback!!
|
|
|
|
|
clickCallback = ruleCallback
|
|
|
|
|
ruleCallback // return the callback
|
|
|
|
|
} else {
|
|
|
|
|
clickCallback = callback
|
|
|
|
|
callback // return the callback
|
|
|
|
|
}
|
|
|
|
|
clickCallback = callback
|
|
|
|
|
callback
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -932,6 +875,8 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
|
|
|
|
|
|
|
|
|
|
@OptIn(ExperimentalLayoutApi::class)
|
|
|
|
|
@Composable
|
|
|
|
|
internal fun FloatingControls(
|
|
|
|
|
@@ -947,7 +892,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
) {
|
|
|
|
|
var showSearchBar by rememberSaveable { mutableStateOf(isSearchResults) }
|
|
|
|
|
val focusRequester = remember { FocusRequester() }
|
|
|
|
|
val isOverlay = remember { context.sharedPreferences.getBoolean("overlay_active", false) }
|
|
|
|
|
val isOverlay = activeSectionTitle != null
|
|
|
|
|
var searchValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
|
|
|
|
mutableStateOf(
|
|
|
|
|
TextFieldValue(
|
|
|
|
|
@@ -1065,36 +1010,42 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
val actions = remember {
|
|
|
|
|
listOf(
|
|
|
|
|
Triple(translation["export_option"] ?: "Export", Icons.Filled.SaveAlt) {
|
|
|
|
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
|
|
|
showExportDialog = true
|
|
|
|
|
{
|
|
|
|
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
|
|
|
showExportDialog = true
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Triple(translation["import_option"] ?: "Import", Icons.Filled.FileDownload) {
|
|
|
|
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
|
|
|
activityLauncher {
|
|
|
|
|
openFile("application/json") { uriString ->
|
|
|
|
|
runCatching {
|
|
|
|
|
val uri = android.net.Uri.parse(uriString)
|
|
|
|
|
context.androidContext.contentResolver.openInputStream(uri)?.use {
|
|
|
|
|
routes.configJsonForImport = it.readBytes().toString(Charsets.UTF_8)
|
|
|
|
|
routes.navController.navigate(Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE)
|
|
|
|
|
{
|
|
|
|
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
|
|
|
activityLauncher {
|
|
|
|
|
openFile("application/json") { uriString ->
|
|
|
|
|
runCatching {
|
|
|
|
|
val uri = android.net.Uri.parse(uriString)
|
|
|
|
|
context.androidContext.contentResolver.openInputStream(uri)?.use {
|
|
|
|
|
routes.configJsonForImport = it.readBytes().toString(Charsets.UTF_8)
|
|
|
|
|
routes.navController.navigate(Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE)
|
|
|
|
|
}
|
|
|
|
|
}.onFailure { err ->
|
|
|
|
|
context.log.error("Failed to read config file", err)
|
|
|
|
|
context.longToast(translation.format("config_import_failure_toast", "error" to (err.message ?: "Unknown")))
|
|
|
|
|
}
|
|
|
|
|
}.onFailure { err ->
|
|
|
|
|
context.log.error("Failed to read config file", err)
|
|
|
|
|
context.longToast(translation.format("config_import_failure_toast", "error" to (err.message ?: "Unknown")))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Triple(translation["reset_option"] ?: "Reset", Icons.Filled.Refresh) {
|
|
|
|
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
|
|
|
showResetConfirmationDialog = true
|
|
|
|
|
{
|
|
|
|
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
|
|
|
showResetConfirmationDialog = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val headerTitle = activeSectionTitle ?: translation["manager.routes.features"] ?: "Features"
|
|
|
|
|
val subtitleText = when {
|
|
|
|
|
isSearchResults -> translation["search_button"] ?: "Search"
|
|
|
|
|
searchKeyword != null -> translation["search_button"] ?: "Search"
|
|
|
|
|
!activeSectionSubtitle.isNullOrBlank() -> activeSectionSubtitle
|
|
|
|
|
else -> translation["manager.sections.features.subtitle"] ?: ""
|
|
|
|
|
}
|
|
|
|
|
@@ -1112,12 +1063,13 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Column(modifier = modifier) {
|
|
|
|
|
Column(modifier = modifier.headerHeightTracker { controlsHeight = it }) {
|
|
|
|
|
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
|
|
|
|
|
title = headerTitle,
|
|
|
|
|
subtitle = if (showSearchBar) null else subtitleText,
|
|
|
|
|
onBack = onBack,
|
|
|
|
|
scrollOffset = scrollOffset,
|
|
|
|
|
enableMorph = true,
|
|
|
|
|
actions = {
|
|
|
|
|
if (showSearchBar) {
|
|
|
|
|
TextField(
|
|
|
|
|
@@ -1228,7 +1180,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
},
|
|
|
|
|
text = { Text(text = name ?: "", color = Color.White) },
|
|
|
|
|
onClick = {
|
|
|
|
|
action()
|
|
|
|
|
action()()
|
|
|
|
|
showExportDropdownMenu = false
|
|
|
|
|
},
|
|
|
|
|
colors = MenuDefaults.itemColors(
|
|
|
|
|
@@ -1310,13 +1262,22 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
) {
|
|
|
|
|
val density = LocalDensity.current
|
|
|
|
|
var controlsHeight by remember { mutableStateOf(100.dp) }
|
|
|
|
|
val listState = rememberRouteLazyListState(stateKey)
|
|
|
|
|
|
|
|
|
|
// Claude Fix 2: Backstack-scoped scroll state
|
|
|
|
|
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
|
|
|
|
val listState = rememberSaveable(
|
|
|
|
|
navBackStackEntry?.id,
|
|
|
|
|
saver = LazyListState.Saver
|
|
|
|
|
) {
|
|
|
|
|
LazyListState()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val sharedSearchHistory = remember { mutableStateListOf<String>().apply { addAll(loadSearchHistory()) } }
|
|
|
|
|
var liveSearchQuery by rememberSaveable { mutableStateOf(searchKeyword.orEmpty()) }
|
|
|
|
|
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
|
|
|
|
|
val globalSearchProperties = remember(enableGlobalSearch) {
|
|
|
|
|
if (enableGlobalSearch) {
|
|
|
|
|
allProperties.filter { isSearchVisibleProperty(it.key) }.map { (it.key to it.value).toPropertyPair() }
|
|
|
|
|
allProperties.filter { isSearchVisibleProperty(it.key) }.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
|
|
|
|
|
} else {
|
|
|
|
|
emptyList()
|
|
|
|
|
}
|
|
|
|
|
@@ -1368,10 +1329,12 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
contentPadding = PaddingValues(
|
|
|
|
|
start = 6.dp,
|
|
|
|
|
end = 6.dp,
|
|
|
|
|
top = controlsHeight,
|
|
|
|
|
bottom = routes.bottomPadding
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
item {
|
|
|
|
|
Spacer(modifier = Modifier.height(controlsHeight + 12.dp))
|
|
|
|
|
}
|
|
|
|
|
if (displayProperties.isEmpty()) {
|
|
|
|
|
item { EmptyState(isActiveSearch) }
|
|
|
|
|
} else {
|
|
|
|
|
@@ -1537,7 +1500,7 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
) {
|
|
|
|
|
PropertiesView(
|
|
|
|
|
properties = remember {
|
|
|
|
|
configContainer.properties.map { (it.key to it.value).toPropertyPair() }.filter {
|
|
|
|
|
configContainer.properties.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }.filter {
|
|
|
|
|
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
@@ -1549,4 +1512,78 @@ class FeaturesRootSection : Routes.Route() {
|
|
|
|
|
onBack = onBack
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ported Reference Fuzzy Logic
|
|
|
|
|
internal data class SearchEntry(val keyword: String, val tokens: List<String>)
|
|
|
|
|
|
|
|
|
|
internal fun buildSearchEntries(): List<SearchEntry> {
|
|
|
|
|
return allProperties.keys.mapNotNull { key ->
|
|
|
|
|
if (!isSearchVisibleProperty(key)) return@mapNotNull null
|
|
|
|
|
val name = context.translation[key.propertyName()]
|
|
|
|
|
val description = context.translation[key.propertyDescription()]
|
|
|
|
|
val tokens = listOfNotNull(name, description, key.name).map { it.trim() }.filter { it.isNotEmpty() }
|
|
|
|
|
if (tokens.isEmpty()) null else SearchEntry(keyword = name ?: key.name, tokens = tokens)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun levenshtein(a: String, b: String): Int {
|
|
|
|
|
if (a == b) return 0
|
|
|
|
|
if (a.isEmpty()) return b.length
|
|
|
|
|
if (b.isEmpty()) return a.length
|
|
|
|
|
val prev = IntArray(b.length + 1) { it }
|
|
|
|
|
val curr = IntArray(b.length + 1)
|
|
|
|
|
for (i in a.indices) {
|
|
|
|
|
curr[0] = i + 1
|
|
|
|
|
for (j in b.indices) {
|
|
|
|
|
val cost = if (a[i] == b[j]) 0 else 1
|
|
|
|
|
curr[j + 1] = min(min(curr[j] + 1, prev[j + 1] + 1), prev[j] + cost)
|
|
|
|
|
}
|
|
|
|
|
prev.indices.forEach { prev[it] = curr[it] }
|
|
|
|
|
}
|
|
|
|
|
return curr[b.length]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun similarityScore(query: String, target: String): Float {
|
|
|
|
|
val q = query.lowercase()
|
|
|
|
|
val t = target.lowercase()
|
|
|
|
|
val maxLen = max(q.length, t.length)
|
|
|
|
|
if (maxLen == 0) return 1f
|
|
|
|
|
val dist = levenshtein(q, t)
|
|
|
|
|
return 1f - (dist.toFloat() / maxLen.toFloat())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun fuzzySuggest(query: String, entries: List<SearchEntry>): List<String> {
|
|
|
|
|
val q = query.trim()
|
|
|
|
|
if (q.length < 2) return emptyList()
|
|
|
|
|
return entries.map { entry ->
|
|
|
|
|
val best = entry.tokens.maxOfOrNull { similarityScore(q, it) } ?: 0f
|
|
|
|
|
best to entry.keyword
|
|
|
|
|
}.filter { it.first >= 0.45f }
|
|
|
|
|
.sortedWith(compareByDescending<Pair<Float, String>> { it.first }.thenBy { it.second.length })
|
|
|
|
|
.map { it.second }
|
|
|
|
|
.distinct()
|
|
|
|
|
.take(6)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun loadSearchHistory(): List<String> {
|
|
|
|
|
return context.sharedPreferences.getString("features_search_history", "")
|
|
|
|
|
?.split("|")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun saveSearchHistory(history: List<String>) {
|
|
|
|
|
context.sharedPreferences.edit().putString("features_search_history", history.joinToString("|")).apply()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal fun upsertHistory(term: String, history: SnapshotStateList<String>) {
|
|
|
|
|
val cleaned = term.trim()
|
|
|
|
|
if (cleaned.isEmpty()) return
|
|
|
|
|
val existingIndex = history.indexOfFirst { it.equals(cleaned, ignoreCase = true) }
|
|
|
|
|
if (existingIndex >= 0) history.removeAt(existingIndex)
|
|
|
|
|
history.add(0, cleaned)
|
|
|
|
|
while (history.size > 12) history.removeLast()
|
|
|
|
|
saveSearchHistory(history.toList())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|