Przeglądaj źródła

Convert Extension tab to use Compose (#7107)

* Convert Extension tab to use Compose

Co-authored-by: jobobby04 <[email protected]>

* Review changes

Co-authored-by: jobobby04 <[email protected]>
Andreas 2 lat temu
rodzic
commit
3e2d7d76b9
27 zmienionych plików z 899 dodań i 753 usunięć
  1. 1 0
      app/build.gradle.kts
  2. 25 0
      app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt
  3. 5 0
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  4. 25 0
      app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt
  5. 48 0
      app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt
  6. 35 0
      app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt
  7. 91 0
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt
  8. 417 0
      app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt
  9. 0 27
      app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt
  10. 10 18
      app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt
  11. 0 27
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt
  12. 52 160
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt
  13. 0 27
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt
  14. 0 62
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt
  15. 0 84
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt
  16. 0 65
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt
  17. 156 109
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt
  18. 0 43
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt
  19. 32 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt
  20. 0 32
      app/src/main/res/layout/extension_controller.xml
  21. 0 98
      app/src/main/res/layout/extension_item.xml
  22. BIN
      app/src/main/res/mipmap-hdpi/ic_untrusted_source.png
  23. BIN
      app/src/main/res/mipmap-mdpi/ic_untrusted_source.png
  24. BIN
      app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png
  25. BIN
      app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png
  26. BIN
      app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png
  27. 2 1
      gradle/compose.versions.toml

+ 1 - 0
app/build.gradle.kts

@@ -148,6 +148,7 @@ dependencies {
     implementation(compose.animation)
     implementation(compose.ui.tooling)
     implementation(compose.accompanist.webview)
+    implementation(compose.accompanist.swiperefresh)
 
     implementation(androidx.paging.runtime)
     implementation(androidx.paging.compose)

+ 25 - 0
app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt

@@ -0,0 +1,25 @@
+package eu.kanade.core.util
+
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import rx.Observable
+import rx.Observer
+
+fun <T : Any> Observable<T>.asFlow(): Flow<T> = callbackFlow {
+    val observer = object : Observer<T> {
+        override fun onNext(t: T) {
+            trySend(t)
+        }
+
+        override fun onError(e: Throwable) {
+            close(e)
+        }
+
+        override fun onCompleted() {
+            close()
+        }
+    }
+    val subscription = subscribe(observer)
+    awaitClose { subscription.unsubscribe() }
+}

+ 5 - 0
app/src/main/java/eu/kanade/domain/DomainModule.kt

@@ -3,6 +3,8 @@ package eu.kanade.domain
 import eu.kanade.data.history.HistoryRepositoryImpl
 import eu.kanade.data.manga.MangaRepositoryImpl
 import eu.kanade.data.source.SourceRepositoryImpl
+import eu.kanade.domain.extension.interactor.GetExtensionUpdates
+import eu.kanade.domain.extension.interactor.GetExtensions
 import eu.kanade.domain.history.interactor.DeleteHistoryTable
 import eu.kanade.domain.history.interactor.GetHistory
 import eu.kanade.domain.history.interactor.GetNextChapterForManga
@@ -40,6 +42,9 @@ class DomainModule : InjektModule {
         addFactory { RemoveHistoryById(get()) }
         addFactory { RemoveHistoryByMangaId(get()) }
 
+        addFactory { GetExtensions(get(), get()) }
+        addFactory { GetExtensionUpdates(get(), get()) }
+
         addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
         addFactory { GetLanguagesWithSources(get(), get()) }
         addFactory { GetEnabledSources(get(), get()) }

+ 25 - 0
app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt

@@ -0,0 +1,25 @@
+package eu.kanade.domain.extension.interactor
+
+import eu.kanade.core.util.asFlow
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.extension.model.Extension
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+class GetExtensionUpdates(
+    private val preferences: PreferencesHelper,
+    private val extensionManager: ExtensionManager,
+) {
+
+    fun subscribe(): Flow<List<Extension.Installed>> {
+        val showNsfwSources = preferences.showNsfwSource().get()
+
+        return extensionManager.getInstalledExtensionsObservable().asFlow()
+            .map { installed ->
+                installed
+                    .filter { it.hasUpdate && (showNsfwSources || it.isNsfw.not()) }
+                    .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
+            }
+    }
+}

+ 48 - 0
app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt

@@ -0,0 +1,48 @@
+package eu.kanade.domain.extension.interactor
+
+import eu.kanade.core.util.asFlow
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.extension.model.Extension
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+
+typealias ExtensionSegregation = Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
+
+class GetExtensions(
+    private val preferences: PreferencesHelper,
+    private val extensionManager: ExtensionManager,
+) {
+
+    fun subscribe(): Flow<ExtensionSegregation> {
+        val activeLanguages = preferences.enabledLanguages().get()
+        val showNsfwSources = preferences.showNsfwSource().get()
+
+        return combine(
+            extensionManager.getInstalledExtensionsObservable().asFlow(),
+            extensionManager.getUntrustedExtensionsObservable().asFlow(),
+            extensionManager.getAvailableExtensionsObservable().asFlow(),
+        ) { _installed, _untrusted, _available ->
+
+            val installed = _installed
+                .filter { it.hasUpdate.not() && (showNsfwSources || it.isNsfw.not()) }
+                .sortedWith(
+                    compareBy<Extension.Installed> { it.isObsolete.not() }
+                        .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
+                )
+
+            val untrusted = _untrusted
+                .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
+
+            val available = _available
+                .filter { extension ->
+                    _installed.none { it.pkgName == extension.pkgName } &&
+                        _untrusted.none { it.pkgName == extension.pkgName } &&
+                        extension.lang in activeLanguages &&
+                        (showNsfwSources || extension.isNsfw.not())
+                }
+
+            Triple(installed, untrusted, available)
+        }
+    }
+}

+ 35 - 0
app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt

@@ -0,0 +1,35 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import eu.kanade.presentation.util.horizontalPadding
+
+@Composable
+fun BaseBrowseItem(
+    modifier: Modifier = Modifier,
+    onClickItem: () -> Unit = {},
+    onLongClickItem: () -> Unit = {},
+    icon: @Composable RowScope.() -> Unit = {},
+    action: @Composable RowScope.() -> Unit = {},
+    content: @Composable RowScope.() -> Unit = {},
+) {
+    Row(
+        modifier = modifier
+            .combinedClickable(
+                onClick = onClickItem,
+                onLongClick = onLongClickItem,
+            )
+            .padding(horizontal = horizontalPadding, vertical = 8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        icon()
+        content()
+        action()
+    }
+}

+ 91 - 0
app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt

@@ -0,0 +1,91 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.res.imageResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import eu.kanade.domain.source.model.Source
+import eu.kanade.presentation.util.bitmapPainterResource
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.extension.model.Extension
+import eu.kanade.tachiyomi.ui.browse.extension.Result
+import eu.kanade.tachiyomi.ui.browse.extension.getIcon
+
+private val defaultModifier = Modifier
+    .height(40.dp)
+    .aspectRatio(1f)
+
+@Composable
+fun SourceIcon(
+    source: Source,
+    modifier: Modifier = Modifier,
+) {
+    val icon = source.icon
+
+    if (icon != null) {
+        Image(
+            bitmap = icon,
+            contentDescription = "",
+            modifier = modifier.then(defaultModifier),
+        )
+    } else {
+        Image(
+            painter = painterResource(id = R.mipmap.ic_local_source),
+            contentDescription = "",
+            modifier = modifier.then(defaultModifier),
+        )
+    }
+}
+
+@Composable
+fun ExtensionIcon(
+    extension: Extension,
+    modifier: Modifier = Modifier,
+) {
+    when (extension) {
+        is Extension.Available -> {
+            AsyncImage(
+                model = extension.iconUrl,
+                contentDescription = "",
+                placeholder = ColorPainter(Color(0x1F888888)),
+                error = bitmapPainterResource(id = R.drawable.cover_error),
+                modifier = modifier
+                    .clip(RoundedCornerShape(4.dp))
+                    .then(defaultModifier),
+            )
+        }
+        is Extension.Installed -> {
+            val icon by extension.getIcon()
+            when (icon) {
+                Result.Error -> Image(
+                    bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_local_source),
+                    contentDescription = "",
+                    modifier = modifier.then(defaultModifier),
+                )
+                Result.Loading -> Box(modifier = modifier.then(defaultModifier))
+                is Result.Success -> Image(
+                    bitmap = (icon as Result.Success<ImageBitmap>).value,
+                    contentDescription = "",
+                    modifier = modifier.then(defaultModifier),
+                )
+            }
+        }
+        is Extension.Untrusted -> Image(
+            bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_untrusted_source),
+            contentDescription = "",
+            modifier = modifier.then(defaultModifier),
+        )
+    }
+}

+ 417 - 0
app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt

@@ -0,0 +1,417 @@
+package eu.kanade.presentation.extension
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.google.accompanist.swiperefresh.SwipeRefresh
+import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
+import eu.kanade.presentation.browse.components.BaseBrowseItem
+import eu.kanade.presentation.browse.components.ExtensionIcon
+import eu.kanade.presentation.theme.header
+import eu.kanade.presentation.util.horizontalPadding
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.extension.model.Extension
+import eu.kanade.tachiyomi.extension.model.InstallStep
+import eu.kanade.tachiyomi.ui.browse.extension.ExtensionPresenter
+import eu.kanade.tachiyomi.ui.browse.extension.ExtensionState
+import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
+import eu.kanade.tachiyomi.util.system.LocaleHelper
+
+@Composable
+fun ExtensionScreen(
+    nestedScrollInterop: NestedScrollConnection,
+    presenter: ExtensionPresenter,
+    onLongClickItem: (Extension) -> Unit,
+    onClickItemCancel: (Extension) -> Unit,
+    onInstallExtension: (Extension.Available) -> Unit,
+    onUninstallExtension: (Extension) -> Unit,
+    onUpdateExtension: (Extension.Installed) -> Unit,
+    onTrustExtension: (Extension.Untrusted) -> Unit,
+    onOpenExtension: (Extension.Installed) -> Unit,
+    onClickUpdateAll: () -> Unit,
+    onRefresh: () -> Unit,
+    onLaunched: () -> Unit,
+) {
+    val state by presenter.state.collectAsState()
+    val isRefreshing = presenter.isRefreshing
+
+    SwipeRefresh(
+        modifier = Modifier.nestedScroll(nestedScrollInterop),
+        state = rememberSwipeRefreshState(isRefreshing),
+        onRefresh = onRefresh,
+    ) {
+        when (state) {
+            is ExtensionState.Initialized -> {
+                ExtensionContent(
+                    nestedScrollInterop = nestedScrollInterop,
+                    items = (state as ExtensionState.Initialized).list,
+                    onLongClickItem = onLongClickItem,
+                    onClickItemCancel = onClickItemCancel,
+                    onInstallExtension = onInstallExtension,
+                    onUninstallExtension = onUninstallExtension,
+                    onUpdateExtension = onUpdateExtension,
+                    onTrustExtension = onTrustExtension,
+                    onOpenExtension = onOpenExtension,
+                    onClickUpdateAll = onClickUpdateAll,
+                    onLaunched = onLaunched,
+                )
+            }
+            ExtensionState.Uninitialized -> {}
+        }
+    }
+}
+
+@Composable
+fun ExtensionContent(
+    nestedScrollInterop: NestedScrollConnection,
+    items: List<ExtensionUiModel>,
+    onLongClickItem: (Extension) -> Unit,
+    onClickItemCancel: (Extension) -> Unit,
+    onInstallExtension: (Extension.Available) -> Unit,
+    onUninstallExtension: (Extension) -> Unit,
+    onUpdateExtension: (Extension.Installed) -> Unit,
+    onTrustExtension: (Extension.Untrusted) -> Unit,
+    onOpenExtension: (Extension.Installed) -> Unit,
+    onClickUpdateAll: () -> Unit,
+    onLaunched: () -> Unit,
+) {
+    val (trustState, setTrustState) = remember { mutableStateOf<Extension.Untrusted?>(null) }
+    LazyColumn(
+        contentPadding = WindowInsets.navigationBars.asPaddingValues(),
+    ) {
+        items(
+            items = items,
+            key = {
+                when (it) {
+                    is ExtensionUiModel.Header.Resource -> it.textRes
+                    is ExtensionUiModel.Header.Text -> it.text
+                    is ExtensionUiModel.Item -> it.key()
+                }
+            },
+            contentType = {
+                when (it) {
+                    is ExtensionUiModel.Item -> "item"
+                    else -> "header"
+                }
+            },
+        ) { item ->
+            when (item) {
+                is ExtensionUiModel.Header.Resource -> {
+                    val action: @Composable RowScope.() -> Unit =
+                        if (item.textRes == R.string.ext_updates_pending) {
+                            {
+                                Button(onClick = { onClickUpdateAll() }) {
+                                    Text(
+                                        text = stringResource(id = R.string.ext_update_all),
+                                        style = LocalTextStyle.current.copy(
+                                            color = MaterialTheme.colorScheme.onPrimary,
+                                        ),
+                                    )
+                                }
+                            }
+                        } else {
+                            {}
+                        }
+                    ExtensionHeader(
+                        textRes = item.textRes,
+                        modifier = Modifier.animateItemPlacement(),
+                        action = action,
+                    )
+                }
+                is ExtensionUiModel.Header.Text -> {
+                    ExtensionHeader(
+                        text = item.text,
+                        modifier = Modifier.animateItemPlacement(),
+                    )
+                }
+                is ExtensionUiModel.Item -> {
+                    ExtensionItem(
+                        modifier = Modifier.animateItemPlacement(),
+                        item = item,
+                        onClickItem = {
+                            when (it) {
+                                is Extension.Available -> onInstallExtension(it)
+                                is Extension.Installed -> {
+                                    if (it.hasUpdate) {
+                                        onUpdateExtension(it)
+                                    } else {
+                                        onOpenExtension(it)
+                                    }
+                                }
+                                is Extension.Untrusted -> setTrustState(it)
+                            }
+                        },
+                        onLongClickItem = onLongClickItem,
+                        onClickItemCancel = onClickItemCancel,
+                        onClickItemAction = {
+                            when (it) {
+                                is Extension.Available -> onInstallExtension(it)
+                                is Extension.Installed -> {
+                                    if (it.hasUpdate) {
+                                        onUpdateExtension(it)
+                                    } else {
+                                        onOpenExtension(it)
+                                    }
+                                }
+                                is Extension.Untrusted -> setTrustState(it)
+                            }
+                        },
+                    )
+                    LaunchedEffect(Unit) {
+                        onLaunched()
+                    }
+                }
+            }
+        }
+    }
+    if (trustState != null) {
+        ExtensionTrustDialog(
+            onClickConfirm = {
+                onTrustExtension(trustState)
+                setTrustState(null)
+            },
+            onClickDismiss = {
+                onUninstallExtension(trustState)
+                setTrustState(null)
+            },
+            onDismissRequest = {
+                setTrustState(null)
+            },
+        )
+    }
+}
+
+@Composable
+fun ExtensionItem(
+    modifier: Modifier = Modifier,
+    item: ExtensionUiModel.Item,
+    onClickItem: (Extension) -> Unit,
+    onLongClickItem: (Extension) -> Unit,
+    onClickItemCancel: (Extension) -> Unit,
+    onClickItemAction: (Extension) -> Unit,
+) {
+    val (extension, installStep) = item
+    BaseBrowseItem(
+        modifier = modifier
+            .combinedClickable(
+                onClick = { onClickItem(extension) },
+                onLongClick = { onLongClickItem(extension) },
+            ),
+        onClickItem = { onClickItem(extension) },
+        onLongClickItem = { onLongClickItem(extension) },
+        icon = {
+            ExtensionIcon(extension = extension)
+        },
+        action = {
+            ExtensionItemActions(
+                extension = extension,
+                installStep = installStep,
+                onClickItemCancel = onClickItemCancel,
+                onClickItemAction = onClickItemAction,
+            )
+        },
+    ) {
+        ExtensionItemContent(
+            extension = extension,
+            modifier = Modifier.weight(1f),
+        )
+    }
+}
+
+@Composable
+fun ExtensionItemContent(
+    extension: Extension,
+    modifier: Modifier = Modifier,
+) {
+    val context = LocalContext.current
+    val warning = remember(extension) {
+        when {
+            extension is Extension.Untrusted -> R.string.ext_untrusted
+            extension is Extension.Installed && extension.isUnofficial -> R.string.ext_unofficial
+            extension is Extension.Installed && extension.isObsolete -> R.string.ext_obsolete
+            extension.isNsfw -> R.string.ext_nsfw_short
+            else -> null
+        }
+    }
+
+    Column(
+        modifier = modifier.padding(start = horizontalPadding),
+    ) {
+        Text(
+            text = extension.name,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+            style = MaterialTheme.typography.bodyMedium,
+        )
+        Row(
+            horizontalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            if (extension.lang.isNullOrEmpty().not()) {
+                Text(
+                    text = LocaleHelper.getSourceDisplayName(extension.lang, context),
+                    style = MaterialTheme.typography.bodySmall,
+                )
+            }
+
+            if (extension.versionName.isNotEmpty()) {
+                Text(
+                    text = extension.versionName,
+                    style = MaterialTheme.typography.bodySmall,
+                )
+            }
+
+            if (warning != null) {
+                Text(
+                    text = stringResource(id = warning).uppercase(),
+                    style = MaterialTheme.typography.bodySmall.copy(
+                        color = MaterialTheme.colorScheme.error,
+                    ),
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun ExtensionItemActions(
+    extension: Extension,
+    installStep: InstallStep,
+    modifier: Modifier = Modifier,
+    onClickItemCancel: (Extension) -> Unit = {},
+    onClickItemAction: (Extension) -> Unit = {},
+) {
+    val isIdle = remember(installStep) {
+        installStep == InstallStep.Idle || installStep == InstallStep.Error
+    }
+    Row(modifier = modifier) {
+        TextButton(
+            onClick = { onClickItemAction(extension) },
+            enabled = isIdle,
+        ) {
+            Text(
+                text = when (installStep) {
+                    InstallStep.Pending -> stringResource(R.string.ext_pending)
+                    InstallStep.Downloading -> stringResource(R.string.ext_downloading)
+                    InstallStep.Installing -> stringResource(R.string.ext_installing)
+                    InstallStep.Installed -> stringResource(R.string.ext_installed)
+                    InstallStep.Error -> stringResource(R.string.action_retry)
+                    InstallStep.Idle -> {
+                        when (extension) {
+                            is Extension.Installed -> {
+                                if (extension.hasUpdate) {
+                                    stringResource(R.string.ext_update)
+                                } else {
+                                    stringResource(R.string.action_settings)
+                                }
+                            }
+                            is Extension.Untrusted -> stringResource(R.string.ext_trust)
+                            is Extension.Available -> stringResource(R.string.ext_install)
+                        }
+                    }
+                },
+                style = LocalTextStyle.current.copy(
+                    color = if (isIdle) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceTint,
+                ),
+            )
+        }
+        if (isIdle.not()) {
+            IconButton(onClick = { onClickItemCancel(extension) }) {
+                Icon(Icons.Default.Close, "")
+            }
+        }
+    }
+}
+
+@Composable
+fun ExtensionHeader(
+    @StringRes textRes: Int,
+    modifier: Modifier = Modifier,
+    action: @Composable RowScope.() -> Unit = {},
+) {
+    ExtensionHeader(
+        text = stringResource(id = textRes),
+        modifier = modifier,
+        action = action,
+    )
+}
+
+@Composable
+fun ExtensionHeader(
+    text: String,
+    modifier: Modifier = Modifier,
+    action: @Composable RowScope.() -> Unit = {},
+) {
+    Row(
+        modifier = modifier.padding(horizontal = horizontalPadding),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Text(
+            text = text,
+            modifier = Modifier
+                .padding(vertical = 8.dp)
+                .weight(1f),
+            style = MaterialTheme.typography.header,
+        )
+        action()
+    }
+}
+
+@Composable
+fun ExtensionTrustDialog(
+    onClickConfirm: () -> Unit,
+    onClickDismiss: () -> Unit,
+    onDismissRequest: () -> Unit,
+) {
+    AlertDialog(
+        title = {
+            Text(text = stringResource(id = R.string.untrusted_extension))
+        },
+        text = {
+            Text(text = stringResource(id = R.string.untrusted_extension_message))
+        },
+        confirmButton = {
+            TextButton(onClick = onClickConfirm) {
+                Text(text = stringResource(id = R.string.ext_trust))
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onClickDismiss) {
+                Text(text = stringResource(id = R.string.ext_uninstall))
+            }
+        },
+        onDismissRequest = onDismissRequest,
+    )
+}

+ 0 - 27
app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt

@@ -1,13 +1,10 @@
 package eu.kanade.presentation.source
 
-import androidx.compose.foundation.Image
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.asPaddingValues
-import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.LazyColumn
@@ -31,7 +28,6 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import eu.kanade.domain.source.model.Pin
@@ -191,29 +187,6 @@ fun SourceItem(
     )
 }
 
-@Composable
-fun SourceIcon(
-    source: Source,
-) {
-    val icon = source.icon
-    val modifier = Modifier
-        .height(40.dp)
-        .aspectRatio(1f)
-    if (icon != null) {
-        Image(
-            bitmap = icon,
-            contentDescription = "",
-            modifier = modifier,
-        )
-    } else {
-        Image(
-            painter = painterResource(id = R.mipmap.ic_local_source),
-            contentDescription = "",
-            modifier = modifier,
-        )
-    }
-}
-
 @Composable
 fun SourcePinButton(
     isPinned: Boolean,

+ 10 - 18
app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt

@@ -1,19 +1,16 @@
 package eu.kanade.presentation.source.components
 
-import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
 import eu.kanade.domain.source.model.Source
-import eu.kanade.presentation.source.SourceIcon
+import eu.kanade.presentation.browse.components.BaseBrowseItem
+import eu.kanade.presentation.browse.components.SourceIcon
 import eu.kanade.presentation.util.horizontalPadding
 import eu.kanade.tachiyomi.util.system.LocaleHelper
 
@@ -28,19 +25,14 @@ fun BaseSourceItem(
     action: @Composable RowScope.(Source) -> Unit = {},
     content: @Composable RowScope.(Source, Boolean) -> Unit = defaultContent,
 ) {
-    Row(
-        modifier = modifier
-            .combinedClickable(
-                onClick = onClickItem,
-                onLongClick = onLongClickItem,
-            )
-            .padding(horizontal = horizontalPadding, vertical = 8.dp),
-        verticalAlignment = Alignment.CenterVertically,
-    ) {
-        icon.invoke(this, source)
-        content.invoke(this, source, showLanguageInContent)
-        action.invoke(this, source)
-    }
+    BaseBrowseItem(
+        modifier = modifier,
+        onClickItem = onClickItem,
+        onLongClickItem = onLongClickItem,
+        icon = { icon.invoke(this, source) },
+        action = { action.invoke(this, source) },
+        content = { content.invoke(this, source, showLanguageInContent) },
+    )
 }
 
 private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source ->

+ 0 - 27
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt

@@ -1,27 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.IFlexible
-
-/**
- * Adapter that holds the catalogue cards.
- *
- * @param controller instance of [ExtensionController].
- */
-class ExtensionAdapter(controller: ExtensionController) :
-    FlexibleAdapter<IFlexible<*>>(null, controller, true) {
-
-    init {
-        setDisplayHeadersAtStartUp(true)
-    }
-
-    /**
-     * Listener for browse item clicks.
-     */
-    val buttonClickListener: OnButtonClickListener = controller
-
-    interface OnButtonClickListener {
-        fun onButtonClick(position: Int)
-        fun onCancelButtonClick(position: Int)
-    }
-}

+ 52 - 160
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt

@@ -1,48 +1,30 @@
 package eu.kanade.tachiyomi.ui.browse.extension
 
-import android.view.LayoutInflater
 import android.view.Menu
 import android.view.MenuInflater
 import android.view.MenuItem
-import android.view.View
 import androidx.appcompat.widget.SearchView
-import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import com.bluelinelabs.conductor.ControllerChangeHandler
 import com.bluelinelabs.conductor.ControllerChangeType
-import dev.chrisbanes.insetter.applyInsetter
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.IFlexible
+import eu.kanade.presentation.extension.ExtensionScreen
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.databinding.ExtensionControllerBinding
 import eu.kanade.tachiyomi.extension.model.Extension
-import eu.kanade.tachiyomi.source.online.HttpSource
-import eu.kanade.tachiyomi.ui.base.controller.NucleusController
+import eu.kanade.tachiyomi.ui.base.controller.ComposeController
 import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.browse.BrowseController
 import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController
-import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import reactivecircus.flowbinding.appcompat.queryTextChanges
-import reactivecircus.flowbinding.swiperefreshlayout.refreshes
 
 /**
  * Controller to manage the catalogues available in the app.
  */
 open class ExtensionController :
-    NucleusController<ExtensionControllerBinding, ExtensionPresenter>(),
-    ExtensionAdapter.OnButtonClickListener,
-    FlexibleAdapter.OnItemClickListener,
-    FlexibleAdapter.OnItemLongClickListener,
-    ExtensionTrustDialog.Listener {
-
-    /**
-     * Adapter containing the list of manga from the catalogue.
-     */
-    private var adapter: FlexibleAdapter<IFlexible<*>>? = null
-
-    private var extensions: List<ExtensionItem> = emptyList()
+    ComposeController<ExtensionPresenter>() {
 
     private var query = ""
 
@@ -50,42 +32,54 @@ open class ExtensionController :
         setHasOptionsMenu(true)
     }
 
-    override fun getTitle(): String? {
-        return applicationContext?.getString(R.string.label_extensions)
-    }
-
-    override fun createPresenter(): ExtensionPresenter {
-        return ExtensionPresenter()
-    }
-
-    override fun createBinding(inflater: LayoutInflater) =
-        ExtensionControllerBinding.inflate(inflater)
-
-    override fun onViewCreated(view: View) {
-        super.onViewCreated(view)
-
-        binding.recycler.applyInsetter {
-            type(navigationBars = true) {
-                padding()
-            }
-        }
-
-        binding.swipeRefresh.isRefreshing = true
-        binding.swipeRefresh.refreshes()
-            .onEach { presenter.findAvailableExtensions() }
-            .launchIn(viewScope)
+    override fun getTitle(): String? =
+        applicationContext?.getString(R.string.label_extensions)
 
-        // Initialize adapter, scroll listener and recycler views
-        adapter = ExtensionAdapter(this)
-        // Create recycler and set adapter.
-        binding.recycler.layoutManager = LinearLayoutManager(view.context)
-        binding.recycler.adapter = adapter
-        adapter?.fastScroller = binding.fastScroller
-    }
+    override fun createPresenter(): ExtensionPresenter =
+        ExtensionPresenter()
 
-    override fun onDestroyView(view: View) {
-        adapter = null
-        super.onDestroyView(view)
+    @Composable
+    override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
+        ExtensionScreen(
+            nestedScrollInterop = nestedScrollInterop,
+            presenter = presenter,
+            onLongClickItem = { extension ->
+                when (extension) {
+                    is Extension.Available -> presenter.installExtension(extension)
+                    else -> presenter.uninstallExtension(extension.pkgName)
+                }
+            },
+            onClickItemCancel = { extension ->
+                presenter.cancelInstallUpdateExtension(extension)
+            },
+            onClickUpdateAll = {
+                presenter.updateAllExtensions()
+            },
+            onLaunched = {
+                val ctrl = parentController as BrowseController
+                ctrl.setExtensionUpdateBadge()
+                ctrl.extensionListUpdateRelay.call(true)
+            },
+            onInstallExtension = {
+                presenter.installExtension(it)
+            },
+            onOpenExtension = {
+                val controller = ExtensionDetailsController(it.pkgName)
+                parentController!!.router.pushController(controller)
+            },
+            onTrustExtension = {
+                presenter.trustSignature(it.signatureHash)
+            },
+            onUninstallExtension = {
+                presenter.uninstallExtension(it.pkgName)
+            },
+            onUpdateExtension = {
+                presenter.updateExtension(it)
+            },
+            onRefresh = {
+                presenter.findAvailableExtensions()
+            },
+        )
     }
 
     override fun onOptionsItemSelected(item: MenuItem): Boolean {
@@ -105,26 +99,6 @@ open class ExtensionController :
         }
     }
 
-    override fun onButtonClick(position: Int) {
-        val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
-        when (extension) {
-            is Extension.Available -> presenter.installExtension(extension)
-            is Extension.Untrusted -> openTrustDialog(extension)
-            is Extension.Installed -> {
-                if (!extension.hasUpdate) {
-                    openDetails(extension)
-                } else {
-                    presenter.updateExtension(extension)
-                }
-            }
-        }
-    }
-
-    override fun onCancelButtonClick(position: Int) {
-        val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
-        presenter.cancelInstallUpdateExtension(extension)
-    }
-
     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
         inflater.inflate(R.menu.browse_extensions, menu)
 
@@ -142,93 +116,11 @@ open class ExtensionController :
         }
 
         searchView.queryTextChanges()
-            .drop(1) // Drop first event after subscribed
             .filter { router.backstack.lastOrNull()?.controller == this }
             .onEach {
                 query = it.toString()
-                updateExtensionsList()
+                presenter.search(query)
             }
             .launchIn(viewScope)
     }
-
-    override fun onItemClick(view: View, position: Int): Boolean {
-        val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false
-        when (extension) {
-            is Extension.Available -> presenter.installExtension(extension)
-            is Extension.Untrusted -> openTrustDialog(extension)
-            is Extension.Installed -> openDetails(extension)
-        }
-        return false
-    }
-
-    override fun onItemLongClick(position: Int) {
-        val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
-        if (extension is Extension.Installed || extension is Extension.Untrusted) {
-            uninstallExtension(extension.pkgName)
-        }
-    }
-
-    private fun openDetails(extension: Extension.Installed) {
-        val controller = ExtensionDetailsController(extension.pkgName)
-        parentController!!.router.pushController(controller)
-    }
-
-    private fun openTrustDialog(extension: Extension.Untrusted) {
-        ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName)
-            .showDialog(router)
-    }
-
-    fun setExtensions(extensions: List<ExtensionItem>) {
-        binding.swipeRefresh.isRefreshing = false
-        this.extensions = extensions
-        updateExtensionsList()
-
-        // Update badge on parent controller tab
-        val ctrl = parentController as BrowseController
-        ctrl.setExtensionUpdateBadge()
-        ctrl.extensionListUpdateRelay.call(true)
-    }
-
-    private fun updateExtensionsList() {
-        if (query.isNotBlank()) {
-            val queries = query.split(",")
-            adapter?.updateDataSet(
-                extensions.filter {
-                    queries.any { query ->
-                        when (it.extension) {
-                            is Extension.Available -> {
-                                it.extension.sources.any {
-                                    it.name.contains(query, ignoreCase = true) ||
-                                        it.baseUrl.contains(query, ignoreCase = true) ||
-                                        it.id == query.toLongOrNull()
-                                } || it.extension.name.contains(query, ignoreCase = true)
-                            }
-                            is Extension.Installed -> {
-                                it.extension.sources.any {
-                                    it.name.contains(query, ignoreCase = true) ||
-                                        it.id == query.toLongOrNull() ||
-                                        if (it is HttpSource) { it.baseUrl.contains(query, ignoreCase = true) } else false
-                                } || it.extension.name.contains(query, ignoreCase = true)
-                            }
-                            is Extension.Untrusted -> it.extension.name.contains(query, ignoreCase = true)
-                        }
-                    }
-                },
-            )
-        } else {
-            adapter?.updateDataSet(extensions)
-        }
-    }
-
-    fun downloadUpdate(item: ExtensionItem) {
-        adapter?.updateItem(item, item.installStep)
-    }
-
-    override fun trustSignature(signatureHash: String) {
-        presenter.trustSignature(signatureHash)
-    }
-
-    override fun uninstallExtension(pkgName: String) {
-        presenter.uninstallExtension(pkgName)
-    }
 }

+ 0 - 27
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt

@@ -1,27 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.annotation.SuppressLint
-import android.view.View
-import androidx.core.view.isVisible
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.viewholders.FlexibleViewHolder
-import eu.kanade.tachiyomi.databinding.SectionHeaderItemBinding
-
-class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) :
-    FlexibleViewHolder(view, adapter) {
-
-    private val binding = SectionHeaderItemBinding.bind(view)
-
-    @SuppressLint("SetTextI18n")
-    fun bind(item: ExtensionGroupItem) {
-        var text = item.name
-        if (item.showSize) {
-            text += " (${item.size})"
-        }
-        binding.title.text = text
-
-        binding.actionButton.isVisible = item.actionLabel != null && item.actionOnClick != null
-        binding.actionButton.text = item.actionLabel
-        binding.actionButton.setOnClickListener(if (item.actionLabel != null) item.actionOnClick else null)
-    }
-}

+ 0 - 62
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt

@@ -1,62 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.view.View
-import androidx.recyclerview.widget.RecyclerView
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.AbstractHeaderItem
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.R
-
-/**
- * Item that contains the group header.
- *
- * @param name The header name.
- * @param size The number of items in the group.
- */
-data class ExtensionGroupItem(
-    val name: String,
-    val size: Int,
-    val showSize: Boolean = false,
-) : AbstractHeaderItem<ExtensionGroupHolder>() {
-
-    var actionLabel: String? = null
-    var actionOnClick: (View.OnClickListener)? = null
-
-    /**
-     * Returns the layout resource of this item.
-     */
-    override fun getLayoutRes(): Int {
-        return R.layout.section_header_item
-    }
-
-    /**
-     * Creates a new view holder for this item.
-     */
-    override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ExtensionGroupHolder {
-        return ExtensionGroupHolder(view, adapter)
-    }
-
-    /**
-     * Binds this item to the given view holder.
-     */
-    override fun bindViewHolder(
-        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
-        holder: ExtensionGroupHolder,
-        position: Int,
-        payloads: List<Any?>?,
-    ) {
-        holder.bind(this)
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other is ExtensionGroupItem) {
-            return name == other.name
-        }
-        return false
-    }
-
-    override fun hashCode(): Int {
-        return name.hashCode()
-    }
-}

+ 0 - 84
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt

@@ -1,84 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.view.View
-import androidx.core.view.isVisible
-import coil.dispose
-import coil.load
-import eu.davidea.viewholders.FlexibleViewHolder
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.databinding.ExtensionItemBinding
-import eu.kanade.tachiyomi.extension.model.Extension
-import eu.kanade.tachiyomi.extension.model.InstallStep
-import eu.kanade.tachiyomi.util.system.LocaleHelper
-
-class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
-    FlexibleViewHolder(view, adapter) {
-
-    private val binding = ExtensionItemBinding.bind(view)
-
-    init {
-        binding.extButton.setOnClickListener {
-            adapter.buttonClickListener.onButtonClick(bindingAdapterPosition)
-        }
-        binding.cancelButton.setOnClickListener {
-            adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition)
-        }
-    }
-
-    fun bind(item: ExtensionItem) {
-        val extension = item.extension
-
-        binding.name.text = extension.name
-        binding.version.text = extension.versionName
-        binding.lang.text = LocaleHelper.getSourceDisplayName(extension.lang, itemView.context)
-        binding.warning.text = when {
-            extension is Extension.Untrusted -> itemView.context.getString(R.string.ext_untrusted)
-            extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.ext_unofficial)
-            extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.ext_obsolete)
-            extension.isNsfw -> itemView.context.getString(R.string.ext_nsfw_short)
-            else -> ""
-        }.uppercase()
-
-        binding.icon.dispose()
-        if (extension is Extension.Available) {
-            binding.icon.load(extension.iconUrl)
-        } else if (extension is Extension.Installed) {
-            binding.icon.load(extension.icon)
-        }
-        bindButtons(item)
-    }
-
-    @Suppress("ResourceType")
-    fun bindButtons(item: ExtensionItem) = with(binding.extButton) {
-        val extension = item.extension
-
-        val installStep = item.installStep
-        setText(
-            when (installStep) {
-                InstallStep.Pending -> R.string.ext_pending
-                InstallStep.Downloading -> R.string.ext_downloading
-                InstallStep.Installing -> R.string.ext_installing
-                InstallStep.Installed -> R.string.ext_installed
-                InstallStep.Error -> R.string.action_retry
-                InstallStep.Idle -> {
-                    when (extension) {
-                        is Extension.Installed -> {
-                            if (extension.hasUpdate) {
-                                R.string.ext_update
-                            } else {
-                                R.string.action_settings
-                            }
-                        }
-                        is Extension.Untrusted -> R.string.ext_trust
-                        is Extension.Available -> R.string.ext_install
-                    }
-                }
-            },
-        )
-
-        val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error
-        binding.cancelButton.isVisible = !isIdle
-        isEnabled = isIdle
-        isClickable = isIdle
-    }
-}

