diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt index a0b0194c7..3c7c0c903 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt @@ -6,9 +6,9 @@ import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.tab.LocalTabNavigator @@ -59,7 +59,7 @@ data object BrowseTab : Tab { // Hoisted for extensions tab's search bar val extensionsViewModel = viewModel() - val extensionsState by extensionsViewModel.state.collectAsState() + val extensionsSearchQuery by extensionsViewModel.searchQuery.collectAsStateWithLifecycle() val tabs = listOf( sourcesTab(), @@ -73,7 +73,7 @@ data object BrowseTab : Tab { titleRes = MR.strings.browse, tabs = tabs, state = state, - searchQuery = extensionsState.searchQuery, + searchQuery = extensionsSearchQuery, onChangeSearchQuery = extensionsViewModel::search, ) LaunchedEffect(Unit) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt index 35c357919..7d556799a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt @@ -5,12 +5,12 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.presentation.browse.ExtensionScreen @@ -31,12 +31,12 @@ fun extensionsTab( val navigator = LocalNavigator.currentOrThrow val context = LocalContext.current - val state by extensionsViewModel.state.collectAsState() + val updatesCount by extensionsViewModel.updatesCount.collectAsStateWithLifecycle() var privateExtensionToUninstall by remember { mutableStateOf(null) } return TabContent( titleRes = MR.strings.label_extensions, - badgeNumber = state.updates.takeIf { it > 0 }, + badgeNumber = updatesCount.takeIf { it > 0 }, searchEnabled = true, actions = listOf( AppBar.OverflowAction( @@ -49,6 +49,8 @@ fun extensionsTab( ), ), content = { contentPadding, _ -> + val state by extensionsViewModel.state.collectAsStateWithLifecycle() + BackHandler(enabled = state.searchQuery != null) { extensionsViewModel.search(null) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt index 62ee9b7d0..3af566ccb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.browse.extension import android.app.Application import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.icerock.moko.resources.StringResource import eu.kanade.domain.base.BasePreferences @@ -12,22 +13,24 @@ import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import mihon.core.viewmodel.StateViewModel import tachiyomi.core.common.util.lang.launchIO import tachiyomi.i18n.MR import uy.kohesive.injekt.Injekt @@ -35,75 +38,86 @@ import uy.kohesive.injekt.api.get import kotlin.time.Duration.Companion.seconds class ExtensionsViewModel( - preferences: SourcePreferences = Injekt.get(), + private val preferences: SourcePreferences = Injekt.get(), basePreferences: BasePreferences = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(), private val getExtensions: GetExtensionsByType = Injekt.get(), -) : StateViewModel(State()) { +) : ViewModel() { private val currentDownloads = MutableStateFlow>(hashMapOf()) + private val context = Injekt.get() + + // Public so BrowseTab's search bar can observe it without subscribing to the whole state. + val searchQuery: StateFlow + field = MutableStateFlow(null) + + // Public so the tab badge can observe it without subscribing to the whole state. + val updatesCount = preferences.extensionUpdatesCount.changes() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), 0) + + private val isRefreshing = MutableStateFlow(false) + + private fun extensionMapper(map: Map): (Extension) -> ExtensionUiModel.Item = { + ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle) + } + + @Suppress("LocalVariableName") + private val items = combine( + searchQuery + .debounce(0.25.seconds) + .map { searchQueryPredicate(it ?: "") }, + currentDownloads, + getExtensions.subscribe(), + ) { predicate, downloads, (_updates, _installed, _available, _untrusted) -> + buildMap { + val updates = _updates.filter(predicate).map(extensionMapper(downloads)) + if (updates.isNotEmpty()) { + put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates) + } + + val installed = _installed.filter(predicate).map(extensionMapper(downloads)) + val untrusted = _untrusted.filter(predicate).map(extensionMapper(downloads)) + if (installed.isNotEmpty() || untrusted.isNotEmpty()) { + put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), installed + untrusted) + } + + val languagesWithExtensions = _available + .filter(predicate) + .groupBy { it.lang } + .toSortedMap(LocaleHelper.comparator) + .map { (lang, exts) -> + ExtensionUiModel.Header.Text(LocaleHelper.getSourceDisplayName(lang, context)) to + exts.map(extensionMapper(downloads)) + } + if (languagesWithExtensions.isNotEmpty()) { + putAll(languagesWithExtensions) + } + } + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) + + val state: StateFlow = combine( + items, + searchQuery, + isRefreshing, + preferences.extensionUpdatesCount.changes(), + basePreferences.extensionInstaller.changes(), + ) { items, searchQuery, isRefreshing, updates, installer -> + State( + isLoading = items == null, + isRefreshing = isRefreshing, + items = items.orEmpty(), + updates = updates, + installer = installer, + searchQuery = searchQuery, + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) + init { - val context = Injekt.get() - val extensionMapper: (Map) -> ((Extension) -> ExtensionUiModel.Item) = { map -> - { - ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle) - } - } - - viewModelScope.launchIO { - combine( - state.map { it.searchQuery } - .distinctUntilChanged() - .debounce(0.25.seconds) - .map { searchQueryPredicate(it ?: "") }, - currentDownloads, - getExtensions.subscribe(), - ) { predicate, downloads, (_updates, _installed, _available, _untrusted) -> - buildMap { - val updates = _updates.filter(predicate).map(extensionMapper(downloads)) - if (updates.isNotEmpty()) { - put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates) - } - - val installed = _installed.filter(predicate).map(extensionMapper(downloads)) - val untrusted = _untrusted.filter(predicate).map(extensionMapper(downloads)) - if (installed.isNotEmpty() || untrusted.isNotEmpty()) { - put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), installed + untrusted) - } - - val languagesWithExtensions = _available - .filter(predicate) - .groupBy { it.lang } - .toSortedMap(LocaleHelper.comparator) - .map { (lang, exts) -> - ExtensionUiModel.Header.Text(LocaleHelper.getSourceDisplayName(lang, context)) to - exts.map(extensionMapper(downloads)) - } - if (languagesWithExtensions.isNotEmpty()) { - putAll(languagesWithExtensions) - } - } - } - .collectLatest { items -> - mutableState.update { state -> - state.copy( - isLoading = false, - items = items, - ) - } - } - } - viewModelScope.launchIO { findAvailableExtensions() } - - preferences.extensionUpdatesCount.changes() - .onEach { mutableState.update { state -> state.copy(updates = it) } } - .launchIn(viewModelScope) - - basePreferences.extensionInstaller.changes() - .onEach { mutableState.update { state -> state.copy(installer = it) } } - .launchIn(viewModelScope) } fun searchQueryPredicate(query: String): (Extension) -> Boolean { @@ -137,9 +151,7 @@ class ExtensionsViewModel( } fun search(query: String?) { - mutableState.update { - it.copy(searchQuery = query) - } + searchQuery.update { query } } fun updateAllExtensions() { @@ -190,14 +202,14 @@ class ExtensionsViewModel( fun findAvailableExtensions() { viewModelScope.launchIO { - mutableState.update { it.copy(isRefreshing = true) } + isRefreshing.update { true } extensionManager.findAvailableExtensions() // Fake slower refresh so it doesn't seem like it's not doing anything delay(1.seconds) - mutableState.update { it.copy(isRefreshing = false) } + isRefreshing.update { false } } }