Aphelion Theme Harmonization across tasks page

This commit is contained in:
DarkKnight2122
2026-03-13 03:54:17 +05:30
parent 9df49f1bff
commit d1fc97cee8
6 changed files with 240 additions and 189 deletions

View File

@@ -202,7 +202,6 @@ class TasksRootSection : Routes.Route() {
}
}
activeTasks = listOf()
context.taskManager.getActiveTasks().clear()
}
}
@@ -433,7 +432,14 @@ class TasksRootSection : Routes.Route() {
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
}
key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@TasksRootSection.TasksScreen(nav) } }
key(themeId) {
LaunchedEffect(themeId) {
routes.navigation?.globalScrollOffset = 0
}
with(ManagerTheme.fromId(themeId).theme) {
this@TasksRootSection.TasksScreen(nav)
}
}
}
override val topBarActions: @Composable RowScope.() -> Unit = {
@@ -482,3 +488,7 @@ class TasksRootSection : Routes.Route() {
}
}
}

View File

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

View File

@@ -776,3 +776,4 @@ class HomeRootSection : Routes.Route() {
}
}

View File

@@ -899,3 +899,4 @@ class ScriptingRootSection : Routes.Route() {
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
}

View File

@@ -568,3 +568,4 @@ class SocialRootSection : Routes.Route() {
}
}
}

View File

@@ -336,6 +336,7 @@
"summary_active": "{active} active \u00b7 {recent} recent",
"summary_idle": "Idle \u00b7 {recent} recent",
"running_count": "{count} running",
"tasks_tagline": "Monitor and manage background actions",
"clear_button_description": "Clear tasks",
"failed_to_open_file": "Failed to open file",
"merge_files_toast": "Merging {count} files",