feat: Working extension repo frontend

This commit is contained in:
ziro 2024-01-13 18:47:31 +07:00
parent 64c7b54075
commit 67f32d400d
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
7 changed files with 161 additions and 34 deletions

View file

@ -4,4 +4,5 @@ import eu.kanade.tachiyomi.core.preference.PreferenceStore
class SourcePreferences(private val preferenceStore: PreferenceStore) {
fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet())
fun trustedExtensions() = preferenceStore.getStringSet("trusted_extensions", emptySet())
}

View file

@ -96,7 +96,7 @@ fun CombinedClickableIconButton(
if (enabled) {
enabledTint
} else {
MaterialTheme.colorScheme.onSurface
MaterialTheme.colorScheme.onBackground
.copy(alpha = .38f)
}
CompositionLocalProvider(LocalContentColor provides contentColor, content = content)

View file

@ -1,6 +1,8 @@
package dev.yokai.presentation.extension.repo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ExtensionOff
@ -8,19 +10,31 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.yokai.presentation.AppBarType
import dev.yokai.presentation.YokaiScaffold
import dev.yokai.presentation.component.EmptyScreen
import dev.yokai.presentation.extension.repo.component.ExtensionRepoItem
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
@Composable
fun ExtensionRepoScreen(
title: String,
onBackPress: () -> Unit,
viewModel: ExtensionRepoViewModel = viewModel(),
) {
val context = LocalContext.current
val repoState = viewModel.repoState.collectAsState()
var inputText by remember { mutableStateOf("") }
YokaiScaffold(
onNavigationIconClicked = onBackPress,
@ -35,10 +49,52 @@ fun ExtensionRepoScreen(
},
appBarType = AppBarType.SMALL,
) { innerPadding ->
EmptyScreen(
if (repoState.value is ExtensionRepoState.Loading) return@YokaiScaffold
val repos = (repoState.value as ExtensionRepoState.Success).repos
LazyColumn(
modifier = Modifier.padding(innerPadding),
image = Icons.Filled.ExtensionOff,
message = "No extension repo found",
)
userScrollEnabled = true,
verticalArrangement = Arrangement.Top,
) {
item {
ExtensionRepoItem(
inputText = inputText,
inputHint = "Add new repo",
onInputChange = { inputText = it },
onAddClick = { viewModel.addRepo(it) },
)
}
if (repos.isEmpty()) {
item {
EmptyScreen(
modifier = Modifier.fillParentMaxSize(),
image = Icons.Filled.ExtensionOff,
message = "No extension repo found",
)
}
return@LazyColumn
}
repos.forEach { repo ->
item {
ExtensionRepoItem(
repoUrl = repo,
onDeleteClick = { viewModel.deleteRepo(it) },
)
}
}
}
}
LaunchedEffect(Unit) {
viewModel.event.collectLatest { event ->
if (event is ExtensionRepoEvent.LocalizedMessage)
context.toast(event.stringRes)
if (event is ExtensionRepoEvent.Success)
inputText = ""
}
}
}

View file

@ -28,29 +28,27 @@ class ExtensionRepoViewModel :
val event: StateFlow<ExtensionRepoEvent> = internalEvent.asStateFlow()
init {
refresh()
viewModelScope.launchIO {
repository.getRepo().collectLatest { repos ->
mutableRepoState.update { ExtensionRepoState.Success(repos = repos.toImmutableList()) }
}
}
}
fun addRepo(url: String) {
viewModelScope.launchIO {
val result = repository.addRepo(url)
if (result is Result.Error) return@launchIO
refresh()
if (result is Result.Error) {
internalEvent.value = ExtensionRepoEvent.InvalidUrl
return@launchIO
}
internalEvent.value = ExtensionRepoEvent.Success
}
}
fun deleteRepo(repo: String) {
viewModelScope.launchIO {
repository.deleteRepo(repo)
refresh()
}
}
fun refresh() {
viewModelScope.launchIO {
repository.getRepo().collectLatest { repos ->
mutableRepoState.update { ExtensionRepoState.Success(repos = repos.toImmutableList()) }
}
}
}
}
@ -59,6 +57,7 @@ sealed class ExtensionRepoEvent {
sealed class LocalizedMessage(@StringRes val stringRes: Int) : ExtensionRepoEvent()
data object InvalidUrl : LocalizedMessage(R.string.invalid_repo_url)
data object NoOp : ExtensionRepoEvent()
data object Success : ExtensionRepoEvent()
}
sealed class ExtensionRepoState {

View file

@ -1,26 +1,102 @@
package dev.yokai.presentation.extension.repo.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.IconButton
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.TextFieldDefaults.indicatorLine
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Label
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.tachiyomi.util.compose.textHint
@Composable
fun ExtensionRepoItem(
modifier: Modifier = Modifier,
repoUrl: String,
repoUrl: String? = null,
inputText: String = "",
onInputChange: (String) -> Unit = {},
inputHint: String? = null,
onAddClick: (String) -> Unit = {},
onDeleteClick: (String) -> Unit = {},
) {
require(repoUrl != null || inputHint != null)
val interactionSource = remember { MutableInteractionSource() }
Row(
modifier = modifier,
modifier = modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = repoUrl,
Icon(
modifier = Modifier.padding(horizontal = 8.dp),
imageVector = if (repoUrl != null) Icons.Outlined.Label else Icons.Filled.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
)
Image(imageVector = Icons.Filled.Delete, contentDescription = null)
if (repoUrl != null) {
Text(
modifier = Modifier.weight(1.0f),
text = repoUrl,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 16.sp,
)
IconButton(onClick = { onDeleteClick(repoUrl) }) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
)
}
} else {
val colors = TextFieldDefaults.textFieldColors(
cursorColor = MaterialTheme.colorScheme.secondary,
placeholderColor = MaterialTheme.colorScheme.textHint,
textColor = MaterialTheme.colorScheme.onBackground,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
)
TextField(
modifier = Modifier.indicatorLine(
enabled = false,
colors = colors,
interactionSource = interactionSource,
isError = true,
).weight(1.0f),
value = inputText,
onValueChange = onInputChange,
enabled = true,
placeholder = { Text(text = inputHint!!, fontSize = 16.sp) },
textStyle = TextStyle(fontSize = 16.sp),
colors = colors,
)
IconButton(
onClick = { onAddClick(inputText) },
enabled = inputText.isNotEmpty(),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
)
}
}
}
}

