Support external repos
Largely taken from SY. Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
This commit is contained in:
parent
32bed9b041
commit
c17ada2c98
20 changed files with 557 additions and 59 deletions
|
@ -11,8 +11,11 @@ import eu.kanade.domain.manga.interactor.GetExcludedScanlators
|
||||||
import eu.kanade.domain.manga.interactor.SetExcludedScanlators
|
import eu.kanade.domain.manga.interactor.SetExcludedScanlators
|
||||||
import eu.kanade.domain.manga.interactor.SetMangaViewerFlags
|
import eu.kanade.domain.manga.interactor.SetMangaViewerFlags
|
||||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||||
|
import eu.kanade.domain.source.interactor.CreateSourceRepo
|
||||||
|
import eu.kanade.domain.source.interactor.DeleteSourceRepos
|
||||||
import eu.kanade.domain.source.interactor.GetEnabledSources
|
import eu.kanade.domain.source.interactor.GetEnabledSources
|
||||||
import eu.kanade.domain.source.interactor.GetLanguagesWithSources
|
import eu.kanade.domain.source.interactor.GetLanguagesWithSources
|
||||||
|
import eu.kanade.domain.source.interactor.GetSourceRepos
|
||||||
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
|
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
|
||||||
import eu.kanade.domain.source.interactor.SetMigrateSorting
|
import eu.kanade.domain.source.interactor.SetMigrateSorting
|
||||||
import eu.kanade.domain.source.interactor.ToggleLanguage
|
import eu.kanade.domain.source.interactor.ToggleLanguage
|
||||||
|
@ -167,5 +170,9 @@ class DomainModule : InjektModule {
|
||||||
addFactory { ToggleLanguage(get()) }
|
addFactory { ToggleLanguage(get()) }
|
||||||
addFactory { ToggleSource(get()) }
|
addFactory { ToggleSource(get()) }
|
||||||
addFactory { ToggleSourcePin(get()) }
|
addFactory { ToggleSourcePin(get()) }
|
||||||
|
|
||||||
|
addFactory { CreateSourceRepo(get()) }
|
||||||
|
addFactory { DeleteSourceRepos(get()) }
|
||||||
|
addFactory { GetSourceRepos(get()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package eu.kanade.domain.source.interactor
|
||||||
|
|
||||||
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
|
import tachiyomi.core.preference.plusAssign
|
||||||
|
|
||||||
|
class CreateSourceRepo(private val preferences: SourcePreferences) {
|
||||||
|
|
||||||
|
fun await(name: String): Result {
|
||||||
|
// Do not allow invalid formats
|
||||||
|
if (!name.matches(repoRegex)) {
|
||||||
|
return Result.InvalidName
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences.extensionRepos() += name
|
||||||
|
|
||||||
|
return Result.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
data object InvalidName : Result()
|
||||||
|
data object Success : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a repo with the given name already exists.
|
||||||
|
*/
|
||||||
|
private fun repoExists(name: String): Boolean {
|
||||||
|
return preferences.extensionRepos().get().any { it.equals(name, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val repoRegex = """^[a-zA-Z0-9-_.]*?\/[a-zA-Z0-9-_.]*?$""".toRegex()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
package eu.kanade.domain.source.interactor
|
||||||
|
|
||||||
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
|
|
||||||
|
class DeleteSourceRepos(private val preferences: SourcePreferences) {
|
||||||
|
|
||||||
|
fun await(repos: List<String>) {
|
||||||
|
preferences.extensionRepos().set(
|
||||||
|
preferences.extensionRepos().get().filterNot { it in repos }.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
package eu.kanade.domain.source.interactor
|
||||||
|
|
||||||
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class GetSourceRepos(private val preferences: SourcePreferences) {
|
||||||
|
|
||||||
|
fun subscribe(): Flow<List<String>> {
|
||||||
|
return preferences.extensionRepos().changes().map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) }
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,6 +38,8 @@ class SourcePreferences(
|
||||||
SetMigrateSorting.Direction.ASCENDING,
|
SetMigrateSorting.Direction.ASCENDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet())
|
||||||
|
|
||||||
fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0)
|
fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0)
|
||||||
|
|
||||||
fun trustedSignatures() = preferenceStore.getStringSet(Preference.appStateKey("trusted_signatures"), emptySet())
|
fun trustedSignatures() = preferenceStore.getStringSet(Preference.appStateKey("trusted_signatures"), emptySet())
|
||||||
|
|
|
@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
@ -116,7 +117,7 @@ fun ExtensionDetailsScreen(
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
if (state.extension == null) {
|
if (state.extension == null) {
|
||||||
EmptyScreen(
|
EmptyScreen(
|
||||||
stringRes = MR.strings.empty_screen,
|
MR.strings.empty_screen,
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
)
|
)
|
||||||
return@Scaffold
|
return@Scaffold
|
||||||
|
@ -149,6 +150,21 @@ private fun ExtensionDetails(
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
) {
|
) {
|
||||||
when {
|
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 ->
|
extension.isUnofficial ->
|
||||||
item {
|
item {
|
||||||
WarningBanner(MR.strings.unofficial_extension_message)
|
WarningBanner(MR.strings.unofficial_extension_message)
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,9 +25,11 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.core.preference.asToggleableState
|
||||||
import eu.kanade.presentation.category.visualName
|
import eu.kanade.presentation.category.visualName
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import tachiyomi.core.preference.CheckboxState
|
import tachiyomi.core.preference.CheckboxState
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
|
@ -40,12 +42,15 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
fun CategoryCreateDialog(
|
fun CategoryCreateDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onCreate: (String) -> 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("") }
|
var name by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val nameAlreadyExists = remember(name) { categories.anyWithName(name) }
|
val nameAlreadyExists = remember(name) { categories.contains(name) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
|
@ -66,25 +71,32 @@ fun CategoryCreateDialog(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(text = stringResource(MR.strings.action_add_category))
|
Text(text = title)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
OutlinedTextField(
|
Column {
|
||||||
modifier = Modifier.focusRequester(focusRequester),
|
extraMessage?.let { Text(it) }
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
OutlinedTextField(
|
||||||
label = { Text(text = stringResource(MR.strings.name)) },
|
modifier = Modifier
|
||||||
supportingText = {
|
.focusRequester(focusRequester),
|
||||||
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
|
value = name,
|
||||||
MR.strings.error_category_exists
|
onValueChange = { name = it },
|
||||||
} else {
|
label = {
|
||||||
MR.strings.information_required_plain
|
Text(text = stringResource(MR.strings.name))
|
||||||
}
|
},
|
||||||
Text(text = stringResource(msgRes))
|
supportingText = {
|
||||||
},
|
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
|
||||||
isError = name.isNotEmpty() && nameAlreadyExists,
|
alreadyExistsError
|
||||||
singleLine = true,
|
} else {
|
||||||
)
|
MR.strings.information_required_plain
|
||||||
|
}
|
||||||
|
Text(text = stringResource(msgRes))
|
||||||
|
},
|
||||||
|
isError = name.isNotEmpty() && nameAlreadyExists,
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -99,14 +111,15 @@ fun CategoryCreateDialog(
|
||||||
fun CategoryRenameDialog(
|
fun CategoryRenameDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onRename: (String) -> Unit,
|
onRename: (String) -> Unit,
|
||||||
categories: ImmutableList<Category>,
|
categories: ImmutableList<String>,
|
||||||
category: Category,
|
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) }
|
var valueHasChanged by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val nameAlreadyExists = remember(name) { categories.anyWithName(name) }
|
val nameAlreadyExists = remember(name) { categories.contains(name) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
|
@ -140,7 +153,7 @@ fun CategoryRenameDialog(
|
||||||
label = { Text(text = stringResource(MR.strings.name)) },
|
label = { Text(text = stringResource(MR.strings.name)) },
|
||||||
supportingText = {
|
supportingText = {
|
||||||
val msgRes = if (valueHasChanged && nameAlreadyExists) {
|
val msgRes = if (valueHasChanged && nameAlreadyExists) {
|
||||||
MR.strings.error_category_exists
|
alreadyExistsError
|
||||||
} else {
|
} else {
|
||||||
MR.strings.information_required_plain
|
MR.strings.information_required_plain
|
||||||
}
|
}
|
||||||
|
@ -163,7 +176,8 @@ fun CategoryRenameDialog(
|
||||||
fun CategoryDeleteDialog(
|
fun CategoryDeleteDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
category: Category,
|
title: String,
|
||||||
|
text: String,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
|
@ -181,10 +195,10 @@ fun CategoryDeleteDialog(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(text = stringResource(MR.strings.delete_category))
|
Text(text = title)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(text = stringResource(MR.strings.delete_category_confirmation, category.name))
|
Text(text = text)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -220,7 +234,7 @@ fun CategorySortAlphabeticallyDialog(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChangeCategoryDialog(
|
fun ChangeCategoryDialog(
|
||||||
initialSelection: List<CheckboxState<Category>>,
|
initialSelection: ImmutableList<CheckboxState<Category>>,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onEditCategories: () -> Unit,
|
onEditCategories: () -> Unit,
|
||||||
onConfirm: (List<Long>, List<Long>) -> Unit,
|
onConfirm: (List<Long>, List<Long>) -> Unit,
|
||||||
|
@ -292,7 +306,7 @@ fun ChangeCategoryDialog(
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
val mutableList = selection.toMutableList()
|
val mutableList = selection.toMutableList()
|
||||||
mutableList[index] = it.next()
|
mutableList[index] = it.next()
|
||||||
selection = mutableList.toList()
|
selection = mutableList.toList().toImmutableList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
|
@ -326,7 +340,3 @@ fun ChangeCategoryDialog(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<Category>.anyWithName(name: String): Boolean {
|
|
||||||
return any { name == it.name }
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,16 +2,22 @@ package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.fragment.app.FragmentActivity
|
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.domain.source.service.SourcePreferences
|
||||||
|
import eu.kanade.presentation.category.repos.RepoScreen
|
||||||
import eu.kanade.presentation.more.settings.Preference
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import tachiyomi.core.i18n.stringResource
|
import tachiyomi.core.i18n.stringResource
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
|
import tachiyomi.presentation.core.i18n.pluralStringResource
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
|
import tachiyomi.presentation.core.util.collectAsState
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
@ -24,7 +30,11 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||||
@Composable
|
@Composable
|
||||||
override fun getPreferences(): List<Preference> {
|
override fun getPreferences(): List<Preference> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
|
||||||
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
|
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
|
||||||
|
val reposCount by sourcePreferences.extensionRepos().collectAsState()
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
Preference.PreferenceGroup(
|
Preference.PreferenceGroup(
|
||||||
title = stringResource(MR.strings.label_sources),
|
title = stringResource(MR.strings.label_sources),
|
||||||
|
@ -33,6 +43,13 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||||
pref = sourcePreferences.hideInLibraryItems(),
|
pref = sourcePreferences.hideInLibraryItems(),
|
||||||
title = stringResource(MR.strings.pref_hide_in_library_items),
|
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(
|
Preference.PreferenceGroup(
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package eu.kanade.tachiyomi.extension.api
|
package eu.kanade.tachiyomi.extension.api
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||||
|
@ -24,6 +25,7 @@ internal class ExtensionGithubApi {
|
||||||
|
|
||||||
private val networkService: NetworkHelper by injectLazy()
|
private val networkService: NetworkHelper by injectLazy()
|
||||||
private val preferenceStore: PreferenceStore by injectLazy()
|
private val preferenceStore: PreferenceStore by injectLazy()
|
||||||
|
private val sourcePreferences: SourcePreferences by injectLazy()
|
||||||
private val extensionManager: ExtensionManager by injectLazy()
|
private val extensionManager: ExtensionManager by injectLazy()
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
@ -58,7 +60,20 @@ internal class ExtensionGithubApi {
|
||||||
val extensions = with(json) {
|
val extensions = with(json) {
|
||||||
response
|
response
|
||||||
.parseAs<List<ExtensionJsonObject>>()
|
.parseAs<List<ExtensionJsonObject>>()
|
||||||
.toExtensions()
|
.toExtensions() + sourcePreferences.extensionRepos()
|
||||||
|
.get()
|
||||||
|
.flatMap { repoPath ->
|
||||||
|
val url = if (requiresFallbackSource) {
|
||||||
|
"$FALLBACK_BASE_URL$repoPath@repo/"
|
||||||
|
} else {
|
||||||
|
"$BASE_URL$repoPath/repo/"
|
||||||
|
}
|
||||||
|
networkService.client
|
||||||
|
.newCall(GET("${url}index.min.json"))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs<List<ExtensionJsonObject>>()
|
||||||
|
.toExtensions(url, repoSource = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanity check - a small number of extensions probably means something broke
|
// Sanity check - a small number of extensions probably means something broke
|
||||||
|
@ -71,10 +86,7 @@ internal class ExtensionGithubApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun checkForUpdates(
|
suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<Extension.Installed>? {
|
||||||
context: Context,
|
|
||||||
fromAvailableExtensionList: Boolean = false,
|
|
||||||
): List<Extension.Installed>? {
|
|
||||||
// Limit checks to once a day at most
|
// Limit checks to once a day at most
|
||||||
if (!fromAvailableExtensionList &&
|
if (!fromAvailableExtensionList &&
|
||||||
Instant.now().toEpochMilli() < lastExtCheck.get() + 1.days.inWholeMilliseconds
|
Instant.now().toEpochMilli() < lastExtCheck.get() + 1.days.inWholeMilliseconds
|
||||||
|
@ -111,7 +123,10 @@ internal class ExtensionGithubApi {
|
||||||
return extensionsWithUpdate
|
return extensionsWithUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<ExtensionJsonObject>.toExtensions(): List<Extension.Available> {
|
private fun List<ExtensionJsonObject>.toExtensions(
|
||||||
|
repoUrl: String = getUrlPrefix(),
|
||||||
|
repoSource: Boolean = false,
|
||||||
|
): List<Extension.Available> {
|
||||||
return this
|
return this
|
||||||
.filter {
|
.filter {
|
||||||
val libVersion = it.extractLibVersion()
|
val libVersion = it.extractLibVersion()
|
||||||
|
@ -128,13 +143,15 @@ internal class ExtensionGithubApi {
|
||||||
isNsfw = it.nsfw == 1,
|
isNsfw = it.nsfw == 1,
|
||||||
sources = it.sources?.map(extensionSourceMapper).orEmpty(),
|
sources = it.sources?.map(extensionSourceMapper).orEmpty(),
|
||||||
apkName = it.apk,
|
apkName = it.apk,
|
||||||
iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png",
|
iconUrl = "${repoUrl}icon/${it.pkg}.png",
|
||||||
|
repoUrl = repoUrl,
|
||||||
|
isRepoSource = repoSource,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getApkUrl(extension: Extension.Available): String {
|
fun getApkUrl(extension: Extension.Available): String {
|
||||||
return "${getUrlPrefix()}apk/${extension.apkName}"
|
return "${extension.repoUrl}/apk/${extension.apkName}"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getUrlPrefix(): String {
|
private fun getUrlPrefix(): String {
|
||||||
|
@ -150,8 +167,10 @@ internal class ExtensionGithubApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/"
|
private const val BASE_URL = "https://raw.githubusercontent.com/"
|
||||||
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/"
|
private const val REPO_URL_PREFIX = "${BASE_URL}tachiyomiorg/tachiyomi-extensions/repo/"
|
||||||
|
private const val FALLBACK_BASE_URL = "https://gcore.jsdelivr.net/gh/"
|
||||||
|
private const val FALLBACK_REPO_URL_PREFIX = "${FALLBACK_BASE_URL}tachiyomiorg/tachiyomi-extensions@repo/"
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class ExtensionJsonObject(
|
private data class ExtensionJsonObject(
|
||||||
|
|
|
@ -29,6 +29,8 @@ sealed class Extension {
|
||||||
val isObsolete: Boolean = false,
|
val isObsolete: Boolean = false,
|
||||||
val isUnofficial: Boolean = false,
|
val isUnofficial: Boolean = false,
|
||||||
val isShared: Boolean,
|
val isShared: Boolean,
|
||||||
|
val repoUrl: String? = null,
|
||||||
|
val isRepoSource: Boolean = false,
|
||||||
) : Extension()
|
) : Extension()
|
||||||
|
|
||||||
data class Available(
|
data class Available(
|
||||||
|
@ -42,6 +44,8 @@ sealed class Extension {
|
||||||
val sources: List<Source>,
|
val sources: List<Source>,
|
||||||
val apkName: String,
|
val apkName: String,
|
||||||
val iconUrl: String,
|
val iconUrl: String,
|
||||||
|
val repoUrl: String,
|
||||||
|
val isRepoSource: Boolean,
|
||||||
) : Extension() {
|
) : Extension() {
|
||||||
|
|
||||||
data class Source(
|
data class Source(
|
||||||
|
|
|
@ -24,6 +24,8 @@ import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.util.removeCovers
|
import eu.kanade.tachiyomi.util.removeCovers
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
@ -265,7 +267,10 @@ class BrowseSourceScreenModel(
|
||||||
else -> {
|
else -> {
|
||||||
val preselectedIds = getCategories.await(manga.id).map { it.id }
|
val preselectedIds = getCategories.await(manga.id).map { it.id }
|
||||||
setDialog(
|
setDialog(
|
||||||
Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }),
|
Dialog.ChangeMangaCategory(
|
||||||
|
manga,
|
||||||
|
categories.mapAsCheckboxState { it.id in preselectedIds }.toImmutableList(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -338,7 +343,7 @@ class BrowseSourceScreenModel(
|
||||||
data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
|
data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
|
||||||
data class ChangeMangaCategory(
|
data class ChangeMangaCategory(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val initialSelection: List<CheckboxState.State<Category>>,
|
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
|
||||||
) : Dialog
|
) : Dialog
|
||||||
data class Migrate(val newManga: Manga) : Dialog
|
data class Migrate(val newManga: Manga) : Dialog
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.util.fastMap
|
||||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
@ -15,7 +16,10 @@ import eu.kanade.presentation.category.components.CategoryRenameDialog
|
||||||
import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog
|
import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import tachiyomi.i18n.MR
|
||||||
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||||
|
|
||||||
class CategoryScreen : Screen() {
|
class CategoryScreen : Screen() {
|
||||||
|
@ -52,22 +56,24 @@ class CategoryScreen : Screen() {
|
||||||
CategoryCreateDialog(
|
CategoryCreateDialog(
|
||||||
onDismissRequest = screenModel::dismissDialog,
|
onDismissRequest = screenModel::dismissDialog,
|
||||||
onCreate = screenModel::createCategory,
|
onCreate = screenModel::createCategory,
|
||||||
categories = successState.categories,
|
categories = successState.categories.fastMap { it.name }.toImmutableList(),
|
||||||
|
title = stringResource(MR.strings.action_add_category),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is CategoryDialog.Rename -> {
|
is CategoryDialog.Rename -> {
|
||||||
CategoryRenameDialog(
|
CategoryRenameDialog(
|
||||||
onDismissRequest = screenModel::dismissDialog,
|
onDismissRequest = screenModel::dismissDialog,
|
||||||
onRename = { screenModel.renameCategory(dialog.category, it) },
|
onRename = { screenModel.renameCategory(dialog.category, it) },
|
||||||
categories = successState.categories,
|
categories = successState.categories.fastMap { it.name }.toImmutableList(),
|
||||||
category = dialog.category,
|
category = dialog.category.name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is CategoryDialog.Delete -> {
|
is CategoryDialog.Delete -> {
|
||||||
CategoryDeleteDialog(
|
CategoryDeleteDialog(
|
||||||
onDismissRequest = screenModel::dismissDialog,
|
onDismissRequest = screenModel::dismissDialog,
|
||||||
onDelete = { screenModel.deleteCategory(dialog.category.id) },
|
onDelete = { screenModel.deleteCategory(dialog.category.id) },
|
||||||
category = dialog.category,
|
title = stringResource(MR.strings.delete_category),
|
||||||
|
text = stringResource(MR.strings.delete_category_confirmation, dialog.category.name),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is CategoryDialog.SortAlphabetically -> {
|
is CategoryDialog.SortAlphabetically -> {
|
||||||
|
|
|
@ -28,9 +28,11 @@ import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
||||||
import eu.kanade.tachiyomi.util.removeCovers
|
import eu.kanade.tachiyomi.util.removeCovers
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.PersistentList
|
import kotlinx.collections.immutable.PersistentList
|
||||||
import kotlinx.collections.immutable.mutate
|
import kotlinx.collections.immutable.mutate
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
@ -661,13 +663,15 @@ class LibraryScreenModel(
|
||||||
val common = getCommonCategories(mangaList)
|
val common = getCommonCategories(mangaList)
|
||||||
// Get indexes of the mix categories to preselect.
|
// Get indexes of the mix categories to preselect.
|
||||||
val mix = getMixCategories(mangaList)
|
val mix = getMixCategories(mangaList)
|
||||||
val preselected = categories.map {
|
val preselected = categories
|
||||||
when (it) {
|
.map {
|
||||||
in common -> CheckboxState.State.Checked(it)
|
when (it) {
|
||||||
in mix -> CheckboxState.TriState.Exclude(it)
|
in common -> CheckboxState.State.Checked(it)
|
||||||
else -> CheckboxState.State.None(it)
|
in mix -> CheckboxState.TriState.Exclude(it)
|
||||||
|
else -> CheckboxState.State.None(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.toImmutableList()
|
||||||
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) }
|
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -683,7 +687,10 @@ class LibraryScreenModel(
|
||||||
|
|
||||||
sealed interface Dialog {
|
sealed interface Dialog {
|
||||||
data object SettingsSheet : Dialog
|
data object SettingsSheet : Dialog
|
||||||
data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog
|
data class ChangeCategory(
|
||||||
|
val manga: List<Manga>,
|
||||||
|
val initialSelection: ImmutableList<CheckboxState<Category>>,
|
||||||
|
) : Dialog
|
||||||
data class DeleteManga(val manga: List<Manga>) : Dialog
|
data class DeleteManga(val manga: List<Manga>) : Dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,8 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||||
import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
||||||
import eu.kanade.tachiyomi.util.removeCovers
|
import eu.kanade.tachiyomi.util.removeCovers
|
||||||
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
@ -360,7 +362,7 @@ class MangaScreenModel(
|
||||||
successState.copy(
|
successState.copy(
|
||||||
dialog = Dialog.ChangeCategory(
|
dialog = Dialog.ChangeCategory(
|
||||||
manga = manga,
|
manga = manga,
|
||||||
initialSelection = categories.mapAsCheckboxState { it.id in selection },
|
initialSelection = categories.mapAsCheckboxState { it.id in selection }.toImmutableList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -992,7 +994,10 @@ class MangaScreenModel(
|
||||||
// Track sheet - end
|
// Track sheet - end
|
||||||
|
|
||||||
sealed interface Dialog {
|
sealed interface Dialog {
|
||||||
data class ChangeCategory(val manga: Manga, val initialSelection: List<CheckboxState<Category>>) : Dialog
|
data class ChangeCategory(
|
||||||
|
val manga: Manga,
|
||||||
|
val initialSelection: ImmutableList<CheckboxState<Category>>,
|
||||||
|
) : Dialog
|
||||||
data class DeleteChapters(val chapters: List<Chapter>) : Dialog
|
data class DeleteChapters(val chapters: List<Chapter>) : Dialog
|
||||||
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
|
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
|
||||||
data class SetFetchInterval(val manga: Manga) : Dialog
|
data class SetFetchInterval(val manga: Manga) : Dialog
|
||||||
|
|
|
@ -80,4 +80,9 @@
|
||||||
<item quantity="one">Extension update available</item>
|
<item quantity="one">Extension update available</item>
|
||||||
<item quantity="other">%d extension updates available</item>
|
<item quantity="other">%d extension updates available</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="num_repos">
|
||||||
|
<item quantity="one">%d repo</item>
|
||||||
|
<item quantity="other">%d repos</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -336,6 +336,17 @@
|
||||||
<string name="ext_installer_shizuku_stopped">Shizuku is not running</string>
|
<string name="ext_installer_shizuku_stopped">Shizuku is not running</string>
|
||||||
<string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
|
<string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
|
||||||
|
|
||||||
|
<!-- Extension repos -->
|
||||||
|
<string name="label_extension_repos">Extension repos</string>
|
||||||
|
<string name="information_empty_repos">You have no repos set.</string>
|
||||||
|
<string name="action_add_repo">Add repo</string>
|
||||||
|
<string name="action_add_repo_message">Add additional repos to Tachiyomi, the format of a repo is \"username/repo\", with username being the repo owner, and repo being the repo name.</string>
|
||||||
|
<string name="error_repo_exists">This repo already exists!</string>
|
||||||
|
<string name="action_delete_repo">Delete repo</string>
|
||||||
|
<string name="invalid_repo_name">Invalid repo name</string>
|
||||||
|
<string name="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
|
||||||
|
<string name="repo_extension_message">This extension is from an external repo. Tap to view the repo.</string>
|
||||||
|
|
||||||
<!-- Reader section -->
|
<!-- Reader section -->
|
||||||
<string name="pref_fullscreen">Fullscreen</string>
|
<string name="pref_fullscreen">Fullscreen</string>
|
||||||
<string name="pref_show_navigation_mode">Show tap zones overlay</string>
|
<string name="pref_show_navigation_mode">Show tap zones overlay</string>
|
||||||
|
|
Reference in a new issue