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:
Andreas
2022-05-15 15:59:53 +02:00
committed by GitHub
parent 7a0915964a
commit 3e2d7d76b9
27 changed files with 902 additions and 756 deletions

View File

@@ -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()
}
}

View File

@@ -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),
)
}
}

View File

@@ -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,
)
}

View File

@@ -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,

View File

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