Convert Source tab to use Compose (#6987)

* Use Compose in Source tab

* Replace hashCode with key function

* Add ability to turn off pins moving on top of source list

* Changes from review comments

(cherry picked from commit 29a0989f2889d3361f583285091878c9b4570a52)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt
This commit is contained in:
Andreas 2022-04-24 20:35:59 +02:00 committed by Jobobby04
parent 41b2c948e1
commit c1659ad908
34 changed files with 1054 additions and 693 deletions

View File

@ -0,0 +1,13 @@
package eu.kanade.data.source
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.source.CatalogueSource
val sourceMapper: (CatalogueSource) -> Source = { source ->
Source(
source.id,
source.lang,
source.name,
source.supportsLatest
)
}

View File

@ -0,0 +1,18 @@
package eu.kanade.data.source
import eu.kanade.domain.source.model.Source
import eu.kanade.domain.source.repository.SourceRepository
import eu.kanade.tachiyomi.source.SourceManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class SourceRepositoryImpl(
private val sourceManager: SourceManager
) : SourceRepository {
override fun getSources(): Flow<List<Source>> {
return sourceManager.catalogueSources.map { sources ->
sources.map(sourceMapper)
}
}
}

View File

@ -1,12 +1,21 @@
package eu.kanade.domain
import eu.kanade.data.history.HistoryRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl
import eu.kanade.domain.history.interactor.DeleteHistoryTable
import eu.kanade.domain.history.interactor.GetHistory
import eu.kanade.domain.history.interactor.GetNextChapterForManga
import eu.kanade.domain.history.interactor.RemoveHistoryById
import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
import eu.kanade.domain.history.repository.HistoryRepository
import eu.kanade.domain.source.interactor.DisableSource
import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.domain.source.interactor.GetShowLatest
import eu.kanade.domain.source.interactor.GetSourceCategories
import eu.kanade.domain.source.interactor.SetSourceCategories
import eu.kanade.domain.source.interactor.ToggleExcludeFromDataSaver
import eu.kanade.domain.source.interactor.ToggleSourcePin
import eu.kanade.domain.source.repository.SourceRepository
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
@ -22,5 +31,16 @@ class DomainModule : InjektModule {
addFactory { GetNextChapterForManga(get()) }
addFactory { RemoveHistoryById(get()) }
addFactory { RemoveHistoryByMangaId(get()) }
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get()) }
addFactory { GetEnabledSources(get(), get()) }
addFactory { DisableSource(get()) }
addFactory { ToggleSourcePin(get()) }
// SY -->
addFactory { GetSourceCategories(get()) }
addFactory { GetShowLatest(get()) }
addFactory { ToggleExcludeFromDataSaver(get()) }
addFactory { SetSourceCategories(get()) }
// SY <--
}
}

View File

@ -0,0 +1,14 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.preference.plusAssign
class DisableSource(
private val preferences: PreferencesHelper
) {
fun await(source: Source) {
preferences.disabledSources() += source.id.toString()
}
}

View File

@ -0,0 +1,94 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.model.Pin
import eu.kanade.domain.source.model.Pins
import eu.kanade.domain.source.model.Source
import eu.kanade.domain.source.repository.SourceRepository
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
class GetEnabledSources(
private val repository: SourceRepository,
private val preferences: PreferencesHelper
) {
fun subscribe(): Flow<List<Source>> {
return preferences.pinnedSources().asFlow()
.combine(preferences.enabledLanguages().asFlow()) { pinList, enabledLanguages ->
Config(pinSet = pinList, enabledSources = enabledLanguages)
}
.combine(preferences.disabledSources().asFlow()) { config, disabledSources ->
config.copy(disabledSources = disabledSources)
}
.combine(preferences.lastUsedSource().asFlow()) { config, lastUsedSource ->
config.copy(lastUsedSource = lastUsedSource)
}
// SY -->
.combine(preferences.dataSaverExcludedSources().asFlow()) { config, excludedFromDataSaver ->
config.copy(excludedFromDataSaver = excludedFromDataSaver)
}
.combine(preferences.sourcesTabSourcesInCategories().asFlow()) { config, sourcesInCategories ->
config.copy(sourcesInCategories = sourcesInCategories)
}
.combine(preferences.sourcesTabCategoriesFilter().asFlow()) { config, sourceCategoriesFilter ->
config.copy(sourceCategoriesFilter = sourceCategoriesFilter)
}
// SY <--
.combine(repository.getSources()) { (pinList, enabledLanguages, disabledSources, lastUsedSource, excludedFromDataSaver, sourcesInCategories, sourceCategoriesFilter), sources ->
val pinsOnTop = preferences.pinsOnTop().get()
val sourcesAndCategories = sourcesInCategories.map {
it.split('|').let { (source, test) -> source.toLong() to test }
}
val sourcesInSourceCategories = sourcesAndCategories.map { it.first }
sources
.filter { it.lang in enabledLanguages || it.id == LocalSource.ID }
.filterNot { it.id.toString() in disabledSources }
.flatMap {
val flag = if ("${it.id}" in pinList) Pins.pinned else Pins.unpinned
// SY -->
val categories = sourcesAndCategories.filter { (id) -> id == it.id }
.map(Pair<*, String>::second)
.toSet()
// SY <--
val source = it.copy(
pin = flag,
isExcludedFromDataSaver = it.id.toString() in excludedFromDataSaver,
categories = categories
)
val toFlatten = mutableListOf(source)
if (source.id == lastUsedSource) {
toFlatten.add(source.copy(isUsedLast = true, pin = source.pin - Pin.Actual))
}
if (pinsOnTop.not() && Pin.Pinned in source.pin) {
toFlatten[0] = toFlatten[0].copy(pin = source.pin + Pin.Forced)
toFlatten.add(source.copy(pin = source.pin - Pin.Actual))
}
// SY -->
categories.forEach { category ->
toFlatten.add(source.copy(category = category, pin = source.pin - Pin.Actual))
}
if (sourceCategoriesFilter && Pin.Actual !in toFlatten[0].pin && source.id in sourcesInSourceCategories) {
toFlatten.removeAt(0)
}
// SY <--
toFlatten
}
}
.distinctUntilChanged()
}
}
private data class Config(
val pinSet: Set<String> = setOf(),
val enabledSources: Set<String> = setOf(),
val disabledSources: Set<String> = setOf(),
val lastUsedSource: Long? = null,
// SY -->
val excludedFromDataSaver: Set<String> = setOf(),
val sourcesInCategories: Set<String> = setOf(),
val sourceCategoriesFilter: Boolean = false,
// SY <--
)