+ 0 - 65
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt

@@ -1,65 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.view.View
-import androidx.recyclerview.widget.RecyclerView
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.AbstractSectionableItem
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.extension.model.Extension
-import eu.kanade.tachiyomi.extension.model.InstallStep
-import eu.kanade.tachiyomi.source.CatalogueSource
-
-/**
- * Item that contains source information.
- *
- * @param source Instance of [CatalogueSource] containing source information.
- * @param header The header for this item.
- */
-data class ExtensionItem(
-    val extension: Extension,
-    val header: ExtensionGroupItem? = null,
-    val installStep: InstallStep = InstallStep.Idle,
-) :
-    AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) {
-
-    /**
-     * Returns the layout resource of this item.
-     */
-    override fun getLayoutRes(): Int {
-        return R.layout.extension_item
-    }
-
-    /**
-     * Creates a new view holder for this item.
-     */
-    override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ExtensionHolder {
-        return ExtensionHolder(view, adapter as ExtensionAdapter)
-    }
-
-    /**
-     * Binds this item to the given view holder.
-     */
-    override fun bindViewHolder(
-        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
-        holder: ExtensionHolder,
-        position: Int,
-        payloads: List<Any?>?,
-    ) {
-        if (payloads == null || payloads.isEmpty()) {
-            holder.bind(this)
-        } else {
-            holder.bindButtons(this)
-        }
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-        return extension.pkgName == (other as ExtensionItem).extension.pkgName
-    }
-
-    override fun hashCode(): Int {
-        return extension.pkgName.hashCode()
-    }
-}

+ 156 - 109
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt

@@ -2,144 +2,151 @@ package eu.kanade.tachiyomi.ui.browse.extension
 
 import android.app.Application
 import android.os.Bundle
-import android.view.View
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import eu.kanade.domain.extension.interactor.GetExtensionUpdates
+import eu.kanade.domain.extension.interactor.GetExtensions
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.preference.PreferenceValues
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.extension.ExtensionManager
 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.ui.base.presenter.BasePresenter
+import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.system.LocaleHelper
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
 import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
-import java.util.concurrent.TimeUnit
-
-private typealias ExtensionTuple =
-    Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
 
 /**
  * Presenter of [ExtensionController].
  */
 open class ExtensionPresenter(
     private val extensionManager: ExtensionManager = Injekt.get(),
-    private val preferences: PreferencesHelper = Injekt.get(),
+    private val getExtensionUpdates: GetExtensionUpdates = Injekt.get(),
+    private val getExtensions: GetExtensions = Injekt.get(),
 ) : BasePresenter<ExtensionController>() {
 
-    private var extensions = emptyList<ExtensionItem>()
+    private val _query: MutableStateFlow<String> = MutableStateFlow("")
+
+    private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
+
+    private val _state: MutableStateFlow<ExtensionState> = MutableStateFlow(ExtensionState.Uninitialized)
+    val state: StateFlow<ExtensionState> = _state.asStateFlow()
 
-    private var currentDownloads = hashMapOf<String, InstallStep>()
+    var isRefreshing: Boolean by mutableStateOf(true)
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
 
         extensionManager.findAvailableExtensions()
-        bindToExtensionsObservable()
-    }
-
-    private fun bindToExtensionsObservable(): Subscription {
-        val installedObservable = extensionManager.getInstalledExtensionsObservable()
-        val untrustedObservable = extensionManager.getUntrustedExtensionsObservable()
-        val availableObservable = extensionManager.getAvailableExtensionsObservable()
-            .startWith(emptyList<Extension.Available>())
-
-        return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) { installed, untrusted, available -> Triple(installed, untrusted, available) }
-            .debounce(500, TimeUnit.MILLISECONDS)
-            .map(::toItems)
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeLatestCache({ view, _ -> view.setExtensions(extensions) })
-    }
 
