From fb1fa766c6cbd9670c3ffe5a20e9a0d6d0a80daa Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:46:44 +0530 Subject: [PATCH] feat: implement search history management with SearchHistoryStorage and SearchHistoryRepository for improved user experience --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../search/SearchHistoryStorage.android.kt | 26 ++++ .../features/profiles/ProfileRepository.kt | 2 + .../search/SearchHistoryRepository.kt | 98 +++++++++++++++ .../features/search/SearchHistoryStorage.kt | 6 + .../nuvio/app/features/search/SearchScreen.kt | 117 ++++++++++++++++++ .../app/features/tmdb/TmdbMetadataService.kt | 6 +- .../search/SearchHistoryRepositoryTest.kt | 28 +++++ .../search/SearchHistoryStorage.ios.kt | 15 +++ 9 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchHistoryRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/search/SearchHistoryRepositoryTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index e2fa44243..3aefe1ada 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.android.kt new file mode 100644 index 000000000..f351c4bad --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index d1b7a8baa..f5f75726c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchHistoryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchHistoryRepository.kt new file mode 100644 index 000000000..ae40b9909 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchHistoryRepository.kt @@ -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>(emptyList()) + val uiState: StateFlow> = _uiState.asStateFlow() + + private var hasLoaded = false + private var recentSearches: List = 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>(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, + query: String, + limit: Int, +): List = + buildList { + add(query) + current.forEach { existing -> + if (existing != query && size < limit) { + add(existing) + } + } + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.kt new file mode 100644 index 000000000..8f3f5f360 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.search + +internal expect object SearchHistoryStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index b099adf35..60c78976a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -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, + 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, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index c79f88083..575292646 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -435,9 +435,9 @@ internal data class TmdbEnrichment( val language: String?, val productionCompanies: List, val networks: List, - val collectionName: String?, - val collectionItems: List, - val moreLikeThis: List, + val collectionName: String? = null, + val collectionItems: List = emptyList(), + val moreLikeThis: List = emptyList(), ) { fun hasContent(): Boolean = localizedTitle != null || diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/search/SearchHistoryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/search/SearchHistoryRepositoryTest.kt new file mode 100644 index 000000000..5bb1a4c91 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/search/SearchHistoryRepositoryTest.kt @@ -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) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.ios.kt new file mode 100644 index 000000000..bec4301cc --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.ios.kt @@ -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)) + } +}