Support external repos

Largely taken from SY.

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
This commit is contained in:
arkon
2024-01-05 17:28:08 -05:00
parent 32bed9b041
commit c17ada2c98
20 changed files with 557 additions and 59 deletions

View File

@@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -116,7 +117,7 @@ fun ExtensionDetailsScreen(
) { paddingValues ->
if (state.extension == null) {
EmptyScreen(
stringRes = MR.strings.empty_screen,
MR.strings.empty_screen,
modifier = Modifier.padding(paddingValues),
)
return@Scaffold
@@ -149,6 +150,21 @@ private fun ExtensionDetails(
contentPadding = contentPadding,
) {
when {
extension.isRepoSource ->
item {
val uriHandler = LocalUriHandler.current
WarningBanner(
MR.strings.repo_extension_message,
modifier = Modifier.clickable {
extension.repoUrl ?: return@clickable
uriHandler.openUri(
extension.repoUrl
.replace("https://raw.githubusercontent.com", "https://github.com")
.removeSuffix("/repo/"),
)
},
)
}
extension.isUnofficial ->
item {
WarningBanner(MR.strings.unofficial_extension_message)

View File

@@ -0,0 +1,60 @@
package eu.kanade.presentation.category
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.category.components.repo.SourceRepoContent
import eu.kanade.presentation.category.repos.RepoScreenState
import eu.kanade.presentation.components.AppBar
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.components.material.topSmallPaddingValues
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus
@Composable
fun SourceRepoScreen(
state: RepoScreenState.Success,
onClickCreate: () -> Unit,
onClickDelete: (String) -> Unit,
navigateUp: () -> Unit,
) {
val lazyListState = rememberLazyListState()
Scaffold(
topBar = { scrollBehavior ->
AppBar(
navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos),
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CategoryFloatingActionButton(
lazyListState = lazyListState,
onCreate = onClickCreate,
)
},
) { paddingValues ->
if (state.isEmpty) {
EmptyScreen(
MR.strings.information_empty_repos,
modifier = Modifier.padding(paddingValues),
)
return@Scaffold
}
SourceRepoContent(
repos = state.repos,
lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium),
onClickDelete = onClickDelete,
)
}
}

View File

@@ -25,9 +25,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import dev.icerock.moko.resources.StringResource
import eu.kanade.core.preference.asToggleableState
import eu.kanade.presentation.category.visualName
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import tachiyomi.core.preference.CheckboxState
import tachiyomi.domain.category.model.Category
@@ -40,12 +42,15 @@ import kotlin.time.Duration.Companion.seconds
fun CategoryCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
categories: ImmutableList<Category>,
categories: ImmutableList<String>,
title: String,
extraMessage: String? = null,
alreadyExistsError: StringResource = MR.strings.error_category_exists,
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { categories.anyWithName(name) }
val nameAlreadyExists = remember(name) { categories.contains(name) }
AlertDialog(
onDismissRequest = onDismissRequest,
@@ -66,25 +71,32 @@ fun CategoryCreateDialog(
}
},
title = {
Text(text = stringResource(MR.strings.action_add_category))
Text(text = title)
},
text = {
OutlinedTextField(
modifier = Modifier.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
label = { Text(text = stringResource(MR.strings.name)) },
supportingText = {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
MR.strings.error_category_exists
} else {
MR.strings.information_required_plain
}
Text(text = stringResource(msgRes))
},
isError = name.isNotEmpty() && nameAlreadyExists,
singleLine = true,
)
Column {
extraMessage?.let { Text(it) }
OutlinedTextField(
modifier = Modifier
.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
label = {
Text(text = stringResource(MR.strings.name))
},
supportingText = {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
alreadyExistsError
} else {
MR.strings.information_required_plain
}
Text(text = stringResource(msgRes))
},
isError = name.isNotEmpty() && nameAlreadyExists,
singleLine = true,
)
}
},
)
@@ -99,14 +111,15 @@ fun CategoryCreateDialog(
fun CategoryRenameDialog(
onDismissRequest: () -> Unit,
onRename: (String) -> Unit,
categories: ImmutableList<Category>,
category: Category,
categories: ImmutableList<String>,
category: String,
alreadyExistsError: StringResource = MR.strings.error_category_exists,
) {
var name by remember { mutableStateOf(category.name) }
var name by remember { mutableStateOf(category) }
var valueHasChanged by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { categories.anyWithName(name) }
val nameAlreadyExists = remember(name) { categories.contains(name) }
AlertDialog(
onDismissRequest = onDismissRequest,
@@ -140,7 +153,7 @@ fun CategoryRenameDialog(
label = { Text(text = stringResource(MR.strings.name)) },
supportingText = {
val msgRes = if (valueHasChanged && nameAlreadyExists) {
MR.strings.error_category_exists
alreadyExistsError
} else {
MR.strings.information_required_plain
}
@@ -163,7 +176,8 @@ fun CategoryRenameDialog(
fun CategoryDeleteDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
category: Category,
title: String,
text: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
@@ -181,10 +195,10 @@ fun CategoryDeleteDialog(
}
},
title = {
Text(text = stringResource(MR.strings.delete_category))
Text(text = title)
},
text = {
Text(text = stringResource(MR.strings.delete_category_confirmation, category.name))
Text(text = text)
},
)
}
@@ -220,7 +234,7 @@ fun CategorySortAlphabeticallyDialog(
@Composable
fun ChangeCategoryDialog(
initialSelection: List<CheckboxState<Category>>,
initialSelection: ImmutableList<CheckboxState<Category>>,
onDismissRequest: () -> Unit,
onEditCategories: () -> Unit,
onConfirm: (List<Long>, List<Long>) -> Unit,
@@ -292,7 +306,7 @@ fun ChangeCategoryDialog(
if (index != -1) {
val mutableList = selection.toMutableList()
mutableList[index] = it.next()
selection = mutableList.toList()
selection = mutableList.toList().toImmutableList()
}
}
Row(
@@ -326,7 +340,3 @@ fun ChangeCategoryDialog(
},
)
}
private fun List<Category>.anyWithName(name: String): Boolean {
return any { name == it.name }
}

View File

@@ -0,0 +1,79 @@
package eu.kanade.presentation.category.components.repo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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 kotlinx.collections.immutable.ImmutableList
import tachiyomi.presentation.core.components.material.padding
@Composable
fun SourceRepoContent(
repos: ImmutableList<String>,
lazyListState: LazyListState,
paddingValues: PaddingValues,
onClickDelete: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
state = lazyListState,
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
modifier = modifier,
) {
items(repos) { repo ->
SourceRepoListItem(
modifier = Modifier.animateItemPlacement(),
repo = repo,
onDelete = { onClickDelete(repo) },
)
}
}
}
@Composable
private fun SourceRepoListItem(
repo: String,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
ElevatedCard(
modifier = modifier,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = MaterialTheme.padding.medium,
top = MaterialTheme.padding.medium,
end = MaterialTheme.padding.medium,
),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = "")
Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium))
}
Row {
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onDelete) {
Icon(imageVector = Icons.Outlined.Delete, contentDescription = "")
}
}
}
}

