mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: implement search history management with SearchHistoryStorage and SearchHistoryRepository for improved user experience
This commit is contained in:
parent
b82d9caced
commit
fb1fa766c6
9 changed files with 297 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.search
|
||||
|
||||
internal expect object SearchHistoryStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue