mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-21 20:47:03 -05: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
27 changed files with 902 additions and 756 deletions
|
@ -148,6 +148,7 @@ dependencies {
|
|||
implementation(compose.animation)
|
||||
implementation(compose.ui.tooling)
|
||||
implementation(compose.accompanist.webview)
|
||||
implementation(compose.accompanist.swiperefresh)
|
||||
|
||||
implementation(androidx.paging.runtime)
|
||||
implementation(androidx.paging.compose)
|
||||
|
|
25
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.manga.MangaRepositoryImpl
|
||||
import eu.kanade.data.source.SourceRepositoryImpl
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionUpdates
|
||||
import eu.kanade.domain.extension.interactor.GetExtensions
|
||||
import eu.kanade.domain.history.interactor.DeleteHistoryTable
|
||||
import eu.kanade.domain.history.interactor.GetHistory
|
||||
import eu.kanade.domain.history.interactor.GetNextChapterForManga
|
||||
|
@ -40,6 +42,9 @@ class DomainModule : InjektModule {
|
|||
addFactory { RemoveHistoryById(get()) }
|
||||
addFactory { RemoveHistoryByMangaId(get()) }
|
||||
|
||||
addFactory { GetExtensions(get(), get()) }
|
||||
addFactory { GetExtensionUpdates(get(), get()) }
|
||||
|
||||
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
|
||||
addFactory { GetLanguagesWithSources(get(), get()) }
|
||||
addFactory { GetEnabledSources(get(), get()) }
|
||||
|
|
|
@ -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
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
@ -31,7 +28,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.domain.source.model.Pin
|
||||
|
@ -191,29 +187,6 @@ fun SourceItem(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceIcon(
|
||||
source: Source,
|
||||
) {
|
||||
val icon = source.icon
|
||||
val modifier = Modifier
|
||||
.height(40.dp)
|
||||
.aspectRatio(1f)
|
||||
if (icon != null) {
|
||||
Image(
|
||||
bitmap = icon,
|
||||
contentDescription = "",
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.ic_local_source),
|
||||
contentDescription = "",
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourcePinButton(
|
||||
isPinned: Boolean,
|
||||
|
|
|
@ -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,
|
||||
BaseBrowseItem(
|
||||
modifier = modifier,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = 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 ->
|
||||
|
|
|
@ -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
|
||||
|
||||
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 =
|
||||
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 {
|
||||
return ExtensionPresenter()
|
||||
}
|
||||
|
||||
override fun createBinding(inflater: LayoutInflater) =
|
||||
ExtensionControllerBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
|
||||
binding.recycler.applyInsetter {
|
||||
type(navigationBars = true) {
|
||||
padding()
|
||||
}
|
||||
}
|
||||
|
||||
binding.swipeRefresh.isRefreshing = true
|
||||
binding.swipeRefresh.refreshes()
|
||||
.onEach { presenter.findAvailableExtensions() }
|
||||
.launchIn(viewScope)
|
||||
|
||||
// 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)
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.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 }
|
||||
.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) }
|
||||
val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map ->
|
||||
{
|
||||
ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
|
||||
}
|
||||
}
|
||||
items += updatesSorted.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
availableGroupedByLang
|
||||
.forEach {
|
||||
val header = ExtensionGroupItem(it.key, it.value.size)
|
||||
items += it.value.map { extension ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.extensions = items
|
||||
return items
|
||||
fun search(query: String) {
|
||||
launchIO {
|
||||
_query.emit(query)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? {
|
||||
val extensions = extensions.toMutableList()
|
||||
val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName }
|
||||
|
||||
return if (position != -1) {
|
||||
val item = extensions[position].copy(installStep = state)
|
||||
extensions[position] = item
|
||||
|
||||
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 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)
|
||||
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
|
||||
.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()
|
||||
}
|
||||
|
|
|
@ -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.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>()
|
||||
}
|
||||
|
|
|
@ -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" }
|
||||
|
||||
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 a new issue