View File

@ -0,0 +1,18 @@
package eu.kanade.domain.source.interactor
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.browse.source.SourceController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetShowLatest(
private val preferences: PreferencesHelper
) {
fun subscribe(mode: SourceController.Mode): Flow<Boolean> {
return preferences.useNewSourceNavigation().asFlow()
.map {
mode == SourceController.Mode.CATALOGUE && !it
}
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.domain.source.interactor
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import kotlinx.coroutines.flow.Flow
class GetSourceCategories(
private val preferences: PreferencesHelper
) {
fun subscribe(): Flow<Set<String>> {
return preferences.sourcesTabCategories().asFlow()
}
}

View File

@ -0,0 +1,22 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.preference.minusAssign
import eu.kanade.tachiyomi.util.preference.plusAssign
class SetSourceCategories(
private val preferences: PreferencesHelper
) {
fun await(source: Source, sourceCategories: List<String>) {
val sourceIdString = source.id.toString()
val currentSourceCategories = preferences.sourcesTabSourcesInCategories().get().filterNot {
it.substringBefore('|') == sourceIdString
}
val newSourceCategories = currentSourceCategories + sourceCategories.map {
"$sourceIdString|$it"
}
preferences.sourcesTabSourcesInCategories().set(newSourceCategories.toSet())
}
}

View File

@ -0,0 +1,20 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.preference.minusAssign
import eu.kanade.tachiyomi.util.preference.plusAssign
class ToggleExcludeFromDataSaver(
private val preferences: PreferencesHelper
) {
fun await(source: Source) {
val isExcluded = source.id.toString() in preferences.dataSaverExcludedSources().get()
if (isExcluded) {
preferences.dataSaverExcludedSources() -= source.id.toString()
} else {
preferences.dataSaverExcludedSources() += source.id.toString()
}
}
}

View File

@ -0,0 +1,20 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.preference.minusAssign
import eu.kanade.tachiyomi.util.preference.plusAssign
class ToggleSourcePin(
private val preferences: PreferencesHelper
) {
fun await(source: Source) {
val isPinned = source.id.toString() in preferences.pinnedSources().get()
if (isPinned) {
preferences.pinnedSources() -= source.id.toString()
} else {
preferences.pinnedSources() += source.id.toString()
}
}
}

View File

@ -0,0 +1,84 @@
package eu.kanade.domain.source.model
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
import eu.kanade.tachiyomi.extension.ExtensionManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
data class Source(
val id: Long,
val lang: String,
val name: String,
val supportsLatest: Boolean,
val pin: Pins = Pins.unpinned,
val isUsedLast: Boolean = false,
// SY -->
val category: String? = null,
val isExcludedFromDataSaver: Boolean = false,
val categories: Set<String> = emptySet(),
// SY <--
) {
val nameWithLanguage: String
get() = "$name (${lang.uppercase()})"
val icon: ImageBitmap?
get() {
return Injekt.get<ExtensionManager>().getAppIconForSource(id)
?.toBitmap()
?.asImageBitmap()
}
val key: () -> Long = {
when {
isUsedLast -> id shr 16
Pin.Forced in pin -> id shr 32
category != null -> id shr 48 + category.hashCode()
else -> id
}
}
}
sealed class Pin(val code: Int) {
object Unpinned : Pin(0b00)
object Pinned : Pin(0b01)
object Actual : Pin(0b10)
object Forced : Pin(0b100)
}
inline fun Pins(builder: Pins.PinsBuilder.() -> Unit = {}): Pins {
return Pins.PinsBuilder().apply(builder).flags()
}
fun Pins(vararg pins: Pin) = Pins {
pins.forEach { +it }
}
data class Pins(val code: Int = Pin.Unpinned.code) {
operator fun contains(pin: Pin): Boolean = pin.code and code == pin.code
operator fun plus(pin: Pin): Pins = Pins(code or pin.code)
operator fun minus(pin: Pin): Pins = Pins(code xor pin.code)
companion object {
val unpinned = Pins(Pin.Unpinned)
val pinned = Pins(Pin.Pinned, Pin.Actual)
}
class PinsBuilder(var code: Int = 0) {
operator fun Pin.unaryPlus() {
this@PinsBuilder.code = code or this@PinsBuilder.code
}
operator fun Pin.unaryMinus() {
this@PinsBuilder.code = code or this@PinsBuilder.code
}
fun flags(): Pins = Pins(code)
}
}

View File

@ -0,0 +1,9 @@
package eu.kanade.domain.source.repository
import eu.kanade.domain.source.model.Source
import kotlinx.coroutines.flow.Flow
interface SourceRepository {
fun getSources(): Flow<List<Source>>
}

View File

@ -0,0 +1,402 @@
package eu.kanade.presentation.source
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.domain.source.model.Pin
import eu.kanade.domain.source.model.Source
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.PreferenceRow
import eu.kanade.presentation.theme.header
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.browse.source.SourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.UiModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable
fun SourceScreen(
nestedScrollInterop: NestedScrollConnection,
presenter: SourcePresenter,
onClickItem: (Source) -> Unit,
onClickDisable: (Source) -> Unit,
onClickLatest: (Source) -> Unit,
onClickPin: (Source) -> Unit,
onClickSetCategories: (Source, List<String>) -> Unit,
onClickToggleDataSaver: (Source) -> Unit
) {
val state by presenter.state.collectAsState()
when {
state.isLoading -> CircularProgressIndicator()
state.hasError -> Text(text = state.error!!.message!!)
state.isEmpty -> EmptyScreen(message = "")
else -> SourceList(
nestedScrollConnection = nestedScrollInterop,
list = state.sources,
categories = state.sourceCategories,
showPin = state.showPin,
showLatest = state.showLatest,
onClickItem = onClickItem,
onClickDisable = onClickDisable,
onClickLatest = onClickLatest,
onClickPin = onClickPin,
onClickSetCategories = onClickSetCategories,
onClickToggleDataSaver = onClickToggleDataSaver
)
}
}
@Composable
fun SourceList(
nestedScrollConnection: NestedScrollConnection,
list: List<UiModel>,
categories: List<String>,
showPin: Boolean,
showLatest: Boolean,
onClickItem: (Source) -> Unit,
onClickDisable: (Source) -> Unit,
onClickLatest: (Source) -> Unit,
onClickPin: (Source) -> Unit,
onClickSetCategories: (Source, List<String>) -> Unit,
onClickToggleDataSaver: (Source) -> Unit
) {
val (sourceState, setSourceState) = remember { mutableStateOf<Source?>(null) }
// SY -->
val (sourceCategoriesState, setSourceCategoriesState) = remember { mutableStateOf<Source?>(null) }
// SY <--
LazyColumn(
modifier = Modifier
.nestedScroll(nestedScrollConnection),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
items(
items = list,
contentType = {
when (it) {
is UiModel.Header -> "header"
is UiModel.Item -> "item"
}
},
key = {
when (it) {
is UiModel.Header -> it.hashCode()
is UiModel.Item -> it.source.key()
}
}
) { model ->
when (model) {
is UiModel.Header -> {
SourceHeader(
modifier = Modifier.animateItemPlacement(),
language = model.language,
isCategory = model.isCategory
)
}
is UiModel.Item -> SourceItem(
modifier = Modifier.animateItemPlacement(),
item = model.source,
showLatest = showLatest,
showPin = showPin,
onClickItem = onClickItem,
onLongClickItem = {
setSourceState(it)
},
onClickLatest = onClickLatest,
onClickPin = onClickPin,
)
}
}
}
if (sourceState != null) {
SourceOptionsDialog(
source = sourceState,
onClickPin = {
onClickPin(sourceState)
setSourceState(null)
},
onClickDisable = {
onClickDisable(sourceState)
setSourceState(null)
},
onClickSetCategories = {
setSourceCategoriesState(sourceState)
setSourceState(null)
},
onClickToggleDataSaver = {
onClickToggleDataSaver(sourceState)
setSourceState(null)
},
onDismiss = { setSourceState(null) }
)
}
if (sourceCategoriesState != null) {
SourceCategoriesDialog(
source = sourceCategoriesState,
categories = categories,
oldCategories = sourceCategoriesState.categories,
onClickCategories = {
onClickSetCategories(sourceCategoriesState, it)
setSourceCategoriesState(null)
},
onDismiss = { setSourceCategoriesState(null) },
)
}
}
@Composable
fun SourceHeader(
modifier: Modifier = Modifier,
language: String,
isCategory: Boolean,
) {
val context = LocalContext.current
Text(
text = if (!isCategory) {
LocaleHelper.getSourceDisplayName(language, context)
} else language,
modifier = modifier
.padding(horizontal = horizontalPadding, vertical = 8.dp),
style = MaterialTheme.typography.header,
)
}
@Composable
fun SourceItem(
modifier: Modifier = Modifier,
item: Source,
// SY -->
showLatest: Boolean,
showPin: Boolean,
// SY <--
onClickItem: (Source) -> Unit,
onLongClickItem: (Source) -> Unit,
onClickLatest: (Source) -> Unit,
onClickPin: (Source) -> Unit
) {
Row(
modifier = modifier
.combinedClickable(
onClick = { onClickItem(item) },
onLongClick = { onLongClickItem(item) }
)
.padding(horizontal = horizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SourceIcon(source = item)
Column(
modifier = Modifier
.padding(horizontal = horizontalPadding)
.weight(1f)
) {
Text(
text = item.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = LocaleHelper.getDisplayName(item.lang),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall
)
}
if (item.supportsLatest /* SY --> */ && showLatest /* SY <-- */) {
TextButton(onClick = { onClickLatest(item) }) {
Text(text = stringResource(id = R.string.latest))
}
}
// SY -->
if (showPin) {
SourcePinButton(
isPinned = Pin.Pinned in item.pin,
onClick = { onClickPin(item) },
)
}
// SY <--
}
}
@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,
onClick: () -> Unit
) {
val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin
val tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground
IconButton(onClick = onClick) {
Icon(
imageVector = icon,
contentDescription = "",
tint = tint
)
}
}
@Composable
fun SourceOptionsDialog(
source: Source,
onClickPin: () -> Unit,
onClickDisable: () -> Unit,
onClickSetCategories: () -> Unit,
onClickToggleDataSaver: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
title = {
Text(text = source.nameWithLanguage)
},
text = {
Column {
val textId = if (Pin.Pinned in source.pin) R.string.action_unpin else R.string.action_pin
Text(
text = stringResource(id = textId),
modifier = Modifier
.clickable(onClick = onClickPin)
.fillMaxWidth()
.padding(vertical = 16.dp)
)
if (source.id != LocalSource.ID) {
Text(
text = stringResource(id = R.string.action_disable),
modifier = Modifier
.clickable(onClick = onClickDisable)
.fillMaxWidth()
.padding(vertical = 16.dp)
)
}
// SY -->
Text(
text = stringResource(id = R.string.categories),
modifier = Modifier
.clickable(onClick = onClickSetCategories)
.fillMaxWidth()
.padding(vertical = 16.dp)
)
Text(
text = if (source.isExcludedFromDataSaver) {
stringResource(id = R.string.data_saver_stop_exclude)
} else {
stringResource(id = R.string.data_saver_exclude)
},
modifier = Modifier
.clickable(onClick = onClickToggleDataSaver)
.fillMaxWidth()
.padding(vertical = 16.dp),
)
// SY <--
}
},
onDismissRequest = onDismiss,
confirmButton = {},
)
}
// SY -->
@Composable
fun SourceCategoriesDialog(
source: Source,
categories: List<String>,
oldCategories: Set<String>,
onClickCategories: (List<String>) -> Unit,
onDismiss: () -> Unit,
) {
val newCategories = remember {
mutableStateListOf<String>().also { it.addAll(oldCategories) }
}
AlertDialog(
title = {
Text(text = source.nameWithLanguage)
},
text = {
Column {
categories.forEach {
PreferenceRow(
title = it,
onClick = {
if (it in newCategories) {
newCategories -= it
} else {
newCategories += it
}
},
action = {
Checkbox(checked = it in newCategories, onCheckedChange = null)
},
)
}
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onClickCategories(newCategories.toList()) }) {
Text(text = stringResource(android.R.string.ok))
}
},
)
}
// SY <--