-    @Synchronized
-    private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
         val context = Injekt.get<Application>()
-        val activeLangs = preferences.enabledLanguages().get()
-        val showNsfwSources = preferences.showNsfwSource().get()
-
-        val (installed, untrusted, available) = tuple
-
-        val items = mutableListOf<ExtensionItem>()
+        val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map ->
+            {
+                ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
+            }
+        }
+        val queryFilter: (String) -> ((Extension) -> Boolean) = { query ->
+            filter@{ extension ->
+                if (query.isEmpty()) return@filter true
+                query.split(",").any { _input ->
+                    val input = _input.trim()
+                    if (input.isEmpty()) return@any false
+                    when (extension) {
+                        is Extension.Available -> {
+                            extension.sources.any {
+                                it.name.contains(input, ignoreCase = true) ||
+                                    it.baseUrl.contains(input, ignoreCase = true) ||
+                                    it.id == input.toLongOrNull()
+                            } || extension.name.contains(input, ignoreCase = true)
+                        }
+                        is Extension.Installed -> {
+                            extension.sources.any {
+                                it.name.contains(input, ignoreCase = true) ||
+                                    it.id == input.toLongOrNull() ||
+                                    if (it is HttpSource) { it.baseUrl.contains(input, ignoreCase = true) } else false
+                            } || extension.name.contains(input, ignoreCase = true)
+                        }
+                        is Extension.Untrusted -> extension.name.contains(input, ignoreCase = true)
+                    }
+                }
+            }
+        }
 
