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) { class SourcePreferences(private val preferenceStore: PreferenceStore) {
fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet()) fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet())
fun trustedExtensions() = preferenceStore.getStringSet("trusted_extensions", emptySet())
} }

View file

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

View file

@ -1,6 +1,8 @@
package dev.yokai.presentation.extension.repo package dev.yokai.presentation.extension.repo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ExtensionOff 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.yokai.presentation.AppBarType import dev.yokai.presentation.AppBarType
import dev.yokai.presentation.YokaiScaffold import dev.yokai.presentation.YokaiScaffold
import dev.yokai.presentation.component.EmptyScreen import dev.yokai.presentation.component.EmptyScreen
import dev.yokai.presentation.extension.repo.component.ExtensionRepoItem
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
@Composable @Composable
fun ExtensionRepoScreen( fun ExtensionRepoScreen(
title: String, title: String,
onBackPress: () -> Unit, onBackPress: () -> Unit,
viewModel: ExtensionRepoViewModel = viewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val repoState = viewModel.repoState.collectAsState()
var inputText by remember { mutableStateOf("") }
YokaiScaffold( YokaiScaffold(
onNavigationIconClicked = onBackPress, onNavigationIconClicked = onBackPress,
@ -35,10 +49,52 @@ fun ExtensionRepoScreen(
}, },
appBarType = AppBarType.SMALL, appBarType = AppBarType.SMALL,
) { innerPadding -> ) { innerPadding ->
EmptyScreen( if (repoState.value is ExtensionRepoState.Loading) return@YokaiScaffold
val repos = (repoState.value as ExtensionRepoState.Success).repos
LazyColumn(
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
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, image = Icons.Filled.ExtensionOff,
message = "No extension repo found", 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() val event: StateFlow<ExtensionRepoEvent> = internalEvent.asStateFlow()
init { init {
refresh() viewModelScope.launchIO {
repository.getRepo().collectLatest { repos ->
mutableRepoState.update { ExtensionRepoState.Success(repos = repos.toImmutableList()) }
}
}
} }
fun addRepo(url: String) { fun addRepo(url: String) {
viewModelScope.launchIO { viewModelScope.launchIO {
val result = repository.addRepo(url) val result = repository.addRepo(url)
if (result is Result.Error) return@launchIO if (result is Result.Error) {
refresh() internalEvent.value = ExtensionRepoEvent.InvalidUrl
return@launchIO
}
internalEvent.value = ExtensionRepoEvent.Success
} }
} }
fun deleteRepo(repo: String) { fun deleteRepo(repo: String) {
viewModelScope.launchIO { viewModelScope.launchIO {
repository.deleteRepo(repo) 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() sealed class LocalizedMessage(@StringRes val stringRes: Int) : ExtensionRepoEvent()
data object InvalidUrl : LocalizedMessage(R.string.invalid_repo_url) data object InvalidUrl : LocalizedMessage(R.string.invalid_repo_url)
data object NoOp : ExtensionRepoEvent() data object NoOp : ExtensionRepoEvent()
data object Success : ExtensionRepoEvent()
} }
sealed class ExtensionRepoState { sealed class ExtensionRepoState {

View file

@ -1,26 +1,102 @@
package dev.yokai.presentation.extension.repo.component 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.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.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.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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 @Composable
fun ExtensionRepoItem( fun ExtensionRepoItem(
modifier: Modifier = Modifier, 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( Row(
modifier = modifier, modifier = modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Icon(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.padding(horizontal = 8.dp),
text = repoUrl, imageVector = if (repoUrl != null) Icons.Outlined.Label else Icons.Filled.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
)
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,
) )
Image(imageVector = Icons.Filled.Delete, contentDescription = null) }
}
} }
} }

View file

@ -28,9 +28,8 @@ internal class ExtensionApi {
return withIOContext { return withIOContext {
val extensions = sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) } val extensions = sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) }
// Sanity check - a small number of extensions probably means something broke // Sanity check - empty probably means something broke
// with the repo generator if (extensions.isEmpty()) {
if (extensions.size < 50) {
throw Exception() 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 @Serializable
private data class ExtensionJsonObject( private data class ExtensionJsonObject(
val name: String, 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.source.SourceManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.migration.MigrationController 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.snack
import eu.kanade.tachiyomi.util.view.withFadeTransaction import eu.kanade.tachiyomi.util.view.withFadeTransaction
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -41,7 +42,7 @@ class SettingsBrowseController : SettingsController() {
preferenceCategory { preferenceCategory {
titleRes = R.string.extensions titleRes = R.string.extensions
preference { preference {
titleRes = R.string.source_repos title = context.getString(R.string.source_repos).addBetaTag(context)
onClick { router.pushController(ExtensionRepoController().withFadeTransaction()) } onClick { router.pushController(ExtensionRepoController().withFadeTransaction()) }
// TODO: Enable once it's finished // TODO: Enable once it's finished
summary = "Temporarily disabled, will be enabled once it's fully implemented" summary = "Temporarily disabled, will be enabled once it's fully implemented"