View file

@ -28,9 +28,8 @@ internal class ExtensionApi {
return withIOContext {
val extensions = sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) }
// Sanity check - a small number of extensions probably means something broke
// with the repo generator
if (extensions.size < 50) {
// Sanity check - empty probably means something broke
if (extensions.isEmpty()) {
throw Exception()
}
@ -118,11 +117,6 @@ internal class ExtensionApi {
}
}
private const val BASE_URL = "https://raw.githubusercontent.com/"
private const val REPO_URL_PREFIX = "${BASE_URL}keiyoushi/extensions/repo/"
private const val FALLBACK_BASE_URL = "https://gcore.jsdelivr.net/gh/"
private const val FALLBACK_REPO_URL_PREFIX = "${FALLBACK_BASE_URL}keiyoushi/extensions@repo/"
@Serializable
private data class ExtensionJsonObject(
val name: String,

View file

@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.migration.MigrationController
import eu.kanade.tachiyomi.util.lang.addBetaTag
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.withFadeTransaction
import uy.kohesive.injekt.injectLazy
@ -41,7 +42,7 @@ class SettingsBrowseController : SettingsController() {
preferenceCategory {
titleRes = R.string.extensions
preference {
titleRes = R.string.source_repos
title = context.getString(R.string.source_repos).addBetaTag(context)
onClick { router.pushController(ExtensionRepoController().withFadeTransaction()) }
// TODO: Enable once it's finished
summary = "Temporarily disabled, will be enabled once it's fully implemented"