-        val updatesSorted = installed.filter { it.hasUpdate && (showNsfwSources || !it.isNsfw) }
-            .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
+        launchIO {
+            combine(
+                _query,
+                getExtensions.subscribe(),
+                getExtensionUpdates.subscribe(),
+                _currentDownloads,
+            ) { query, (installed, untrusted, available), updates, downloads ->
+                isRefreshing = false
+
+                val languagesWithExtensions = available
+                    .filter(queryFilter(query))
+                    .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) }
+                    .toSortedMap()
+                    .flatMap { (key, value) ->
+                        listOf(
+                            ExtensionUiModel.Header.Text(key),
+                            *value.map(extensionMapper(downloads)).toTypedArray(),
+                        )
+                    }
 
-        val installedSorted = installed.filter { !it.hasUpdate && (showNsfwSources || !it.isNsfw) }
-            .sortedWith(
-                compareBy<Extension.Installed> { !it.isObsolete }
-                    .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
-            )
+                val items = mutableListOf<ExtensionUiModel>()
 
-        val untrustedSorted = untrusted.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
+                val updates = updates.filter(queryFilter(query)).map(extensionMapper(downloads))
+                if (updates.isNotEmpty()) {
+                    items.add(ExtensionUiModel.Header.Resource(R.string.ext_updates_pending))
+                    items.addAll(updates)
+                }
 
