feat: implement search history management with SearchHistoryStorage and SearchHistoryRepository for improved user experience

This commit is contained in:
tapframe 2026-03-30 20:46:44 +05:30
parent b82d9caced
commit fb1fa766c6
9 changed files with 297 additions and 3 deletions

View file

@ -12,6 +12,7 @@ import com.nuvio.app.features.library.LibraryStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.profiles.ProfileStorage
import com.nuvio.app.features.search.SearchHistoryStorage
import com.nuvio.app.features.settings.ThemeSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.watched.WatchedStorage
@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() {
HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
SearchHistoryStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)

View file

@ -0,0 +1,26 @@
package com.nuvio.app.features.search
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
actual object SearchHistoryStorage {
private const val preferencesName = "nuvio_search_history"
private const val payloadKey = "search_history_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(payloadKey), payload)
?.apply()
}
}

View file

@ -8,6 +8,7 @@ import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.search.SearchHistoryRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watched.WatchedRepository
@ -124,6 +125,7 @@ object ProfileRepository {
HomeCatalogSettingsRepository.onProfileChanged()
ContinueWatchingPreferencesRepository.onProfileChanged()
TmdbSettingsRepository.onProfileChanged()
SearchHistoryRepository.onProfileChanged()
}
suspend fun pushProfiles(profiles: List<ProfilePushPayload>) {

View file

@ -0,0 +1,98 @@
package com.nuvio.app.features.search
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object SearchHistoryRepository {
private const val MAX_RECENT_SEARCHES = 10
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _uiState = MutableStateFlow<List<String>>(emptyList())
val uiState: StateFlow<List<String>> = _uiState.asStateFlow()
private var hasLoaded = false
private var recentSearches: List<String> = emptyList()
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
fun onProfileChanged() {
loadFromDisk()
}
fun recordSearch(query: String) {
ensureLoaded()
val normalizedQuery = query.trim()
if (normalizedQuery.length < 2) return
val updatedSearches = applySearchHistoryEntry(
current = recentSearches,
query = normalizedQuery,
limit = MAX_RECENT_SEARCHES,
)
if (updatedSearches == recentSearches) return
recentSearches = updatedSearches
publish()
persist()
}
fun removeSearch(query: String) {
ensureLoaded()
val updatedSearches = recentSearches.filterNot { it == query }
if (updatedSearches == recentSearches) return
recentSearches = updatedSearches
publish()
persist()
}
private fun loadFromDisk() {
hasLoaded = true
val payload = SearchHistoryStorage.loadPayload().orEmpty().trim()
recentSearches = if (payload.isEmpty()) {
emptyList()
} else {
runCatching {
json.decodeFromString<List<String>>(payload)
}.getOrDefault(emptyList())
.map { it.trim() }
.filter { it.length >= 2 }
.distinct()
.take(MAX_RECENT_SEARCHES)
}
publish()
}
private fun publish() {
_uiState.value = recentSearches
}
private fun persist() {
SearchHistoryStorage.savePayload(json.encodeToString(recentSearches))
}
}
internal fun applySearchHistoryEntry(
current: List<String>,
query: String,
limit: Int,
): List<String> =
buildList {
add(query)
current.forEach { existing ->
if (existing != query && size < limit) {
add(existing)
}
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.search
internal expect object SearchHistoryStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -1,12 +1,26 @@
package com.nuvio.app.features.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.History
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@ -17,6 +31,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioInputField
@ -42,11 +59,13 @@ fun SearchScreen(
LaunchedEffect(Unit) {
AddonRepository.initialize()
WatchedRepository.ensureLoaded()
SearchHistoryRepository.ensureLoaded()
}
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
val listState = rememberLazyListState()
@ -115,6 +134,12 @@ fun SearchScreen(
}
}
LaunchedEffect(query, uiState.sections) {
val normalizedQuery = query.trim()
if (normalizedQuery.isBlank() || uiState.sections.isEmpty()) return@LaunchedEffect
SearchHistoryRepository.recordSearch(normalizedQuery)
}
NuvioScreen(
horizontalPadding = 0.dp,
listState = listState,
@ -145,6 +170,15 @@ fun SearchScreen(
}
if (query.isBlank()) {
if (recentSearches.isNotEmpty()) {
item(key = "recent_searches") {
SearchRecentSection(
recentSearches = recentSearches,
onSearchPress = { recentQuery -> query = recentQuery },
onRemoveSearch = SearchHistoryRepository::removeSearch,
)
}
}
discoverContent(
state = discoverUiState,
onTypeSelected = SearchRepository::selectDiscoverType,
@ -227,3 +261,86 @@ private fun SearchEmptyStateCard(
message = message,
)
}
@Composable
private fun SearchRecentSection(
recentSearches: List<String>,
onSearchPress: (String) -> Unit,
onRemoveSearch: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Recent Searches",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(modifier = Modifier.height(4.dp))
recentSearches.forEach { recentQuery ->
SearchRecentRow(
query = recentQuery,
onSearchPress = { onSearchPress(recentQuery) },
onRemovePress = { onRemoveSearch(recentQuery) },
)
}
Spacer(modifier = Modifier.height(6.dp))
}
}
@Composable
private fun SearchRecentRow(
query: String,
onSearchPress: () -> Unit,
onRemovePress: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onSearchPress)
.padding(vertical = 2.dp)
.background(
color = MaterialTheme.colorScheme.background,
shape = RoundedCornerShape(16.dp),
)
.padding(start = 2.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(999.dp),
)
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Rounded.History,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = query,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
IconButton(onClick = onRemovePress) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove recent search",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View file

@ -435,9 +435,9 @@ internal data class TmdbEnrichment(
val language: String?,
val productionCompanies: List<MetaCompany>,
val networks: List<MetaCompany>,
val collectionName: String?,
val collectionItems: List<MetaPreview>,
val moreLikeThis: List<MetaPreview>,
val collectionName: String? = null,
val collectionItems: List<MetaPreview> = emptyList(),
val moreLikeThis: List<MetaPreview> = emptyList(),
) {
fun hasContent(): Boolean =
localizedTitle != null ||

View file

@ -0,0 +1,28 @@
package com.nuvio.app.features.search
import kotlin.test.Test
import kotlin.test.assertEquals
class SearchHistoryRepositoryTest {
@Test
fun `applySearchHistoryEntry moves existing query to front`() {
val updated = applySearchHistoryEntry(
current = listOf("batman", "dune", "silo"),
query = "dune",
limit = 10,
)
assertEquals(listOf("dune", "batman", "silo"), updated)
}
@Test
fun `applySearchHistoryEntry respects max size`() {
val updated = applySearchHistoryEntry(
current = listOf("a", "b", "c"),
query = "d",
limit = 3,
)
assertEquals(listOf("d", "a", "b"), updated)
}
}

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.search
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
actual object SearchHistoryStorage {
private const val payloadKey = "search_history_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
}
}