mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-22 14:11:50 +01:00
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>
This commit is contained in:
parent
7a0915964a
commit
3e2d7d76b9
@ -148,6 +148,7 @@ dependencies {
|
|||||||
implementation(compose.animation)
|
implementation(compose.animation)
|
||||||
implementation(compose.ui.tooling)
|
implementation(compose.ui.tooling)
|
||||||
implementation(compose.accompanist.webview)
|
implementation(compose.accompanist.webview)
|
||||||
|
implementation(compose.accompanist.swiperefresh)
|
||||||
|
|
||||||
implementation(androidx.paging.runtime)
|
implementation(androidx.paging.runtime)
|
||||||
implementation(androidx.paging.compose)
|
implementation(androidx.paging.compose)
|
||||||
|
25
app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt
Normal file
25
app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt
Normal file
@ -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() }
|
||||||
|
}
|
@ -3,6 +3,8 @@ package eu.kanade.domain
|
|||||||
import eu.kanade.data.history.HistoryRepositoryImpl
|
import eu.kanade.data.history.HistoryRepositoryImpl
|
||||||
import eu.kanade.data.manga.MangaRepositoryImpl
|
import eu.kanade.data.manga.MangaRepositoryImpl
|
||||||
import eu.kanade.data.source.SourceRepositoryImpl
|
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.DeleteHistoryTable
|
||||||
import eu.kanade.domain.history.interactor.GetHistory
|
import eu.kanade.domain.history.interactor.GetHistory
|
||||||
import eu.kanade.domain.history.interactor.GetNextChapterForManga
|
import eu.kanade.domain.history.interactor.GetNextChapterForManga
|
||||||
@ -40,6 +42,9 @@ class DomainModule : InjektModule {
|
|||||||
addFactory { RemoveHistoryById(get()) }
|
addFactory { RemoveHistoryById(get()) }
|
||||||
addFactory { RemoveHistoryByMangaId(get()) }
|
addFactory { RemoveHistoryByMangaId(get()) }
|
||||||
|
|
||||||
|
addFactory { GetExtensions(get(), get()) }
|
||||||
|
addFactory { GetExtensionUpdates(get(), get()) }
|
||||||
|
|
||||||
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
|
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
|
||||||
addFactory { GetLanguagesWithSources(get(), get()) }
|
addFactory { GetLanguagesWithSources(get(), get()) }
|
||||||
addFactory { GetEnabledSources(get(), get()) }
|
addFactory { GetEnabledSources(get(), get()) }
|
||||||
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
@ -1,13 +1,10 @@
|
|||||||
package eu.kanade.presentation.source
|
package eu.kanade.presentation.source
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.kanade.domain.source.model.Pin
|
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
|
@Composable
|
||||||
fun SourcePinButton(
|
fun SourcePinButton(
|
||||||
isPinned: Boolean,
|
isPinned: Boolean,
|
||||||
|
@ -1,19 +1,16 @@
|
|||||||
package eu.kanade.presentation.source.components
|
package eu.kanade.presentation.source.components
|
||||||
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import eu.kanade.domain.source.model.Source
|
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.presentation.util.horizontalPadding
|
||||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||||
|
|
||||||
@ -28,19 +25,14 @@ fun BaseSourceItem(
|
|||||||
action: @Composable RowScope.(Source) -> Unit = {},
|
action: @Composable RowScope.(Source) -> Unit = {},
|
||||||
content: @Composable RowScope.(Source, Boolean) -> Unit = defaultContent,
|
content: @Composable RowScope.(Source, Boolean) -> Unit = defaultContent,
|
||||||
) {
|
) {
|
||||||
Row(
|
BaseBrowseItem(
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
.combinedClickable(
|
onClickItem = onClickItem,
|
||||||
onClick = onClickItem,
|
onLongClickItem = onLongClickItem,
|
||||||
onLongClick = onLongClickItem,
|
icon = { icon.invoke(this, source) },
|
||||||
|
action = { action.invoke(this, source) },
|
||||||
|
content = { content.invoke(this, source, showLanguageInContent) },
|
||||||
)
|
)
|
||||||
.padding(horizontal = horizontalPadding, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
icon.invoke(this, source)
|
|
||||||
content.invoke(this, source, showLanguageInContent)
|
|
||||||
action.invoke(this, source)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source ->
|
private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source ->
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,48 +1,30 @@
|
|||||||
package eu.kanade.tachiyomi.ui.browse.extension
|
package eu.kanade.tachiyomi.ui.browse.extension
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
|
||||||
import androidx.appcompat.widget.SearchView
|
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.ControllerChangeHandler
|
||||||
import com.bluelinelabs.conductor.ControllerChangeType
|
import com.bluelinelabs.conductor.ControllerChangeType
|
||||||
import dev.chrisbanes.insetter.applyInsetter
|
import eu.kanade.presentation.extension.ExtensionScreen
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.items.IFlexible
|
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.databinding.ExtensionControllerBinding
|
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.ui.base.controller.ComposeController
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
import eu.kanade.tachiyomi.ui.base.controller.pushController
|
||||||
import eu.kanade.tachiyomi.ui.browse.BrowseController
|
import eu.kanade.tachiyomi.ui.browse.BrowseController
|
||||||
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController
|
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import reactivecircus.flowbinding.appcompat.queryTextChanges
|
import reactivecircus.flowbinding.appcompat.queryTextChanges
|
||||||
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller to manage the catalogues available in the app.
|
* Controller to manage the catalogues available in the app.
|
||||||
*/
|
*/
|
||||||
open class ExtensionController :
|
open class ExtensionController :
|
||||||
NucleusController<ExtensionControllerBinding, ExtensionPresenter>(),
|
ComposeController<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()
|
|
||||||
|
|
||||||
private var query = ""
|
private var query = ""
|
||||||
|
|
||||||
@ -50,42 +32,54 @@ open class ExtensionController :
|
|||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getTitle(): String? {
|
override fun getTitle(): String? =
|
||||||
return applicationContext?.getString(R.string.label_extensions)
|
applicationContext?.getString(R.string.label_extensions)
|
||||||
|
|
||||||
|
override fun createPresenter(): ExtensionPresenter =
|
||||||
|
ExtensionPresenter()
|
||||||
|
|
||||||
|
@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)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
override fun createPresenter(): ExtensionPresenter {
|
onClickItemCancel = { extension ->
|
||||||
return ExtensionPresenter()
|
presenter.cancelInstallUpdateExtension(extension)
|
||||||
}
|
},
|
||||||
|
onClickUpdateAll = {
|
||||||
override fun createBinding(inflater: LayoutInflater) =
|
presenter.updateAllExtensions()
|
||||||
ExtensionControllerBinding.inflate(inflater)
|
},
|
||||||
|
onLaunched = {
|
||||||
override fun onViewCreated(view: View) {
|
val ctrl = parentController as BrowseController
|
||||||
super.onViewCreated(view)
|
ctrl.setExtensionUpdateBadge()
|
||||||
|
ctrl.extensionListUpdateRelay.call(true)
|
||||||
binding.recycler.applyInsetter {
|
},
|
||||||
type(navigationBars = true) {
|
onInstallExtension = {
|
||||||
padding()
|
presenter.installExtension(it)
|
||||||
}
|
},
|
||||||
}
|
onOpenExtension = {
|
||||||
|
val controller = ExtensionDetailsController(it.pkgName)
|
||||||
binding.swipeRefresh.isRefreshing = true
|
parentController!!.router.pushController(controller)
|
||||||
binding.swipeRefresh.refreshes()
|
},
|
||||||
.onEach { presenter.findAvailableExtensions() }
|
onTrustExtension = {
|
||||||
.launchIn(viewScope)
|
presenter.trustSignature(it.signatureHash)
|
||||||
|
},
|
||||||
// Initialize adapter, scroll listener and recycler views
|
onUninstallExtension = {
|
||||||
adapter = ExtensionAdapter(this)
|
presenter.uninstallExtension(it.pkgName)
|
||||||
// Create recycler and set adapter.
|
},
|
||||||
binding.recycler.layoutManager = LinearLayoutManager(view.context)
|
onUpdateExtension = {
|
||||||
binding.recycler.adapter = adapter
|
presenter.updateExtension(it)
|
||||||
adapter?.fastScroller = binding.fastScroller
|
},
|
||||||
}
|
onRefresh = {
|
||||||
|
presenter.findAvailableExtensions()
|
||||||
override fun onDestroyView(view: View) {
|
},
|
||||||
adapter = null
|
)
|
||||||
super.onDestroyView(view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
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) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
inflater.inflate(R.menu.browse_extensions, menu)
|
inflater.inflate(R.menu.browse_extensions, menu)
|
||||||
|
|
||||||
@ -142,93 +116,11 @@ open class ExtensionController :
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchView.queryTextChanges()
|
searchView.queryTextChanges()
|
||||||
.drop(1) // Drop first event after subscribed
|
|
||||||
.filter { router.backstack.lastOrNull()?.controller == this }
|
.filter { router.backstack.lastOrNull()?.controller == this }
|
||||||
.onEach {
|
.onEach {
|
||||||
query = it.toString()
|
query = it.toString()
|
||||||
updateExtensionsList()
|
presenter.search(query)
|
||||||
}
|
}
|
||||||
.launchIn(viewScope)
|
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,144 +2,151 @@ package eu.kanade.tachiyomi.ui.browse.extension
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.Bundle
|
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.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.ExtensionManager
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
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.ui.base.presenter.BasePresenter
|
||||||
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
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.Observable
|
||||||
import rx.Subscription
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
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].
|
* Presenter of [ExtensionController].
|
||||||
*/
|
*/
|
||||||
open class ExtensionPresenter(
|
open class ExtensionPresenter(
|
||||||
private val extensionManager: ExtensionManager = Injekt.get(),
|
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>() {
|
) : 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?) {
|
override fun onCreate(savedState: Bundle?) {
|
||||||
super.onCreate(savedState)
|
super.onCreate(savedState)
|
||||||
|
|
||||||
extensionManager.findAvailableExtensions()
|
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 context = Injekt.get<Application>()
|
||||||
val activeLangs = preferences.enabledLanguages().get()
|
val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map ->
|
||||||
val showNsfwSources = preferences.showNsfwSource().get()
|
{
|
||||||
|
ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
|
||||||
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 }
|
|
||||||
.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 ->
|
val queryFilter: (String) -> ((Extension) -> Boolean) = { query ->
|
||||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 ->
|
launchIO {
|
||||||
ExtensionItem(extension, header)
|
combine(
|
||||||
}
|
_query,
|
||||||
}
|
getExtensions.subscribe(),
|
||||||
if (availableSorted.isNotEmpty()) {
|
getExtensionUpdates.subscribe(),
|
||||||
val availableGroupedByLang = availableSorted
|
_currentDownloads,
|
||||||
|
) { query, (installed, untrusted, available), updates, downloads ->
|
||||||
|
isRefreshing = false
|
||||||
|
|
||||||
|
val languagesWithExtensions = available
|
||||||
|
.filter(queryFilter(query))
|
||||||
.groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) }
|
.groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) }
|
||||||
.toSortedMap()
|
.toSortedMap()
|
||||||
|
.flatMap { (key, value) ->
|
||||||
|
listOf(
|
||||||
|
ExtensionUiModel.Header.Text(key),
|
||||||
|
*value.map(extensionMapper(downloads)).toTypedArray(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
availableGroupedByLang
|
val items = mutableListOf<ExtensionUiModel>()
|
||||||
.forEach {
|
|
||||||
val header = ExtensionGroupItem(it.key, it.value.size)
|
val updates = updates.filter(queryFilter(query)).map(extensionMapper(downloads))
|
||||||
items += it.value.map { extension ->
|
if (updates.isNotEmpty()) {
|
||||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.extensions = items
|
fun search(query: String) {
|
||||||
return items
|
launchIO {
|
||||||
|
_query.emit(query)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun updateAllExtensions() {
|
||||||
private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? {
|
launchIO {
|
||||||
val extensions = extensions.toMutableList()
|
val state = _state.value
|
||||||
val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName }
|
if (state !is ExtensionState.Initialized) return@launchIO
|
||||||
|
state.list.mapNotNull {
|
||||||
return if (position != -1) {
|
if (it !is ExtensionUiModel.Item) return@mapNotNull null
|
||||||
val item = extensions[position].copy(installStep = state)
|
if (it.extension !is Extension.Installed) return@mapNotNull null
|
||||||
extensions[position] = item
|
if (it.extension.hasUpdate.not()) return@mapNotNull null
|
||||||
|
it.extension
|
||||||
this.extensions = extensions
|
}.forEach {
|
||||||
item
|
updateExtension(it)
|
||||||
} else {
|
}
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,15 +162,29 @@ open class ExtensionPresenter(
|
|||||||
extensionManager.cancelInstallUpdateExtension(extension)
|
extensionManager.cancelInstallUpdateExtension(extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
|
private fun removeDownloadState(extension: Extension) {
|
||||||
this.doOnNext { currentDownloads[extension.pkgName] = it }
|
_currentDownloads.update { map ->
|
||||||
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }
|
val map = map.toMutableMap()
|
||||||
.map { state -> updateInstallStep(extension, state) }
|
map.remove(extension.pkgName)
|
||||||
.subscribeWithView({ view, item ->
|
map
|
||||||
if (item != null) {
|
|
||||||
view.downloadUpdate(item)
|
|
||||||
}
|
}
|
||||||
},)
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
.doOnUnsubscribe { removeDownloadState(extension) }
|
||||||
|
.subscribe(
|
||||||
|
{ installStep -> addDownloadState(extension, installStep) },
|
||||||
|
{ removeDownloadState(extension) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun uninstallExtension(pkgName: String) {
|
fun uninstallExtension(pkgName: String) {
|
||||||
@ -171,6 +192,7 @@ open class ExtensionPresenter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun findAvailableExtensions() {
|
fun findAvailableExtensions() {
|
||||||
|
isRefreshing = true
|
||||||
extensionManager.findAvailableExtensions()
|
extensionManager.findAvailableExtensions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,3 +200,28 @@ open class ExtensionPresenter(
|
|||||||
extensionManager.trustSignature(signatureHash)
|
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()
|
||||||
|
}
|
||||||
|
@ -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"
|
|
@ -3,7 +3,15 @@ package eu.kanade.tachiyomi.ui.browse.extension
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.drawable.Drawable
|
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.extension.model.Extension
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
|
|
||||||
fun Extension.getApplicationIcon(context: Context): Drawable? {
|
fun Extension.getApplicationIcon(context: Context): Drawable? {
|
||||||
return try {
|
return try {
|
||||||
@ -12,3 +20,27 @@ fun Extension.getApplicationIcon(context: Context): Drawable? {
|
|||||||
null
|
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>()
|
||||||
|
}
|
||||||
|
@ -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>
|
|
@ -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
Normal file
BIN
app/src/main/res/mipmap-hdpi/ic_untrusted_source.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_untrusted_source.png
Normal file
BIN
app/src/main/res/mipmap-mdpi/ic_untrusted_source.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png
Normal file
BIN
app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png
Normal file
BIN
app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.9 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png
Normal file
BIN
app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.9 KiB |
@ -13,3 +13,4 @@ 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" }
|
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" }
|
Loading…
Reference in New Issue
Block a user