-        val availableSorted = available
-            // Filter out already installed extensions and disabled languages
-            .filter { avail ->
-                installed.none { it.pkgName == avail.pkgName } &&
-                    untrusted.none { it.pkgName == avail.pkgName } &&
-                    avail.lang in activeLangs &&
-                    (showNsfwSources || !avail.isNsfw)
-            }
-            .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
-
-        if (updatesSorted.isNotEmpty()) {
-            val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true)
-            if (preferences.extensionInstaller().get() != PreferenceValues.ExtensionInstaller.LEGACY) {
-                header.actionLabel = context.getString(R.string.ext_update_all)
-                header.actionOnClick = View.OnClickListener { _ ->
-                    extensions
-                        .filter { it.extension is Extension.Installed && it.extension.hasUpdate }
-                        .forEach { updateExtension(it.extension as Extension.Installed) }
+                val installed = installed.filter(queryFilter(query)).map(extensionMapper(downloads))
+                val untrusted = untrusted.filter(queryFilter(query)).map(extensionMapper(downloads))
+                if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
+                    items.add(ExtensionUiModel.Header.Resource(R.string.ext_installed))
+                    items.addAll(installed)
+                    items.addAll(untrusted)
                 }
-            }
-            items += updatesSorted.map { extension ->
-                ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
-            }
-        }
-        if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
-            val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
 
