Use Voyager on Extension Details screen (#8576)

This commit is contained in:
Andreas 2022-11-20 20:36:03 +01:00 committed by GitHub
parent b7fa25777d
commit f1b85ff39d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 325 additions and 264 deletions

View file

@ -3,7 +3,6 @@ package eu.kanade.domain.extension.interactor
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -30,3 +29,9 @@ class GetExtensionSources(
} }
} }
} }
data class ExtensionSourceItem(
val source: Source,
val enabled: Boolean,
val labelAsName: Boolean,
)

View file

@ -38,19 +38,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.domain.extension.interactor.ExtensionSourceItem
import eu.kanade.presentation.browse.components.ExtensionIcon import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DIVIDER_ALPHA import eu.kanade.presentation.components.DIVIDER_ALPHA
import eu.kanade.presentation.components.Divider import eu.kanade.presentation.components.Divider
import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.Scaffold import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.components.ScrollbarLazyColumn
import eu.kanade.presentation.components.WarningBanner import eu.kanade.presentation.components.WarningBanner
@ -60,18 +59,22 @@ import eu.kanade.presentation.util.padding
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsPresenter import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsState
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable @Composable
fun ExtensionDetailsScreen( fun ExtensionDetailsScreen(
navigateUp: () -> Unit, navigateUp: () -> Unit,
presenter: ExtensionDetailsPresenter, state: ExtensionDetailsState,
onClickSourcePreferences: (sourceId: Long) -> Unit, onClickSourcePreferences: (sourceId: Long) -> Unit,
onClickWhatsNew: () -> Unit,
onClickReadme: () -> Unit,
onClickEnableAll: () -> Unit,
onClickDisableAll: () -> Unit,
onClickClearCookies: () -> Unit,
onClickUninstall: () -> Unit,
onClickSource: (sourceId: Long) -> Unit,
) { ) {
val uriHandler = LocalUriHandler.current
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
AppBar( AppBar(
@ -80,19 +83,19 @@ fun ExtensionDetailsScreen(
actions = { actions = {
AppBarActions( AppBarActions(
actions = buildList { actions = buildList {
if (presenter.extension?.isUnofficial == false) { if (state.extension?.isUnofficial == false) {
add( add(
AppBar.Action( AppBar.Action(
title = stringResource(R.string.whats_new), title = stringResource(R.string.whats_new),
icon = Icons.Outlined.History, icon = Icons.Outlined.History,
onClick = { uriHandler.openUri(presenter.getChangelogUrl()) }, onClick = onClickWhatsNew,
), ),
) )
add( add(
AppBar.Action( AppBar.Action(
title = stringResource(R.string.action_faq_and_guides), title = stringResource(R.string.action_faq_and_guides),
icon = Icons.Outlined.HelpOutline, icon = Icons.Outlined.HelpOutline,
onClick = { uriHandler.openUri(presenter.getReadmeUrl()) }, onClick = onClickReadme,
), ),
) )
} }
@ -100,15 +103,15 @@ fun ExtensionDetailsScreen(
listOf( listOf(
AppBar.OverflowAction( AppBar.OverflowAction(
title = stringResource(R.string.action_enable_all), title = stringResource(R.string.action_enable_all),
onClick = { presenter.toggleSources(true) }, onClick = onClickEnableAll,
), ),
AppBar.OverflowAction( AppBar.OverflowAction(
title = stringResource(R.string.action_disable_all), title = stringResource(R.string.action_disable_all),
onClick = { presenter.toggleSources(false) }, onClick = onClickDisableAll,
), ),
AppBar.OverflowAction( AppBar.OverflowAction(
title = stringResource(R.string.pref_clear_cookies), title = stringResource(R.string.pref_clear_cookies),
onClick = { presenter.clearCookies() }, onClick = onClickClearCookies,
), ),
), ),
) )
@ -119,25 +122,36 @@ fun ExtensionDetailsScreen(
) )
}, },
) { paddingValues -> ) { paddingValues ->
ExtensionDetails(paddingValues, presenter, onClickSourcePreferences)
if (state.extension == null) {
EmptyScreen(
textResource = R.string.empty_screen,
modifier = Modifier.padding(paddingValues),
)
return@Scaffold
}
ExtensionDetails(
contentPadding = paddingValues,
extension = state.extension,
sources = state.sources,
onClickSourcePreferences = onClickSourcePreferences,
onClickUninstall = onClickUninstall,
onClickSource = onClickSource,
)
} }
} }
@Composable @Composable
private fun ExtensionDetails( private fun ExtensionDetails(
contentPadding: PaddingValues, contentPadding: PaddingValues,
presenter: ExtensionDetailsPresenter, extension: Extension.Installed,
sources: List<ExtensionSourceItem>,
onClickSourcePreferences: (sourceId: Long) -> Unit, onClickSourcePreferences: (sourceId: Long) -> Unit,
onClickUninstall: () -> Unit,
onClickSource: (sourceId: Long) -> Unit,
) { ) {
when {
presenter.isLoading -> LoadingScreen()
presenter.extension == null -> EmptyScreen(
textResource = R.string.empty_screen,
modifier = Modifier.padding(contentPadding),
)
else -> {
val context = LocalContext.current val context = LocalContext.current
val extension = presenter.extension
var showNsfwWarning by remember { mutableStateOf(false) } var showNsfwWarning by remember { mutableStateOf(false) }
ScrollbarLazyColumn( ScrollbarLazyColumn(
@ -157,7 +171,7 @@ private fun ExtensionDetails(
item { item {
DetailsHeader( DetailsHeader(
extension = extension, extension = extension,
onClickUninstall = { presenter.uninstallExtension() }, onClickUninstall = onClickUninstall,
onClickAppInfo = { onClickAppInfo = {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", extension.pkgName, null) data = Uri.fromParts("package", extension.pkgName, null)
@ -171,14 +185,14 @@ private fun ExtensionDetails(
} }
items( items(
items = presenter.sources, items = sources,
key = { it.source.id }, key = { it.source.id },
) { source -> ) { source ->
SourceSwitchPreference( SourceSwitchPreference(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
source = source, source = source,
onClickSourcePreferences = onClickSourcePreferences, onClickSourcePreferences = onClickSourcePreferences,
onClickSource = { presenter.toggleSource(it) }, onClickSource = onClickSource,
) )
} }
} }
@ -190,8 +204,6 @@ private fun ExtensionDetails(
) )
} }
} }
}
}
@Composable @Composable
private fun DetailsHeader( private fun DetailsHeader(

View file

@ -1,25 +0,0 @@
package eu.kanade.presentation.browse
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
@Stable
interface ExtensionDetailsState {
val isLoading: Boolean
val extension: Extension.Installed?
val sources: List<ExtensionSourceItem>
}
fun ExtensionDetailsState(): ExtensionDetailsState {
return ExtensionDetailsStateImpl()
}
class ExtensionDetailsStateImpl : ExtensionDetailsState {
override var isLoading: Boolean by mutableStateOf(true)
override var extension: Extension.Installed? by mutableStateOf(null)
override var sources: List<ExtensionSourceItem> by mutableStateOf(emptyList())
}

View file

@ -1,36 +1,26 @@
package eu.kanade.tachiyomi.ui.browse.extension.details package eu.kanade.tachiyomi.ui.browse.extension.details
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.presentation.browse.ExtensionDetailsScreen import eu.kanade.presentation.browse.ExtensionDetailsScreen
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
@SuppressLint("RestrictedApi") private const val PKGNAME_KEY = "pkg_name"
class ExtensionDetailsController(
bundle: Bundle? = null,
) : FullComposeController<ExtensionDetailsPresenter>(bundle) {
constructor(pkgName: String) : this( class ExtensionDetailsController : BasicFullComposeController {
bundleOf(PKGNAME_KEY to pkgName),
)
override fun createPresenter() = ExtensionDetailsPresenter(args.getString(PKGNAME_KEY)!!) @Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getString(PKGNAME_KEY)!!)
constructor(pkgName: String) : super(bundleOf(PKGNAME_KEY to pkgName))
val pkgName: String
get() = args.getString(PKGNAME_KEY)!!
@Composable @Composable
override fun ComposeContent() { override fun ComposeContent() {
ExtensionDetailsScreen( Navigator(screen = ExtensionDetailsScreen(pkgName = pkgName))
navigateUp = router::popCurrentController,
presenter = presenter,
onClickSourcePreferences = { router.pushController(SourcePreferencesController(it)) },
)
}
fun onExtensionUninstalled() {
router.popCurrentController()
} }
} }
private const val PKGNAME_KEY = "pkg_name"

View file

@ -1,145 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
import android.app.Application
import android.os.Bundle
import eu.kanade.domain.extension.interactor.GetExtensionSources
import eu.kanade.domain.source.interactor.ToggleSource
import eu.kanade.presentation.browse.ExtensionDetailsState
import eu.kanade.presentation.browse.ExtensionDetailsStateImpl
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import okhttp3.HttpUrl.Companion.toHttpUrl
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionDetailsPresenter(
private val pkgName: String,
private val state: ExtensionDetailsStateImpl = ExtensionDetailsState() as ExtensionDetailsStateImpl,
private val context: Application = Injekt.get(),
private val getExtensionSources: GetExtensionSources = Injekt.get(),
private val toggleSource: ToggleSource = Injekt.get(),
private val network: NetworkHelper = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
) : BasePresenter<ExtensionDetailsController>(), ExtensionDetailsState by state {
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
presenterScope.launchIO {
extensionManager.installedExtensionsFlow
.map { it.firstOrNull { pkg -> pkg.pkgName == pkgName } }
.collectLatest { extension ->
// If extension is null it's most likely uninstalled
if (extension == null) {
withUIContext {
view?.onExtensionUninstalled()
}
return@collectLatest
}
state.extension = extension
fetchExtensionSources()
}
}
}
private fun CoroutineScope.fetchExtensionSources() {
launchIO {
getExtensionSources.subscribe(extension!!)
.map {
it.sortedWith(
compareBy(
{ item -> item.enabled.not() },
{ item -> if (item.labelAsName) item.source.name else LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase() },
),
)
}
.collectLatest {
state.isLoading = false
state.sources = it
}
}
}
fun getChangelogUrl(): String {
extension ?: return ""
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
if (extension.hasChangelog) {
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md")
}
// Falling back on GitHub commit history because there is no explicit changelog in extension
return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory)
}
fun getReadmeUrl(): String {
extension ?: return ""
if (!extension.hasReadme) {
return "https://tachiyomi.org/help/faq/#extensions"
}
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md")
}
fun clearCookies() {
val urls = extension?.sources
?.filterIsInstance<HttpSource>()
?.map { it.baseUrl }
?.distinct() ?: emptyList()
val cleared = urls.sumOf {
network.cookieManager.remove(it.toHttpUrl())
}
logcat { "Cleared $cleared cookies for: ${urls.joinToString()}" }
}
fun uninstallExtension() {
val extension = extension ?: return
extensionManager.uninstallExtension(extension.pkgName)
}
fun toggleSource(sourceId: Long) {
toggleSource.await(sourceId)
}
fun toggleSources(enable: Boolean) {
extension?.sources
?.map { it.id }
?.let { toggleSource.await(it, enable) }
}
private fun createUrl(url: String, pkgName: String, pkgFactory: String?, path: String = ""): String {
return if (!pkgFactory.isNullOrEmpty()) {
when (path.isEmpty()) {
true -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory"
else -> "$url/multisrc/overrides/$pkgFactory/" + (pkgName.split(".").lastOrNull() ?: "") + path
}
} else {
url + "/src/" + pkgName.replace(".", "/") + path
}
}
}
data class ExtensionSourceItem(
val source: Source,
val enabled: Boolean,
val labelAsName: Boolean,
)
private const val URL_EXTENSION_COMMITS = "https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
private const val URL_EXTENSION_BLOB = "https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master"

View file

@ -0,0 +1,57 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
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.compose.ui.platform.LocalUriHandler
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.ExtensionDetailsScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.ui.base.controller.pushController
import kotlinx.coroutines.flow.collectLatest
class ExtensionDetailsScreen(
private val pkgName: String,
) : Screen {
@Composable
override fun Content() {
val context = LocalContext.current
val screenModel = rememberScreenModel { ExtensionDetailsScreenModel(pkgName = pkgName, context = context) }
val state by screenModel.state.collectAsState()
if (state.isLoading) {
LoadingScreen()
return
}
val router = LocalRouter.currentOrThrow
val uriHandler = LocalUriHandler.current
ExtensionDetailsScreen(
navigateUp = router::popCurrentController,
state = state,
onClickSourcePreferences = { router.pushController(SourcePreferencesController(it)) },
onClickWhatsNew = { uriHandler.openUri(screenModel.getChangelogUrl()) },
onClickReadme = { uriHandler.openUri(screenModel.getReadmeUrl()) },
onClickEnableAll = { screenModel.toggleSources(true) },
onClickDisableAll = { screenModel.toggleSources(false) },
onClickClearCookies = { screenModel.clearCookies() },
onClickUninstall = { screenModel.uninstallExtension() },
onClickSource = { screenModel.toggleSource(it) },
)
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is ExtensionDetailsEvent.Uninstalled) {
router.popCurrentController()
}
}
}
}
}

View file

@ -0,0 +1,167 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
import android.content.Context
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.domain.extension.interactor.ExtensionSourceItem
import eu.kanade.domain.extension.interactor.GetExtensionSources
import eu.kanade.domain.source.interactor.ToggleSource
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
private const val URL_EXTENSION_COMMITS =
"https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
private const val URL_EXTENSION_BLOB =
"https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master"
class ExtensionDetailsScreenModel(
pkgName: String,
context: Context,
private val network: NetworkHelper = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
private val getExtensionSources: GetExtensionSources = Injekt.get(),
private val toggleSource: ToggleSource = Injekt.get(),
) : StateScreenModel<ExtensionDetailsState>(ExtensionDetailsState()) {
private val _events: Channel<ExtensionDetailsEvent> = Channel()
val events: Flow<ExtensionDetailsEvent> = _events.receiveAsFlow()
init {
coroutineScope.launch {
launch {
extensionManager.installedExtensionsFlow
.map { it.firstOrNull { extension -> extension.pkgName == pkgName } }
.collectLatest { extension ->
if (extension == null) {
_events.send(ExtensionDetailsEvent.Uninstalled)
return@collectLatest
}
mutableState.update { state ->
state.copy(extension = extension)
}
}
}
launch {
state.collectLatest { state ->
if (state.extension == null) return@collectLatest
getExtensionSources.subscribe(state.extension)
.map {
it.sortedWith(
compareBy(
{ !it.enabled },
{ item ->
item.source.name.takeIf { item.labelAsName }
?: LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase()
},
),
)
}.collectLatest { sources ->
mutableState.update {
it.copy(
sources = sources,
)
}
}
}
}
}
}
fun getChangelogUrl(): String {
val extension = state.value.extension ?: return ""
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
if (extension.hasChangelog) {
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md")
}
// Falling back on GitHub commit history because there is no explicit changelog in extension
return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory)
}
fun getReadmeUrl(): String {
val extension = state.value.extension ?: return ""
if (!extension.hasReadme) {
return "https://tachiyomi.org/help/faq/#extensions"
}
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md")
}
fun clearCookies() {
val extension = state.value.extension ?: return
val urls = extension.sources
.filterIsInstance<HttpSource>()
.map { it.baseUrl }
.distinct()
val cleared = urls.sumOf {
network.cookieManager.remove(it.toHttpUrl())
}
logcat { "Cleared $cleared cookies for: ${urls.joinToString()}" }
}
fun uninstallExtension() {
val extension = state.value.extension ?: return
extensionManager.uninstallExtension(extension.pkgName)
}
fun toggleSource(sourceId: Long) {
toggleSource.await(sourceId)
}
fun toggleSources(enable: Boolean) {
state.value.extension?.sources
?.map { it.id }
?.let { toggleSource.await(it, enable) }
}
private fun createUrl(
url: String,
pkgName: String,
pkgFactory: String?,
path: String = "",
): String {
return if (!pkgFactory.isNullOrEmpty()) {
when (path.isEmpty()) {
true -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory"
else -> "$url/multisrc/overrides/$pkgFactory/" + (pkgName.split(".").lastOrNull() ?: "") + path
}
} else {
url + "/src/" + pkgName.replace(".", "/") + path
}
}
}
sealed class ExtensionDetailsEvent {
object Uninstalled : ExtensionDetailsEvent()
}
data class ExtensionDetailsState(
val extension: Extension.Installed? = null,
val sources: List<ExtensionSourceItem> = emptyList(),
) {
val isLoading: Boolean
get() = sources.isEmpty()
}