diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt index 0ff1554f..a9e63e9e 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt @@ -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() { } } } + + + + diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index 1db6161f..0e5efa7b 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -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, it.value as PropertyValue) 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) - - internal fun buildSearchEntries(): List { - 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): List { - 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> { it.first }.thenBy { it.second.length }) - .map { it.second } - .distinct() - .take(6) - } - - internal fun loadSearchHistory(): List { - return context.sharedPreferences - .getString("features_search_history", "") - ?.split("|") - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - ?: emptyList() - } - - internal fun saveSearchHistory(history: List) { - context.sharedPreferences.edit() - .putString("features_search_history", history.joinToString("|")) - .apply() - } - - internal fun upsertHistory(term: String, history: SnapshotStateList) { - 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, it.value as PropertyValue) } 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 = 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 = 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(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().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, it.value as PropertyValue) } } 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, it.value as PropertyValue) }.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) + + internal fun buildSearchEntries(): List { + 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): List { + 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> { it.first }.thenBy { it.second.length }) + .map { it.second } + .distinct() + .take(6) + } + + internal fun loadSearchHistory(): List { + return context.sharedPreferences.getString("features_search_history", "") + ?.split("|")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + } + + internal fun saveSearchHistory(history: List) { + context.sharedPreferences.edit().putString("features_search_history", history.joinToString("|")).apply() + } + + internal fun upsertHistory(term: String, history: SnapshotStateList) { + 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()) + } } + + + diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt index c9526139..1e7a2f52 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt @@ -776,3 +776,4 @@ class HomeRootSection : Routes.Route() { } } + diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt index 2b65a481..964c8514 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt @@ -899,3 +899,4 @@ class ScriptingRootSection : Routes.Route() { override val topBarActions: @Composable() (RowScope.() -> Unit) = {} } + diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt index c842d32e..4bb4b811 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt @@ -568,3 +568,4 @@ class SocialRootSection : Routes.Route() { } } } + diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index de783c20..5410da6e 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -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",