-            items += installedSorted.map { extension ->
-                ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
-            }
+                if (languagesWithExtensions.isNotEmpty()) {
+                    items.addAll(languagesWithExtensions)
+                }
 
-            items += untrustedSorted.map { extension ->
-                ExtensionItem(extension, header)
+                items
+            }.collectLatest {
+                _state.value = ExtensionState.Initialized(it)
             }
         }
-        if (availableSorted.isNotEmpty()) {
-            val availableGroupedByLang = availableSorted
-                .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) }
-                .toSortedMap()
-
-            availableGroupedByLang
-                .forEach {
-                    val header = ExtensionGroupItem(it.key, it.value.size)
-                    items += it.value.map { extension ->
-                        ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
-                    }
-                }
-        }
-
-        this.extensions = items
-        return items
     }
 
-    @Synchronized
-    private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? {
-        val extensions = extensions.toMutableList()
-        val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName }
-
-        return if (position != -1) {
-            val item = extensions[position].copy(installStep = state)
-            extensions[position] = item
+    fun search(query: String) {
+        launchIO {
+            _query.emit(query)
+        }
+    }
 
-            this.extensions = extensions
-            item
-        } else {
-            null
+    fun updateAllExtensions() {
+        launchIO {
+            val state = _state.value
+            if (state !is ExtensionState.Initialized) return@launchIO
+            state.list.mapNotNull {
+                if (it !is ExtensionUiModel.Item) return@mapNotNull null
+                if (it.extension !is Extension.Installed) return@mapNotNull null
+                if (it.extension.hasUpdate.not()) return@mapNotNull null
+                it.extension
+            }.forEach {
+                updateExtension(it)
+            }
         }
     }
 
@@ -155,15 +162,29 @@ open class ExtensionPresenter(
         extensionManager.cancelInstallUpdateExtension(extension)
     }
 
+    private fun removeDownloadState(extension: Extension) {
+        _currentDownloads.update { map ->
+            val map = map.toMutableMap()
+            map.remove(extension.pkgName)
+            map
+        }
+    }
+
+    private fun addDownloadState(extension: Extension, installStep: InstallStep) {
+        _currentDownloads.update { map ->
+            val map = map.toMutableMap()
+            map[extension.pkgName] = installStep
+            map
+        }
+    }
+
     private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
-        this.doOnNext { currentDownloads[extension.pkgName] = it }
-            .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }
-            .map { state -> updateInstallStep(extension, state) }
-            .subscribeWithView({ view, item ->
-                if (item != null) {
-                    view.downloadUpdate(item)
-                }
-            },)
+        this
+            .doOnUnsubscribe { removeDownloadState(extension) }
+            .subscribe(
+                { installStep -> addDownloadState(extension, installStep) },
+                { removeDownloadState(extension) },
+            )
     }
 
     fun uninstallExtension(pkgName: String) {
@@ -171,6 +192,7 @@ open class ExtensionPresenter(
     }
 
     fun findAvailableExtensions() {
+        isRefreshing = true
         extensionManager.findAvailableExtensions()
     }
 
@@ -178,3 +200,28 @@ open class ExtensionPresenter(
         extensionManager.trustSignature(signatureHash)
     }
 }