View File

@@ -0,0 +1,75 @@
package eu.kanade.presentation.category.repos
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.category.SourceRepoScreen
import eu.kanade.presentation.category.components.CategoryCreateDialog
import eu.kanade.presentation.category.components.CategoryDeleteDialog
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
class RepoScreen : Screen() {
@Composable
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { RepoScreenModel() }
val state by screenModel.state.collectAsState()
if (state is RepoScreenState.Loading) {
LoadingScreen()
return
}
val successState = state as RepoScreenState.Success
SourceRepoScreen(
state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
navigateUp = navigator::pop,
)
when (val dialog = successState.dialog) {
null -> {}
RepoDialog.Create -> {
CategoryCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) },
categories = successState.repos,
title = stringResource(MR.strings.action_add_repo),
extraMessage = stringResource(MR.strings.action_add_repo_message),
alreadyExistsError = MR.strings.error_repo_exists,
)
}
is RepoDialog.Delete -> {
CategoryDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepos(listOf(dialog.repo)) },
title = stringResource(MR.strings.action_delete_repo),
text = stringResource(MR.strings.delete_repo_confirmation, dialog.repo),
)
}
}
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is RepoEvent.LocalizedMessage) {
context.toast(event.stringRes)
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
package eu.kanade.presentation.category.repos
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.source.interactor.CreateSourceRepo
import eu.kanade.domain.source.interactor.DeleteSourceRepos
import eu.kanade.domain.source.interactor.GetSourceRepos
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import tachiyomi.core.util.lang.launchIO
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class RepoScreenModel(
private val getSourceRepos: GetSourceRepos = Injekt.get(),
private val createSourceRepo: CreateSourceRepo = Injekt.get(),
private val deleteSourceRepos: DeleteSourceRepos = Injekt.get(),
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
val events = _events.receiveAsFlow()
init {
screenModelScope.launchIO {
getSourceRepos.subscribe()
.collectLatest { repos ->
mutableState.update {
RepoScreenState.Success(
repos = repos.toImmutableList(),
)
}
}
}
}
/**
* Creates and adds a new repo to the database.
*
* @param name The name of the repo to create.
*/
fun createRepo(name: String) {
screenModelScope.launchIO {
when (createSourceRepo.await(name)) {
is CreateSourceRepo.Result.InvalidName -> _events.send(RepoEvent.InvalidName)
else -> {}
}
}
}
/**
* Deletes the given repos from the database.
*
* @param repos The list of repos to delete.
*/
fun deleteRepos(repos: List<String>) {
screenModelScope.launchIO {
deleteSourceRepos.await(repos)
}
}
fun showDialog(dialog: RepoDialog) {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = dialog)
}
}
}
fun dismissDialog() {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = null)
}
}
}
}
sealed class RepoEvent {
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
data object InvalidName : LocalizedMessage(MR.strings.invalid_repo_name)
data object InternalError : LocalizedMessage(MR.strings.internal_error)
}
sealed class RepoDialog {
data object Create : RepoDialog()
data class Delete(val repo: String) : RepoDialog()
}
sealed class RepoScreenState {
@Immutable
data object Loading : RepoScreenState()
@Immutable
data class Success(
val repos: ImmutableList<String>,
val dialog: RepoDialog? = null,
) : RepoScreenState() {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}

View File

@@ -2,16 +2,22 @@ package eu.kanade.presentation.more.settings.screen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.category.repos.RepoScreen
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@@ -24,7 +30,11 @@ object SettingsBrowseScreen : SearchableSettings {
@Composable
override fun getPreferences(): List<Preference> {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val reposCount by sourcePreferences.extensionRepos().collectAsState()
return listOf(
Preference.PreferenceGroup(
title = stringResource(MR.strings.label_sources),
@@ -33,6 +43,13 @@ object SettingsBrowseScreen : SearchableSettings {
pref = sourcePreferences.hideInLibraryItems(),
title = stringResource(MR.strings.pref_hide_in_library_items),
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.label_extension_repos),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size),
onClick = {
navigator.push(RepoScreen())
},
),
),
),
Preference.PreferenceGroup(