Make ExtensionsViewModel collect extensions only while subscribed (#3729)
Assisted-by: Claude:claude-opus-5
This commit is contained in:
@@ -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<ExtensionsViewModel>()
|
||||
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) {
|
||||
|
||||
@@ -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<Extension?>(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)
|
||||
}
|
||||
|
||||
@@ -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<ExtensionsViewModel.State>(State()) {
|
||||
) : ViewModel() {
|
||||
|
||||
private val currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
|
||||
|
||||
private val context = Injekt.get<Application>()
|
||||
|
||||
// Public so BrowseTab's search bar can observe it without subscribing to the whole state.
|
||||
val searchQuery: StateFlow<String?>
|
||||
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<String, InstallStep>): (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<State> = 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<Application>()
|
||||
val extensionMapper: (Map<String, InstallStep>) -> ((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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user