+
+sealed interface ExtensionUiModel {
+    sealed interface Header : ExtensionUiModel {
+        data class Resource(@StringRes val textRes: Int) : Header
+        data class Text(val text: String) : Header
+    }
+    data class Item(
+        val extension: Extension,
+        val installStep: InstallStep,
+    ) : ExtensionUiModel {
+
+        fun key(): String {
+            return when (extension) {
+                is Extension.Installed ->
+                    if (extension.hasUpdate) "update_${extension.pkgName}" else extension.pkgName
+                else -> extension.pkgName
+            }
+        }
+    }
+}
+
+sealed class ExtensionState {
+    object Uninitialized : ExtensionState()
+    data class Initialized(val list: List<ExtensionUiModel>) : ExtensionState()
+}

+ 0 - 43
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt

@@ -1,43 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.app.Dialog
-import android.os.Bundle
-import androidx.core.os.bundleOf
-import com.bluelinelabs.conductor.Controller
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
-
-class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
-        where T : Controller, T : ExtensionTrustDialog.Listener {
-
-    constructor(target: T, signatureHash: String, pkgName: String) : this(
-        bundleOf(
-            SIGNATURE_KEY to signatureHash,
-            PKGNAME_KEY to pkgName,
-        ),
-    ) {
-        targetController = target
-    }
-
-    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
-        return MaterialAlertDialogBuilder(activity!!)
-            .setTitle(R.string.untrusted_extension)
-            .setMessage(R.string.untrusted_extension_message)
-            .setPositiveButton(R.string.ext_trust) { _, _ ->
-                (targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!)
-            }
-            .setNegativeButton(R.string.ext_uninstall) { _, _ ->
-                (targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!)
-            }
-            .create()
-    }
-
-    interface Listener {
-        fun trustSignature(signatureHash: String)
-        fun uninstallExtension(pkgName: String)
-    }
-}
-
-private const val SIGNATURE_KEY = "signature_key"
-private const val PKGNAME_KEY = "pkgname_key"

+ 32 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt

@@ -3,7 +3,15 @@ package eu.kanade.tachiyomi.ui.browse.extension
 import android.content.Context
 import android.content.pm.PackageManager
 import android.graphics.drawable.Drawable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.graphics.drawable.toBitmap
 import eu.kanade.tachiyomi.extension.model.Extension
+import eu.kanade.tachiyomi.util.lang.withIOContext
 
 fun Extension.getApplicationIcon(context: Context): Drawable? {
     return try {
@@ -12,3 +20,27 @@ fun Extension.getApplicationIcon(context: Context): Drawable? {
         null
     }
 }
+
+@Composable
+fun Extension.getIcon(): State<Result<ImageBitmap>> {
+    val context = LocalContext.current
+    return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
+        withIOContext {
+            value = try {
+                Result.Success(
+                    context.packageManager.getApplicationIcon(pkgName)
+                        .toBitmap()
+                        .asImageBitmap(),
+                )
+            } catch (e: Exception) {
+                Result.Error
+            }
+        }
+    }
+}
+
+sealed class Result<out T> {
+    object Loading : Result<Nothing>()
+    object Error : Result<Nothing>()
+    data class Success<out T>(val value: T) : Result<T>()
+}

+ 0 - 32
app/src/main/res/layout/extension_controller.xml

@@ -1,32 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout
-        android:id="@+id/swipe_refresh"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent">
-
-        <androidx.recyclerview.widget.RecyclerView
-            android:id="@+id/recycler"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:clipToPadding="false"
-            android:paddingTop="8dp"
-            android:paddingBottom="@dimen/action_toolbar_list_padding"
-            tools:listitem="@layout/section_header_item" />
-
-    </eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
-
-    <eu.kanade.tachiyomi.widget.MaterialFastScroll
-        android:id="@+id/fast_scroller"
-        android:layout_width="wrap_content"
-        android:layout_height="match_parent"
-        android:layout_gravity="end"
-        app:fastScrollerBubbleEnabled="false"
-        tools:visibility="visible" />
-
-</FrameLayout>

+ 0 - 98
app/src/main/res/layout/extension_item.xml

@@ -1,98 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="64dp"
-    android:background="@drawable/list_item_selector_background"
-    android:paddingEnd="16dp">
-
-    <ImageView
-        android:id="@+id/icon"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:paddingStart="16dp"
-        android:paddingEnd="8dp"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintDimensionRatio="1:1"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:ignore="ContentDescription"
-        tools:src="@mipmap/ic_launcher_round" />
-
-    <TextView
-        android:id="@+id/name"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginEnd="4dp"
-        android:ellipsize="end"
-        android:maxLines="1"
-        android:textAppearance="?attr/textAppearanceBodyMedium"
-        app:layout_constraintBottom_toTopOf="@id/lang"
-        app:layout_constraintEnd_toStartOf="@id/ext_button"
-        app:layout_constraintStart_toEndOf="@id/icon"
-        app:layout_constraintTop_toTopOf="parent"
-        app:layout_constraintVertical_chainStyle="packed"
-        tools:text="Batoto" />
-
-    <TextView
-        android:id="@+id/lang"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:maxLines="1"
-        android:textAppearance="?attr/textAppearanceBodySmall"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintStart_toEndOf="@id/icon"
-        app:layout_constraintTop_toBottomOf="@+id/name"
-        tools:text="English"
-        tools:visibility="visible" />
-
-    <TextView
-        android:id="@+id/version"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="4dp"
-        android:maxLines="1"
-        android:textAppearance="?attr/textAppearanceBodySmall"
-        app:layout_constraintStart_toEndOf="@id/lang"
-        app:layout_constraintTop_toBottomOf="@+id/name"
-        tools:text="Version" />
-
-    <TextView
-        android:id="@+id/warning"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="4dp"
-        android:maxLines="1"
-        android:textAppearance="?attr/textAppearanceBodySmall"
-        android:textColor="?attr/colorError"
-        app:layout_constraintStart_toEndOf="@id/version"
-        app:layout_constraintTop_toBottomOf="@+id/name"
-        tools:text="Warning" />
-
-    <Button
-        android:id="@+id/ext_button"
-        style="?attr/borderlessButtonStyle"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@+id/cancel_button"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:text="Details" />
-
-    <ImageButton
-        android:id="@+id/cancel_button"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:background="?selectableItemBackgroundBorderless"
-        android:contentDescription="@android:string/cancel"
-        android:padding="12dp"
-        android:src="@drawable/ic_close_24dp"
-        android:visibility="gone"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:tint="?android:attr/textColorPrimary"
-        tools:visibility="visible" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

BIN
app/src/main/res/mipmap-hdpi/ic_untrusted_source.png


BIN
app/src/main/res/mipmap-mdpi/ic_untrusted_source.png


BIN
app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png


BIN
app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png


BIN
app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png


+ 2 - 1
gradle/compose.versions.toml

@@ -12,4 +12,5 @@ material3-core = "androidx.compose.material3:material3:1.0.0-alpha11"
 material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.9"
 material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref="compose" }
 
-accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref="accompanist" }
+accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref="accompanist" }
+accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref="accompanist" }