From fdcd3aa7eb415d3ad17177dad9ece5ccdf1ce28e Mon Sep 17 00:00:00 2001 From: Andreas <andreas.everos@gmail.com> Date: Sun, 15 May 2022 15:59:53 +0200 Subject: [PATCH] Convert Extension tab to use Compose (#7107) * Convert Extension tab to use Compose Co-authored-by: jobobby04 <17078382+jobobby04@users.noreply.github.com> * Review changes Co-authored-by: jobobby04 <17078382+jobobby04@users.noreply.github.com> (cherry picked from commit 3e2d7d76b9b0fb1156d4dfaa01f4176d801089ce) # Conflicts: # app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt # app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt --- app/build.gradle.kts | 1 + .../eu/kanade/core/util/RxJavaExtensions.kt | 25 + .../java/eu/kanade/domain/DomainModule.kt | 5 + .../interactor/GetExtensionUpdates.kt | 25 + .../extension/interactor/GetExtensions.kt | 48 ++ .../browse/components/BaseBrowseItem.kt | 35 ++ .../browse/components/BrowseIcons.kt | 91 ++++ .../presentation/extension/ExtensionScreen.kt | 453 ++++++++++++++++++ .../presentation/source/SourceScreen.kt | 27 -- .../source/components/BaseSourceItem.kt | 28 +- .../ui/browse/extension/ExtensionAdapter.kt | 27 -- .../browse/extension/ExtensionController.kt | 212 ++------ .../browse/extension/ExtensionGroupHolder.kt | 27 -- .../ui/browse/extension/ExtensionGroupItem.kt | 62 --- .../ui/browse/extension/ExtensionHolder.kt | 116 ----- .../ui/browse/extension/ExtensionItem.kt | 65 --- .../ui/browse/extension/ExtensionPresenter.kt | 271 ++++++----- .../browse/extension/ExtensionTrustDialog.kt | 43 -- .../ui/browse/extension/ExtensionViewUtils.kt | 32 ++ .../main/res/layout/extension_controller.xml | 32 -- app/src/main/res/layout/extension_item.xml | 98 ---- .../res/mipmap-hdpi/ic_untrusted_source.png | Bin 0 -> 2124 bytes .../res/mipmap-mdpi/ic_untrusted_source.png | Bin 0 -> 1102 bytes .../res/mipmap-xhdpi/ic_untrusted_source.png | Bin 0 -> 2599 bytes .../res/mipmap-xxhdpi/ic_untrusted_source.png | Bin 0 -> 5065 bytes .../mipmap-xxxhdpi/ic_untrusted_source.png | Bin 0 -> 7096 bytes gradle/compose.versions.toml | 3 +- 27 files changed, 938 insertions(+), 788 deletions(-) create mode 100644 app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt create mode 100644 app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt create mode 100644 app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt create mode 100644 app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt delete mode 100644 app/src/main/res/layout/extension_controller.xml delete mode 100644 app/src/main/res/layout/extension_item.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_untrusted_source.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_untrusted_source.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7bcff576c..550c4b4c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -131,6 +131,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) diff --git a/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt b/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt new file mode 100644 index 000000000..4d1ef452d --- /dev/null +++ b/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() } +} diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 0657ce59f..8bc448163 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/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 @@ -45,6 +47,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()) } diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt new file mode 100644 index 000000000..96373f9b4 --- /dev/null +++ b/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 }) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt new file mode 100644 index 000000000..fbb5c1ac2 --- /dev/null +++ b/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() /* SY --> */ && it.isRedundant.not() /* SY <-- */ } + .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) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt new file mode 100644 index 000000000..2b3dd022f --- /dev/null +++ b/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() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt new file mode 100644 index 000000000..1b0323257 --- /dev/null +++ b/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), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt b/app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt new file mode 100644 index 000000000..3eda4feec --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt @@ -0,0 +1,453 @@ +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.material.icons.outlined.Settings +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.api.REPO_URL_PREFIX +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.source.ConfigurableSource +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 +import exh.source.anyIs + +@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 is Extension.Installed && extension.isRedundant -> R.string.ext_redundant + 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() /* SY --> */ plusRepo extension /* SY <-- */, + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.error, + ), + ) + } /* SY --> */ else if (extension is Extension.Available && extension.repoUrl != REPO_URL_PREFIX) { + Text( + text = stringResource(R.string.repo_source).uppercase(), + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.error, + ), + ) + } + // SY <-- + } + } +} + +@Composable +private infix fun String.plusRepo(extension: Extension): String { + val context = LocalContext.current + return remember(this, extension, context) { + if (extension is Extension.Available && extension.repoUrl != REPO_URL_PREFIX) { + if (isNullOrEmpty()) { + "" + } else { + "$this • " + } + context.getString(R.string.repo_source) + } else this + } +} + +@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, verticalAlignment = Alignment.CenterVertically) { + // SY --> + if ( + extension is Extension.Installed && + isIdle && + extension.sources.any { it.anyIs<ConfigurableSource>() } + ) { + Icon(Icons.Outlined.Settings, "", tint = MaterialTheme.colorScheme.primary) + } + // SY <-- + 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, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt b/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt index feb8e75d3..3198babcc 100644 --- a/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt +++ b/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 @@ -33,7 +30,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 @@ -243,29 +239,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, diff --git a/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt b/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt index 4f35d7e4d..d5d1b6fc1 100644 --- a/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt +++ b/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 -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt deleted file mode 100644 index 89f621da2..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt index 7b1aa7b3d..8112692b7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt +++ b/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 getTitle(): String? = + applicationContext?.getString(R.string.label_extensions) - override fun createPresenter(): ExtensionPresenter { - return ExtensionPresenter() - } + override fun createPresenter(): ExtensionPresenter = + 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) - - // 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 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) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt deleted file mode 100644 index 099ad8c88..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt deleted file mode 100644 index 53adf7588..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt deleted file mode 100644 index 33209ecfd..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt +++ /dev/null @@ -1,116 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -import android.annotation.SuppressLint -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.api.REPO_URL_PREFIX -import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.source.ConfigurableSource -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) - // SY --> - extension is Extension.Installed && extension.isRedundant -> itemView.context.getString(R.string.ext_redundant) - extension.isNsfw -> itemView.context.getString(R.string.ext_nsfw_short).plusRepo(extension) - else -> "".plusRepo(extension) - // SY <-- - }.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) - } - - // SY --> - private fun String.plusRepo(extension: Extension): String { - return if (extension is Extension.Available) { - when (extension.repoUrl) { - REPO_URL_PREFIX -> this - else -> { - if (isEmpty()) { - this - } else { - this + " • " - } + itemView.context.getString(R.string.repo_source) - } - } - } else this - } - - // SY <-- - @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 - } - } - }, - ) - // SY --> - if (extension is Extension.Installed && - installStep == InstallStep.Idle && - extension.sources.any { it is ConfigurableSource } - ) { - @SuppressLint("SetTextI18n") - text = "$text+" - } - // SY <-- - - val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error - binding.cancelButton.isVisible = !isIdle - isEnabled = isIdle - isClickable = isIdle - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt deleted file mode 100644 index 5e895f6b5..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt index 9e3f1aacd..76b38aa06 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt +++ b/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 = hashMapOf<String, InstallStep>() + private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf()) + + private val _state: MutableStateFlow<ExtensionState> = MutableStateFlow(ExtensionState.Uninitialized) + val state: StateFlow<ExtensionState> = _state.asStateFlow() + + 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 updatesSorted = installed.filter { it.hasUpdate && (showNsfwSources || !it.isNsfw) } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - - val installedSorted = installed.filter { !it.hasUpdate && (showNsfwSources || !it.isNsfw) } - .sortedWith( - compareBy<Extension.Installed> { !it.isObsolete /* SY --> */ && !it.isRedundant /* SY <-- */ } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, - ) - - val untrustedSorted = untrusted.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - - 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) } - } - } - items += updatesSorted.map { extension -> - ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) + val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map -> + { + ExtensionUiModel.Item(it, map[it.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) - } - - items += untrustedSorted.map { extension -> - ExtensionItem(extension, header) - } - } - 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) + 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) } } + } } - this.extensions = items - return items + 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 items = mutableListOf<ExtensionUiModel>() + + 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 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) + } + + if (languagesWithExtensions.isNotEmpty()) { + items.addAll(languagesWithExtensions) + } + + items + }.collectLatest { + _state.value = ExtensionState.Initialized(it) + } + } } - @Synchronized - private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? { - val extensions = extensions.toMutableList() - val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName } + fun search(query: String) { + launchIO { + _query.emit(query) + } + } - return if (position != -1) { - val item = extensions[position].copy(installStep = state) - extensions[position] = item - - 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() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt deleted file mode 100644 index 23d23a32b..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt +++ /dev/null @@ -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" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt index a4bae2484..e9f4b263f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt +++ b/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>() +} diff --git a/app/src/main/res/layout/extension_controller.xml b/app/src/main/res/layout/extension_controller.xml deleted file mode 100644 index 0db6a3024..000000000 --- a/app/src/main/res/layout/extension_controller.xml +++ /dev/null @@ -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> diff --git a/app/src/main/res/layout/extension_item.xml b/app/src/main/res/layout/extension_item.xml deleted file mode 100644 index b76c9437d..000000000 --- a/app/src/main/res/layout/extension_item.xml +++ /dev/null @@ -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> diff --git a/app/src/main/res/mipmap-hdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-hdpi/ic_untrusted_source.png new file mode 100644 index 0000000000000000000000000000000000000000..a61d75b69f6a3d3e135b505a741ee03c67125473 GIT binary patch literal 2124 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 Cya4$C literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-mdpi/ic_untrusted_source.png new file mode 100644 index 0000000000000000000000000000000000000000..27fa261666b6833db8549bbb910da902034e8a21 GIT binary patch literal 1102 zcmV-U1hM;xP)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm00001b5ch_0Itp) z=>Px(2uVaiRA@u(n$1rWK^VrLh00<p2R{PwqMZ1Un<4xIY9ItW7{27%iwQSFxEO?s zF<iWnaOo9LLVybp;4dH=PbhGLlkxkYtt=_jJT7lKOlRrV?oy0w(zIo^GtY0H_kCyD z!NNa&S^v0xoCB2F!IK<dTNzMtmofkj0q{BJG67iQ<KwY-JpRJA?LGiiC7$IUj^j)x z5{btH0|P&GEhd0SGMSu+Mx*f(3qLk|DwRsaVzEa6(rSYa!1u9(gM$xcWo4Jn62Rf% z;m`8&^6LPSYB-qyE@v{CuV+cTXt6BoDuD0#0Ip;*nJ;G);2MB$=Kw}`K7R&Yzcw3} z(JEkbGf-V!u*sHXh2+4ePr&{Az`J*@C=u-S+}H>#E&??*1wn)Z;3YmE$gNwz(o#WA z1P{Ql>FmtTd_K{7J0X^reM1BbAmZP@_YN69Km|Yq2Owc%67NT}o*M#@Cntgd@De{b z2)um@tgV?7S-|ez1#aI4#>S32*VN>O(+Lp819-(e#Xo%t?Ck-4eOU<I4T^vD3aG3E zMn;Z9@LoExvf`N&#RITv^!8>;pM=7lJHXTwaPcDW>sJ;+4V8QMfaz%<8g=jK>T*H# zaGIL~Iy#KyHCO<3apG%2P@oF2y6Os~_?Iu;wp0q}=>eoob&0P-2jJl9rGn@X;+BZ# z%=7aM6R%{SpAXpq0ni-~D8{)s;W(}XjCnue*+=LA-QC^}2oxb^W&qoE?`3lHbNbAi zTsSoVeS@aHSF4QxFlp7e_X?qfg%CrC8?by1s0quN=OrOzazPKpQv)zLpo*86=Y0$( zsZMl4LdoPp=l~R8szMAKRvW4FYPBJlK(LJJ;iOImbATH}!&Uq`zz|Xa)THGKD|Mdb zQ%_id5Woiy%sD{^LIdDFsJ$K7*f>5vLqovmXtvrgS@i`<7PQ$}Hz$UNf#=V&{ji<; zA+F9I`=R0;;BX&5x)A*KrCLvjH*az`b2<o};AXBs@$4oT0QN*D+S+ozLdtx4Epqz4 zN8s|zymT8JfIzgin!k;i!p95Fr}$tU5HWOu5PDAJbD+SP561!Nl{vw1BE(6nvkq0j z%!dmgiLE-359BYzhZ90-F=aTduNxPjoRZqoQt)p=;RAR}53D8FDioM3{~i550FLAA zR#a5P{LfAJckb`+CvDrl;pcpyYHMq2qOPv)VfeEAkFdSHJyl;{&m*QEC!G(lN4ue+ z;pXJz<g?n^+V)5!67@e!@OP%u>D127&SHOm|C7zl&0PSFI*Rd>5&%VCK02Yi5WFnk zJ@R0IhkBfK9xbKx<5sV8V*WjH^x#=>DKDN86M~PNv*PoAH7KPue^%#G3n+H{-<N|1 UUQ-!Z@&Et;07*qoM6N<$g3|{9-T(jq literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png new file mode 100644 index 0000000000000000000000000000000000000000..e83ceec512162949905e8494161b36821ae5bc98 GIT binary patch literal 2599 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⪻j#Rn%V~+t(JTXcFtu;J`8x=%V!JL6v z!~}xBW)1M{v%y)Ste@ahdTzfxX7~-7XNLgZ&HL{MGod**$OPb{j{;9V8RYIN@NM&X zaUI4LF<>LWH^~m?1aXSuZMP)?KYIiS68J8F?nMmwQ7K29*&uqlDsKrx69p<s;ItOX zj>+-~K4(goFe@&b1n{mnOYY7)M_)y<0oLR(aXi9g`JARs%`XxB>=M8`<2+GzZp4cj zQNgsF;F~O;Gov|!qYN{nvrT|Zf_N!UrL;+^r%Anmmd_r0nZVCJ0YZ+LW2;C=0=~(@ zbuokBv$tO2boB7)vy}zuatX@O9d|^(q-T<$C<!xZewk%Qe$0FjAUuIF;dIMU1!O0m z1c(%s*^%5&Fa_s0dLKR)Q%z<&WtN@%5}+a>NgS{trA&FgPF2PK^CLm>ldx>T+L5ko zQ7S1cFTD9?Nj+Q>@cAZycfWCC@b<jT*VUK;v`kouwH;gF^G|>X%wK;U{meFUBcI{w ztIL|_n+VCL1eu;HNBbQCDU;<0a0H}`K+2bQ9^eQ_8G)29?>wN&2xu4_9Nb@9%L<m; zOb84O4Gq=R)$y&t!{z$+^t1c>`@d>zY@A`bxZ4j53=DkV)YP<~NI*#oIB*G*6Dw$J z-@g6fdGqEy<N!7!uw%!L7w6BP|3YyEjmU+3Oe<)R0LsppvuDq~sJFLwOLH@)7`RP` zK%r15w6wG=-nDBNR~+FQGW;^s#B0cqAg-cx27t3$TU%##c6M%PZf>43o})=_`yYYf z;b9aCg@dbBty<pG)3c|ze91qhuA;;Xh?SHkQHo~HoO#~r)vK4ynl)=-Lqo$<&-2DK z6Ta^cA3l8e!0z3<x2;{fcH^EsdwwfQ&k@rKN(9#=L81-6!ivjT)dL_A{4h39o=DJG z-NeG3lo<wFg3b@u4-?v7(n@*)*b~bc76B*lTv>_W)B44#s$-D^v5G3c`j;ed#Uv8J z-?^5gap40S50FVXv8ZS9BZD93Jk>;$yp%0TAn0NdPb2uY5+nmAmbDeJVXOi#V<LbS zWK?85VU^W7@JCUxvUUez2F1@=amu7P0vrJ;Bargtod={$z<)-b$Ga#=DK!89002ov JPDHLkV1h)d*^dAK literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png new file mode 100644 index 0000000000000000000000000000000000000000..4eddc956103e19b582a5a6e5aa1f93846abb0520 GIT binary patch literal 5065 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 Y^O<f9egb#y)ol)-qhWw4Reu!ne^lOqqyPW_ literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png new file mode 100644 index 0000000000000000000000000000000000000000..a2825d56262a1523572632a43c1a6525022266ec GIT binary patch literal 7096 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 literal 0 HcmV?d00001 diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index e97b9dacb..da78c8e97 100644 --- a/gradle/compose.versions.toml +++ b/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" } \ No newline at end of file +accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref="accompanist" } +accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref="accompanist" } \ No newline at end of file