View File

@ -9,7 +9,8 @@ import com.google.android.material.composethemeadapter3.createMdc3Theme
fun TachiyomiTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val (colorScheme, typography) = createMdc3Theme(
context = context
context = context,
setTextColors = true
)
MaterialTheme(

View File

@ -0,0 +1,16 @@
package eu.kanade.presentation.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
val Typography.header: TextStyle
@Composable
get() {
return bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
}

View File

@ -335,6 +335,8 @@ class PreferencesHelper(val context: Context) {
fun autoClearChapterCache() = prefs.getBoolean(Keys.autoClearChapterCache, false)
fun pinsOnTop() = flowPrefs.getBoolean("pins_on_top", true)
fun setChapterSettingsDefault(manga: Manga) {
prefs.edit {
putInt(Keys.defaultChapterFilterByRead, manga.readFilter)

View File

@ -72,13 +72,17 @@ class ExtensionManager(
}
fun getAppIconForSource(source: Source): Drawable? {
val pkgName = installedExtensions.find { ext -> ext.sources.any { it.id == source.id } }?.pkgName
return getAppIconForSource(source.id)
}
fun getAppIconForSource(sourceId: Long): Drawable? {
val pkgName = installedExtensions.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
if (pkgName != null) {
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) }
}
// SY -->
return when (source.id) {
return when (sourceId) {
EH_SOURCE_ID -> ContextCompat.getDrawable(context, R.mipmap.ic_ehentai_source)
EXH_SOURCE_ID -> ContextCompat.getDrawable(context, R.mipmap.ic_ehentai_source)
MERGED_SOURCE_ID -> ContextCompat.getDrawable(context, R.mipmap.ic_merged_source)

View File

@ -33,9 +33,12 @@ import exh.source.handleSourceLibrary
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import rx.Observable
import uy.kohesive.injekt.injectLazy
import kotlin.reflect.KClass
@ -45,6 +48,9 @@ open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>()
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
private val _catalogueSources: MutableStateFlow<List<CatalogueSource>> = MutableStateFlow(listOf())
val catalogueSources: Flow<List<CatalogueSource>> = _catalogueSources
// SY -->
private val prefs: PreferencesHelper by injectLazy()
@ -137,15 +143,23 @@ open class SourceManager(private val context: Context) {
if (!sourcesMap.containsKey(source.id)) {
sourcesMap[source.id] = newSource
}
triggerCatalogueSources()
}
internal fun unregisterSource(source: Source) {
sourcesMap.remove(source.id)
triggerCatalogueSources()
// SY -->
currentDelegatedSources.remove(source.id)
// SY <--
}
private fun triggerCatalogueSources() {
_catalogueSources.update {
sourcesMap.values.filterIsInstance<CatalogueSource>()
}
}
private fun createInternalSources(): List<Source> = listOf(
LocalSource(context),
)

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.base.controller
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.compose.runtime.Composable
@ -7,6 +8,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import nucleus.presenter.Presenter
/**
@ -34,7 +36,26 @@ abstract class ComposeController<P : Presenter<*>> : NucleusController<ComposeCo
/**
* Basic Compose controller without a presenter.
*/
abstract class BasicComposeController : BaseController<ComposeControllerBinding>() {
abstract class BasicComposeController(bundle: Bundle? = null) : BaseController<ComposeControllerBinding>(bundle) {
override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
ComposeControllerBinding.inflate(inflater)
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.root.setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection(binding.root)
TachiyomiTheme {
ComposeContent(nestedScrollInterop)
}
}
}
@Composable abstract fun ComposeContent(nestedScrollInterop: NestedScrollConnection)
}
abstract class SearchableComposeController<P : BasePresenter<*>>(bundle: Bundle? = null) : SearchableNucleusController<ComposeControllerBinding, P>(bundle) {
override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
ComposeControllerBinding.inflate(inflater)

View File

@ -1,17 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.databinding.SectionHeaderItemBinding
import eu.kanade.tachiyomi.util.system.LocaleHelper
class LangHolder(view: View, adapter: FlexibleAdapter<*>) :
FlexibleViewHolder(view, adapter) {
private val binding = SectionHeaderItemBinding.bind(view)
fun bind(item: LangItem) {
binding.title.text = LocaleHelper.getSourceDisplayName(item.code, itemView.context)
}
}

View File

@ -1,42 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
/**
* Item that contains the language header.
*
* @param code The lang code.
*/
data class LangItem(val code: String) : AbstractHeaderItem<LangHolder>() {
/**
* Returns the layout resource of this item.
*/
override fun getLayoutRes(): Int {
return R.layout.section_header_item
}
/**
* Creates a new view holder for this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): LangHolder {
return LangHolder(view, adapter)
}
/**
* Binds this item to the given view holder.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: LangHolder,
position: Int,
payloads: MutableList<Any>,
) {
holder.bind(this)
}
}

View File

@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
/**
* Adapter that holds the catalogue cards.
*
* @param controller instance of [SourceController].
*/
class SourceAdapter(controller: SourceController) :
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
init {
setDisplayHeadersAtStartUp(true)
}
/**
* Listener for browse item clicks.
*/
val clickListener: OnSourceClickListener = controller
/**
* Listener which should be called when user clicks browse.
* Note: Should only be handled by [SourceController]
*/
interface OnSourceClickListener {
fun onBrowseClick(position: Int)
fun onLatestClick(position: Int)
fun onPinClick(position: Int)
}
}

View File

@ -1,65 +1,39 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.app.Dialog
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.LinearLayoutManager
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import com.bluelinelabs.conductor.Controller
import eu.kanade.domain.source.model.Source
import eu.kanade.presentation.source.SourceScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.SourceMainControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.SearchableComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.category.sources.ChangeSourceCategoriesDialog
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.preference.minusAssign
import eu.kanade.tachiyomi.util.preference.plusAssign
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.onAnimationsFinished
import exh.ui.smartsearch.SmartSearchController
import kotlinx.parcelize.Parcelize
import uy.kohesive.injekt.injectLazy
/**
* This controller shows and manages the different catalogues enabled by the user.
* This controller should only handle UI actions, IO actions should be done by [SourcePresenter]
* [SourceAdapter.OnSourceClickListener] call function data on browse item click.
* [SourceAdapter.OnLatestClickListener] call function data on latest item click
*/
class SourceController(bundle: Bundle? = null) :
SearchableNucleusController<SourceMainControllerBinding, SourcePresenter>(bundle),
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
SourceAdapter.OnSourceClickListener,
/*SY -->*/
ChangeSourceCategoriesDialog.Listener /*SY <--*/ {
class SourceController(bundle: Bundle? = null) : SearchableComposeController<SourcePresenter>(bundle) {
private val preferences: PreferencesHelper by injectLazy()
private var adapter: SourceAdapter? = null
// EXH -->
private val smartSearchConfig: SmartSearchConfig? = args.getParcelable(SMART_SEARCH_CONFIG)
@ -81,241 +55,57 @@ class SourceController(bundle: Bundle? = null) :
// SY <--
}
override fun createPresenter(): SourcePresenter {
return SourcePresenter(/* SY --> */ controllerMode = mode /* SY <-- */)
}
override fun createPresenter(): SourcePresenter =
SourcePresenter(/* SY --> */ controllerMode = mode /* SY <-- */)
override fun createBinding(inflater: LayoutInflater) = SourceMainControllerBinding.inflate(inflater)
@Composable
override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
SourceScreen(
nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
onClickItem = { source ->
if (preferences.useNewSourceNavigation().get()) {
openSource(source, SourceFeedController(source.id))
} else {
openSource(source, BrowseSourceController(source))
}
},
onClickDisable = { source ->
presenter.disableSource(source)
},
onClickLatest = { source ->
openSource(source, LatestUpdatesController(source))
},
onClickPin = { source ->
presenter.togglePin(source)
},
onClickSetCategories = { source, categories ->
presenter.setSourceCategories(source, categories)
},
onClickToggleDataSaver = { source ->
presenter.toggleExcludeFromDataSaver(source)
},
)
LaunchedEffect(Unit) {
(activity as? MainActivity)?.ready = true
}
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = SourceAdapter(this)
// Create recycler and set adapter.
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter
binding.recycler.onAnimationsFinished {
(activity as? MainActivity)?.ready = true
}
adapter?.fastScroller = binding.fastScroller
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
// SY -->
if (mode == Mode.CATALOGUE) {
// Update list on extension changes (e.g. new installation)
(parentController as BrowseController).extensionListUpdateRelay
.skip(1) // Skip first update when ExtensionController created
.subscribeUntilDestroy {
presenter.updateSources()
}
}
// SY <--
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isPush) {
presenter.updateSources()
}
}
override fun onItemClick(view: View, position: Int): Boolean {
onItemClick(position)
return false
}
private fun onItemClick(position: Int) {
val item = adapter?.getItem(position) as? SourceItem ?: return
val source = item.source
// SY -->
when (mode) {
Mode.CATALOGUE -> {
// Open the catalogue view.
// SY -->
if (preferences.useNewSourceNavigation().get()) {
openSourceFeed(source)
} else openSource(source, BrowseSourceController(source))
// SY <--
}
Mode.SMART_SEARCH -> router.pushController(
SmartSearchController(
bundleOf(
SmartSearchController.ARG_SOURCE_ID to source.id,
SmartSearchController.ARG_SMART_SEARCH_CONFIG to smartSearchConfig,
),
),
)
}
// SY <--
}
override fun onItemLongClick(position: Int) {
val activity = activity ?: return
val item = adapter?.getItem(position) as? SourceItem ?: return
val isPinned = item.header?.code?.equals(SourcePresenter.PINNED_KEY) ?: false
val items = mutableListOf(
activity.getString(if (isPinned) R.string.action_unpin else R.string.action_pin) to { toggleSourcePin(item.source) },
)
if (item.source !is LocalSource) {
items.add(activity.getString(R.string.action_disable) to { disableSource(item.source) })
}
// SY -->
items.add(
activity.getString(R.string.categories) to { addToCategories(item.source) },
)
if (preferences.dataSaver().get()) {
val isExcluded = item.source.id.toString() in preferences.dataSaverExcludedSources().get()
items.add(
activity.getString(
if (isExcluded) R.string.data_saver_stop_exclude else R.string.data_saver_exclude,
) to {
excludeFromDataSaver(item.source, isExcluded)
},
)
}
// SY <--
SourceOptionsDialog(item.source.toString(), items).showDialog(router)
}
private fun disableSource(source: Source) {
preferences.disabledSources() += source.id.toString()
presenter.updateSources()
}
private fun toggleSourcePin(source: Source) {
val isPinned = source.id.toString() in preferences.pinnedSources().get()
if (isPinned) {
preferences.pinnedSources() -= source.id.toString()
} else {
preferences.pinnedSources() += source.id.toString()
}
presenter.updateSources()
}
// SY -->
private fun addToCategories(source: Source) {
val categories = preferences.sourcesTabCategories().get()
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it }))
.toTypedArray()
if (categories.isEmpty()) {
applicationContext?.toast(R.string.no_source_categories)
return
}
val preferenceSources = preferences.sourcesTabSourcesInCategories().get().toMutableList()
val sources = preferenceSources.map { it.split("|")[0] }
if (source.id.toString() in sources) {
val sourceCategories = preferenceSources
.map { item -> item.split("|").let { it.component1() to it.component2() } }
.filter { it.first == source.id.toString() }
.map { it.second }
val selection = categories.map { it in sourceCategories }
.toBooleanArray()
ChangeSourceCategoriesDialog(this, source, categories, selection)
.showDialog(router)
} else {
ChangeSourceCategoriesDialog(this, source, categories, categories.map { false }.toBooleanArray())
.showDialog(router)
}
}
override fun updateCategoriesForSource(source: Source, categories: List<String>) {
var preferenceSources = preferences.sourcesTabSourcesInCategories().get().toMutableList()
val sources = preferenceSources.map { it.split("|")[0] }
if (source.id.toString() in sources) {
preferenceSources = preferenceSources
.map { it.split("|") }
.filter { it[0] != source.id.toString() }
.map { it[0] + "|" + it[1] }.toMutableList()
}
categories.forEach {
preferenceSources.add(source.id.toString() + "|" + it)
}
preferences.sourcesTabSourcesInCategories().set(
preferenceSources.sorted().toSet(),
)
presenter.updateSources()
}
private fun excludeFromDataSaver(source: Source, isExcluded: Boolean) {
if (isExcluded) {
preferences.dataSaverExcludedSources() -= source.id.toString()
} else {
preferences.dataSaverExcludedSources() += source.id.toString()
}
}
// SY <--
/**
* Called when browse is clicked in [SourceAdapter]
*/
override fun onBrowseClick(position: Int) {
onItemClick(position)
}
/**
* Called when latest is clicked in [SourceAdapter]
*/
override fun onLatestClick(position: Int) {
val item = adapter?.getItem(position) as? SourceItem ?: return
openSource(item.source, LatestUpdatesController(item.source))
}
/**
* Called when pin icon is clicked in [SourceAdapter]
*/
override fun onPinClick(position: Int) {
val item = adapter?.getItem(position) as? SourceItem ?: return
toggleSourcePin(item.source)
}
/**
* Opens a catalogue with the given controller.
*/
private fun openSource(source: CatalogueSource, controller: BrowseSourceController) {
private fun openSource(source: Source, controller: Controller) {
if (!preferences.incognitoMode().get()) {
preferences.lastUsedSource().set(source.id)
}
parentController!!.router.pushController(controller)
}
// SY -->
/**
* Opens a catalogue with the source feed controller.
*/
private fun openSourceFeed(source: CatalogueSource) {
preferences.lastUsedSource().set(source.id)
parentController!!.router.pushController(SourceFeedController(source))
}
// SY <--
/**
* Called when an option menu item has been selected by the user.
*
@ -323,51 +113,13 @@ class SourceController(bundle: Bundle? = null) :
* @return True if this event has been consumed, false if it has not.
*/
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
return when (item.itemId) {
// Initialize option to open catalogue settings.
R.id.action_settings -> {
parentController!!.router.pushController(SourceFilterController())
true
}
}
return super.onOptionsItemSelected(item)
}
/**
* Called to update adapter containing sources.
*/
fun setSources(sources: List<IFlexible<*>>) {
adapter?.updateDataSet(sources)
}
/**
* Called to set the last used catalogue at the top of the view.
*/
fun setLastUsedSource(item: SourceItem?) {
adapter?.removeAllScrollableHeaders()
if (item != null) {
adapter?.addScrollableHeader(item)
adapter?.addScrollableHeader(LangItem(SourcePresenter.LAST_USED_KEY))
}
}
class SourceOptionsDialog(bundle: Bundle? = null) : DialogController(bundle) {
private lateinit var source: String
private lateinit var items: List<Pair<String, () -> Unit>>
constructor(source: String, items: List<Pair<String, () -> Unit>>) : this() {
this.source = source
this.items = items
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(source)
.setItems(items.map { it.first }.toTypedArray()) { dialog, which ->
items[which].second()
dialog.dismiss()
}
.create()
else -> super.onOptionsItemSelected(item)
}
}

View File

@ -1,58 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.view.View
import androidx.core.view.isVisible
import coil.load
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.SourceMainControllerItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.icon
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.view.setVectorCompat
class SourceHolder(view: View, val adapter: SourceAdapter /* SY --> */, private val showLatest: Boolean, private val showPins: Boolean /* SY <-- */) :
FlexibleViewHolder(view, adapter) {
private val binding = SourceMainControllerItemBinding.bind(view)
init {
binding.sourceLatest.setOnClickListener {
adapter.clickListener.onLatestClick(bindingAdapterPosition)
}
binding.pin.setOnClickListener {
adapter.clickListener.onPinClick(bindingAdapterPosition)
}
// SY -->
if (!showLatest) {
binding.sourceLatest.isVisible = false
}
// SY <--
}
fun bind(item: SourceItem) {
val source = item.source
binding.title.text = source.name
binding.subtitle.isVisible = source !is LocalSource
binding.subtitle.text = LocaleHelper.getDisplayName(source.lang)
// Set source icon
val icon = source.icon()
when {
icon != null -> binding.image.load(icon)
item.source.id == LocalSource.ID -> binding.image.load(R.mipmap.ic_local_source)
}
binding.sourceLatest.isVisible = source.supportsLatest/* SY --> */ && showLatest // SY <--
binding.pin.isVisible = showPins
if (item.isPinned) {
binding.pin.setVectorCompat(R.drawable.ic_push_pin_24dp, R.attr.colorAccent)
} else {
binding.pin.setVectorCompat(R.drawable.ic_push_pin_outline_24dp, android.R.attr.textColorHint)
}
}
}

View File

@ -1,60 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
/**
* Item that contains source information.
*
* @param source Instance of [CatalogueSource] containing source information.
* @param header The header for this item.
*/
data class SourceItem(
val source: CatalogueSource,
val header: LangItem? = null,
val isPinned: Boolean = false,
// SY -->
val showLatest: Boolean,
val showPins: Boolean,
// SY <--
) :
AbstractSectionableItem<SourceHolder, LangItem>(header) {
override fun getLayoutRes(): Int {
return R.layout.source_main_controller_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceHolder {
return SourceHolder(view, adapter as SourceAdapter /* SY --> */, showLatest, showPins /* SY <-- */)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: SourceHolder,
position: Int,
payloads: MutableList<Any>,
) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (other is SourceItem) {
return source.id == other.source.id &&
getHeader()?.code == other.getHeader()?.code &&
isPinned == other.isPinned
}
return false
}
override fun hashCode(): Int {
var result = source.id.hashCode()
result = 31 * result + (header?.hashCode() ?: 0)
result = 31 * result + isPinned.hashCode()
return result
}
}

View File

@ -1,16 +1,23 @@
package eu.kanade.tachiyomi.ui.browse.source
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import android.os.Bundle
import eu.kanade.domain.source.interactor.DisableSource
import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.domain.source.interactor.GetShowLatest
import eu.kanade.domain.source.interactor.GetSourceCategories
import eu.kanade.domain.source.interactor.SetSourceCategories
import eu.kanade.domain.source.interactor.ToggleExcludeFromDataSaver
import eu.kanade.domain.source.interactor.ToggleSourcePin
import eu.kanade.domain.source.model.Pin
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.TreeMap
@ -20,146 +27,130 @@ import java.util.TreeMap
* Function calls should be done from here. UI calls should be done from the controller.
*/
class SourcePresenter(
val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(),
private val getEnabledSources: GetEnabledSources = Injekt.get(),
private val disableSource: DisableSource = Injekt.get(),
private val toggleSourcePin: ToggleSourcePin = Injekt.get(),
// SY -->
private val getSourceCategories: GetSourceCategories = Injekt.get(),
private val getShowLatest: GetShowLatest = Injekt.get(),
private val toggleExcludeFromDataSaver: ToggleExcludeFromDataSaver = Injekt.get(),
private val setSourceCategories: SetSourceCategories = Injekt.get(),
private val controllerMode: SourceController.Mode,
// SY <--
) : BasePresenter<SourceController>() {
var sources = getEnabledSources()
/**
* Unsubscribe and create a new subscription to fetch enabled sources.
*/
private fun loadSources() {
val pinnedSources = mutableListOf<SourceItem>()
val pinnedSourceIds = preferences.pinnedSources().get()
private val _state: MutableStateFlow<SourceState> = MutableStateFlow(SourceState.EMPTY)
val state: StateFlow<SourceState> = _state.asStateFlow()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
presenterScope.launchIO {
getEnabledSources.subscribe()
.catch { exception ->
_state.update { state ->
state.copy(sources = listOf(), error = exception)
}
}
.collectLatest(::collectLatestSources)
}
// SY -->
val categories = preferences.sourcesTabCategories().get()
.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER, { it }))
.map {
SourceCategory(it)
presenterScope.launchIO {
getSourceCategories.subscribe()
.catch { exception ->
_state.update { state ->
state.copy(sources = listOf(), error = exception)
}
}
.collectLatest(::updateCategories)
}
presenterScope.launchIO {
_state.update { state ->
state.copy(
showPin = controllerMode == SourceController.Mode.CATALOGUE,
)
}
}
presenterScope.launchIO {
getShowLatest.subscribe(mode = controllerMode)
.catch { exception ->
_state.update { state ->
state.copy(sources = listOf(), error = exception)
}
}
.collectLatest(::updateShowLatest)
}
// SY <--
}
val sourcesAndCategoriesCombined = preferences.sourcesTabSourcesInCategories().get()
val sourcesAndCategories = if (sourcesAndCategoriesCombined.isNotEmpty()) sourcesAndCategoriesCombined.map {
val temp = it.split("|")
temp[0] to temp[1]
} else null
val sourcesInCategories = sourcesAndCategories?.map { it.first }
// SY <--
val map = TreeMap<String, MutableList<CatalogueSource>> { d1, d2 ->
private suspend fun collectLatestSources(sources: List<Source>) {
val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
// Catalogues without a lang defined will be placed at the end
when {
d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1
d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1
d1 == PINNED_KEY && d2 != PINNED_KEY -> -1
d2 == PINNED_KEY && d1 != PINNED_KEY -> 1
d1 == "" && d2 != "" -> 1
d2 == "" && d1 != "" -> -1
else -> d1.compareTo(d2)
}
}
val byLang = sources.groupByTo(map) { it.lang }
var sourceItems = byLang.flatMap {
val langItem = LangItem(it.key)
it.value.map { source ->
val byLang = sources.groupByTo(map) {
when {
// SY -->
val showPins = controllerMode == SourceController.Mode.CATALOGUE
val showLatest = showPins && !preferences.useNewSourceNavigation().get()
it.category != null -> it.category
// SY <--
val isPinned = source.id.toString() in pinnedSourceIds
if (isPinned) {
pinnedSources.add(SourceItem(source, LangItem(PINNED_KEY), isPinned /* SY --> */, showLatest, showPins /* SY <-- */))
it.isUsedLast -> LAST_USED_KEY
Pin.Actual in it.pin -> PINNED_KEY
else -> it.lang
}
// SY -->
if (sourcesInCategories != null && source.id.toString() in sourcesInCategories) {
sourcesAndCategories
.filter { SourcesAndCategory -> SourcesAndCategory.first == source.id.toString() }
.forEach { SourceAndCategory ->
categories.forEach { dataClass ->
if (dataClass.category.trim() == SourceAndCategory.second.trim()) {
dataClass.sources.add(
SourceItem(
source,
LangItem("custom|" + SourceAndCategory.second),
isPinned,
showLatest,
showPins,
),
}
_state.update { state ->
state.copy(
sources = byLang.flatMap {
listOf(
UiModel.Header(it.key, it.value.firstOrNull()?.category != null),
*it.value.map { source ->
UiModel.Item(source)
}.toTypedArray()
)
},
error = null
)
}
}
}
}
// SY <--
SourceItem(source, langItem, isPinned /* SY --> */, showLatest, showPins /* SY <-- */)
}
}
if (preferences.sourcesTabCategoriesFilter().get()) {
sourcesInCategories?.let { sourcesIds -> sourceItems = sourceItems.filterNot { it.source.id.toString() in sourcesIds } }
}
// SY -->
categories.forEach {
sourceItems = it.sources.sortedBy { sourceItem -> sourceItem.source.name.lowercase() } + sourceItems
private suspend fun updateCategories(categories: Set<String>) {
_state.update { state ->
state.copy(
sourceCategories = categories.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it })
)
}
}
private suspend fun updateShowLatest(showLatest: Boolean) {
_state.update { state ->
state.copy(
showLatest = showLatest
)
}
}
// SY <--
if (pinnedSources.isNotEmpty()) {
sourceItems = pinnedSources + sourceItems
fun disableSource(source: Source) {
disableSource.await(source)
}
view?.setSources(sourceItems)
fun togglePin(source: Source) {
toggleSourcePin.await(source)
}
private fun loadLastUsedSource() {
// Immediate initial load
preferences.lastUsedSource().get().let { updateLastUsedSource(it) }
// Subsequent updates
preferences.lastUsedSource().asFlow()
.drop(1)
.onStart { delay(500) }
.distinctUntilChanged()
.onEach { updateLastUsedSource(it) }
.launchIn(presenterScope)
fun toggleExcludeFromDataSaver(source: Source) {
toggleExcludeFromDataSaver.await(source)
}
private fun updateLastUsedSource(sourceId: Long) {
val source = (sourceManager.get(sourceId) as? CatalogueSource)?.let {
val isPinned = it.id.toString() in preferences.pinnedSources().get()
// SY -->
val showPins = controllerMode == SourceController.Mode.CATALOGUE
val showLatest = showPins && !preferences.useNewSourceNavigation().get()
// SY <--
SourceItem(it, null, isPinned /* SY --> */, showLatest, showPins /* SY <-- */)
}
source?.let { view?.setLastUsedSource(it) }
}
fun updateSources() {
sources = getEnabledSources()
loadSources()
loadLastUsedSource()
}
/**
* Returns a list of enabled sources ordered by language and name.
*
* @return list containing enabled sources.
*/
private fun getEnabledSources(): List<CatalogueSource> {
val languages = preferences.enabledLanguages().get()
val disabledSourceIds = preferences.disabledSources().get()
return sourceManager.getVisibleCatalogueSources()
.filter { it.lang in languages || it.id == LocalSource.ID }
.filterNot { it.id.toString() in disabledSourceIds }
.sortedBy { "(${it.lang}) ${it.name.lowercase()}" }
fun setSourceCategories(source: Source, categories: List<String>) {
setSourceCategories.await(source, categories)
}
companion object {
@ -168,6 +159,29 @@ class SourcePresenter(
}
}
// SY -->
data class SourceCategory(val category: String, var sources: MutableList<SourceItem> = mutableListOf())
// SY <--
sealed class UiModel {
data class Item(val source: Source) : UiModel()
data class Header(val language: String, val isCategory: Boolean) : UiModel()
}
data class SourceState(
val sources: List<UiModel>,
val error: Throwable?,
val sourceCategories: List<String>,
val showLatest: Boolean,
val showPin: Boolean
) {
val isLoading: Boolean
get() = sources.isEmpty() && error == null
val hasError: Boolean
get() = error != null
val isEmpty: Boolean
get() = sources.isEmpty()
companion object {
val EMPTY = SourceState(listOf(), null, emptyList(), true, true)
}
}

View File

@ -20,6 +20,7 @@ import com.google.android.material.snackbar.Snackbar
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
@ -80,8 +81,8 @@ open class BrowseSourceController(bundle: Bundle) :
ChangeMangaCategoriesDialog.Listener {
constructor(
source: CatalogueSource,
searchQuery: String? = null,
sourceId: Long,
query: String? = null,
// SY -->
smartSearchConfig: SourceController.SmartSearchConfig? = null,
savedSearch: Long? = null,
@ -89,10 +90,9 @@ open class BrowseSourceController(bundle: Bundle) :
// SY <--
) : this(
Bundle().apply {
putLong(SOURCE_ID_KEY, source.id)
if (searchQuery != null) {
putString(SEARCH_QUERY_KEY, searchQuery)
putLong(SOURCE_ID_KEY, sourceId)
if (query != null) {
putString(SEARCH_QUERY_KEY, query)
}
// SY -->
@ -111,6 +111,38 @@ open class BrowseSourceController(bundle: Bundle) :
},
)
constructor(
source: CatalogueSource,
query: String? = null,
// SY -->
smartSearchConfig: SourceController.SmartSearchConfig? = null,
savedSearch: Long? = null,
filterList: String? = null,
// SY <--
) : this(
source.id,
query,
smartSearchConfig,
savedSearch,
filterList
)
constructor(
source: Source,
query: String? = null,
// SY -->
smartSearchConfig: SourceController.SmartSearchConfig? = null,
savedSearch: Long? = null,
filterList: String? = null,
// SY <--
) : this(
source.id,
query,
smartSearchConfig,
savedSearch,
filterList
)
private val preferences: PreferencesHelper by injectLazy()
/**

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.browse.source.latest
import android.os.Bundle
import android.view.Menu
import androidx.core.os.bundleOf
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
@ -13,9 +14,15 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
*/
class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) {
constructor(source: Source) : this(
bundleOf(SOURCE_ID_KEY to source.id),
)
// SY -->
constructor(source: CatalogueSource) : this(
bundleOf(SOURCE_ID_KEY to source.id),
)
// SY <--
override fun createPresenter(): BrowseSourcePresenter {
return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY))

View File

@ -69,6 +69,17 @@ class SettingsBrowseController : SettingsController() {
}
// SY <--
preferenceCategory {
titleRes = R.string.pref_category_general
switchPreference {
bindTo(preferences.pinsOnTop())
titleRes = R.string.pref_move_on_top
summaryRes = R.string.pref_move_on_top_summary
defaultValue = true
}
}
preferenceCategory {
titleRes = R.string.label_extensions

View File

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M16,9V4l1,0c0.55,0 1,-0.45 1,-1v0c0,-0.55 -0.45,-1 -1,-1H7C6.45,2 6,2.45 6,3v0c0,0.55 0.45,1 1,1l1,0v5c0,1.66 -1.34,3 -3,3h0v2h5.97v7l1,1l1,-1v-7H19v-2h0C17.34,12 16,10.66 16,9z" />
</vector>

View File

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M14,4v5c0,1.12 0.37,2.16 1,3H9c0.65,-0.86 1,-1.9 1,-3V4H14M17,2H7C6.45,2 6,2.45 6,3c0,0.55 0.45,1 1,1c0,0 0,0 0,0l1,0v5c0,1.66 -1.34,3 -3,3v2h5.97v7l1,1l1,-1v-7H19v-2c0,0 0,0 0,0c-1.66,0 -3,-1.34 -3,-3V4l1,0c0,0 0,0 0,0c0.55,0 1,-0.45 1,-1C18,2.45 17.55,2 17,2L17,2z" />
</vector>

View File

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="@dimen/action_toolbar_list_padding"
tools:listitem="@layout/section_header_item" />
<eu.kanade.tachiyomi.widget.MaterialFastScroll
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
</FrameLayout>

View File

@ -39,6 +39,7 @@
android:id="@+id/subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:visibility="gone"
@ -49,11 +50,13 @@
tools:text="English"
tools:visibility="visible" />
<Button
android:id="@+id/source_latest"
style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:maxHeight="48dp"
android:minWidth="0dp"
android:minHeight="48dp"
@ -62,22 +65,9 @@
android:text="@string/latest"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/pin"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<ImageButton
android:id="@+id/pin"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_pin"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_push_pin_outline_24dp"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -421,6 +421,8 @@
<!-- Browse section -->
<string name="pref_enable_automatic_extension_updates">Check for extension updates</string>
<string name="pref_search_pinned_sources_only">Only include pinned sources</string>
<string name="pref_move_on_top">Move pins on top</string>
<string name="pref_move_on_top_summary">Move up pins to top of the source list</string>
<!-- Backup section -->
<string name="pref_create_backup">Create backup</string>