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 package eu.kanade.domain
import eu.kanade.data.history.HistoryRepositoryImpl 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.DeleteHistoryTable
import eu.kanade.domain.history.interactor.GetHistory import eu.kanade.domain.history.interactor.GetHistory
import eu.kanade.domain.history.interactor.GetNextChapterForManga import eu.kanade.domain.history.interactor.GetNextChapterForManga
import eu.kanade.domain.history.interactor.RemoveHistoryById import eu.kanade.domain.history.interactor.RemoveHistoryById
import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
import eu.kanade.domain.history.repository.HistoryRepository 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.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory import uy.kohesive.injekt.api.addFactory
@ -22,5 +31,16 @@ class DomainModule : InjektModule {
addFactory { GetNextChapterForManga(get()) } addFactory { GetNextChapterForManga(get()) }
addFactory { RemoveHistoryById(get()) } addFactory { RemoveHistoryById(get()) }
addFactory { RemoveHistoryByMangaId(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) { fun TachiyomiTheme(content: @Composable () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val (colorScheme, typography) = createMdc3Theme( val (colorScheme, typography) = createMdc3Theme(
context = context context = context,
setTextColors = true
) )
MaterialTheme( 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 autoClearChapterCache() = prefs.getBoolean(Keys.autoClearChapterCache, false)
fun pinsOnTop() = flowPrefs.getBoolean("pins_on_top", true)
fun setChapterSettingsDefault(manga: Manga) { fun setChapterSettingsDefault(manga: Manga) {
prefs.edit { prefs.edit {
putInt(Keys.defaultChapterFilterByRead, manga.readFilter) putInt(Keys.defaultChapterFilterByRead, manga.readFilter)

View File

@ -72,13 +72,17 @@ class ExtensionManager(
} }
fun getAppIconForSource(source: Source): Drawable? { 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) { if (pkgName != null) {
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) } return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) }
} }
// SY --> // SY -->
return when (source.id) { return when (sourceId) {
EH_SOURCE_ID -> ContextCompat.getDrawable(context, R.mipmap.ic_ehentai_source) EH_SOURCE_ID -> ContextCompat.getDrawable(context, R.mipmap.ic_ehentai_source)
EXH_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) 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import kotlin.reflect.KClass import kotlin.reflect.KClass
@ -45,6 +48,9 @@ open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>() private val sourcesMap = mutableMapOf<Long, Source>()
private val stubSourcesMap = mutableMapOf<Long, StubSource>() private val stubSourcesMap = mutableMapOf<Long, StubSource>()
private val _catalogueSources: MutableStateFlow<List<CatalogueSource>> = MutableStateFlow(listOf())
val catalogueSources: Flow<List<CatalogueSource>> = _catalogueSources
// SY --> // SY -->
private val prefs: PreferencesHelper by injectLazy() private val prefs: PreferencesHelper by injectLazy()
@ -137,15 +143,23 @@ open class SourceManager(private val context: Context) {
if (!sourcesMap.containsKey(source.id)) { if (!sourcesMap.containsKey(source.id)) {
sourcesMap[source.id] = newSource sourcesMap[source.id] = newSource
} }
triggerCatalogueSources()
} }
internal fun unregisterSource(source: Source) { internal fun unregisterSource(source: Source) {
sourcesMap.remove(source.id) sourcesMap.remove(source.id)
triggerCatalogueSources()
// SY --> // SY -->
currentDelegatedSources.remove(source.id) currentDelegatedSources.remove(source.id)
// SY <-- // SY <--
} }
private fun triggerCatalogueSources() {
_catalogueSources.update {
sourcesMap.values.filterIsInstance<CatalogueSource>()
}
}
private fun createInternalSources(): List<Source> = listOf( private fun createInternalSources(): List<Source> = listOf(
LocalSource(context), LocalSource(context),
) )

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.base.controller package eu.kanade.tachiyomi.ui.base.controller
import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -7,6 +8,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.databinding.ComposeControllerBinding import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import nucleus.presenter.Presenter import nucleus.presenter.Presenter
/** /**
@ -34,7 +36,26 @@ abstract class ComposeController<P : Presenter<*>> : NucleusController<ComposeCo
/** /**
* Basic Compose controller without a presenter. * 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 = override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
ComposeControllerBinding.inflate(inflater) 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 package eu.kanade.tachiyomi.ui.browse.source
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.core.os.bundleOf import androidx.compose.runtime.Composable
import androidx.recyclerview.widget.LinearLayoutManager import androidx.compose.runtime.LaunchedEffect
import com.bluelinelabs.conductor.ControllerChangeHandler import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import com.bluelinelabs.conductor.ControllerChangeType import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder import eu.kanade.domain.source.model.Source
import dev.chrisbanes.insetter.applyInsetter import eu.kanade.presentation.source.SourceScreen
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.SourceMainControllerBinding import eu.kanade.tachiyomi.ui.base.controller.SearchableComposeController
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.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe 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.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController 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.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController 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.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 kotlinx.parcelize.Parcelize
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
/** /**
* This controller shows and manages the different catalogues enabled by the user. * 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] * 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) : class SourceController(bundle: Bundle? = null) : SearchableComposeController<SourcePresenter>(bundle) {
SearchableNucleusController<SourceMainControllerBinding, SourcePresenter>(bundle),
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
SourceAdapter.OnSourceClickListener,
/*SY -->*/
ChangeSourceCategoriesDialog.Listener /*SY <--*/ {
private val preferences: PreferencesHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy()
private var adapter: SourceAdapter? = null
// EXH --> // EXH -->
private val smartSearchConfig: SmartSearchConfig? = args.getParcelable(SMART_SEARCH_CONFIG) private val smartSearchConfig: SmartSearchConfig? = args.getParcelable(SMART_SEARCH_CONFIG)
@ -81,241 +55,57 @@ class SourceController(bundle: Bundle? = null) :
// SY <-- // SY <--
} }
override fun createPresenter(): SourcePresenter { override fun createPresenter(): SourcePresenter =
return SourcePresenter(/* SY --> */ controllerMode = mode /* SY <-- */) 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) { override fun onViewCreated(view: View) {
super.onViewCreated(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) 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. * 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()) { if (!preferences.incognitoMode().get()) {
preferences.lastUsedSource().set(source.id) preferences.lastUsedSource().set(source.id)
} }
parentController!!.router.pushController(controller) 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. * 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. * @return True if this event has been consumed, false if it has not.
*/ */
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { return when (item.itemId) {
// Initialize option to open catalogue settings. // Initialize option to open catalogue settings.
R.id.action_settings -> { R.id.action_settings -> {
parentController!!.router.pushController(SourceFilterController()) parentController!!.router.pushController(SourceFilterController())
true
} }
} else -> super.onOptionsItemSelected(item)
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()
} }
} }

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 package eu.kanade.tachiyomi.ui.browse.source
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import android.os.Bundle
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.domain.source.interactor.DisableSource
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.tachiyomi.source.SourceManager 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 eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import kotlinx.coroutines.delay import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.TreeMap 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. * Function calls should be done from here. UI calls should be done from the controller.
*/ */
class SourcePresenter( class SourcePresenter(
val sourceManager: SourceManager = Injekt.get(), private val getEnabledSources: GetEnabledSources = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(), private val disableSource: DisableSource = Injekt.get(),
private val toggleSourcePin: ToggleSourcePin = Injekt.get(),
// SY --> // 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, private val controllerMode: SourceController.Mode,
// SY <-- // SY <--
) : BasePresenter<SourceController>() { ) : BasePresenter<SourceController>() {
var sources = getEnabledSources() private val _state: MutableStateFlow<SourceState> = MutableStateFlow(SourceState.EMPTY)
val state: StateFlow<SourceState> = _state.asStateFlow()
/**
* Unsubscribe and create a new subscription to fetch enabled sources.
*/
private fun loadSources() {
val pinnedSources = mutableListOf<SourceItem>()
val pinnedSourceIds = preferences.pinnedSources().get()
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 --> // SY -->
val categories = preferences.sourcesTabCategories().get() presenterScope.launchIO {
.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER, { it })) getSourceCategories.subscribe()
.map { .catch { exception ->
SourceCategory(it) _state.update { state ->
state.copy(sources = listOf(), error = exception)
}
}
.collectLatest(::updateCategories)
}
presenterScope.launchIO {
_state.update { state ->
state.copy(
showPin = controllerMode == SourceController.Mode.CATALOGUE,
)
} }
}
val sourcesAndCategoriesCombined = preferences.sourcesTabSourcesInCategories().get() presenterScope.launchIO {
val sourcesAndCategories = if (sourcesAndCategoriesCombined.isNotEmpty()) sourcesAndCategoriesCombined.map { getShowLatest.subscribe(mode = controllerMode)
val temp = it.split("|") .catch { exception ->
temp[0] to temp[1] _state.update { state ->
} else null state.copy(sources = listOf(), error = exception)
}
val sourcesInCategories = sourcesAndCategories?.map { it.first } }
.collectLatest(::updateShowLatest)
}
// SY <-- // 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 // Catalogues without a lang defined will be placed at the end
when { 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 d1 == "" && d2 != "" -> 1
d2 == "" && d1 != "" -> -1 d2 == "" && d1 != "" -> -1
else -> d1.compareTo(d2) else -> d1.compareTo(d2)
} }
} }
val byLang = sources.groupByTo(map) { it.lang } val byLang = sources.groupByTo(map) {
var sourceItems = byLang.flatMap { when {
val langItem = LangItem(it.key)
it.value.map { source ->
// SY --> // SY -->
val showPins = controllerMode == SourceController.Mode.CATALOGUE it.category != null -> it.category
val showLatest = showPins && !preferences.useNewSourceNavigation().get()
// SY <-- // SY <--
val isPinned = source.id.toString() in pinnedSourceIds it.isUsedLast -> LAST_USED_KEY
if (isPinned) { Pin.Actual in it.pin -> PINNED_KEY
pinnedSources.add(SourceItem(source, LangItem(PINNED_KEY), isPinned /* SY --> */, showLatest, showPins /* SY <-- */)) 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,
),
)
}
}
}
}
// SY <--
SourceItem(source, langItem, isPinned /* SY --> */, showLatest, showPins /* SY <-- */)
} }
} }
_state.update { state ->
if (preferences.sourcesTabCategoriesFilter().get()) { state.copy(
sourcesInCategories?.let { sourcesIds -> sourceItems = sourceItems.filterNot { it.source.id.toString() in sourcesIds } } sources = byLang.flatMap {
listOf(
UiModel.Header(it.key, it.value.firstOrNull()?.category != null),
*it.value.map { source ->
UiModel.Item(source)
}.toTypedArray()
)
},
error = null
)
} }
// SY -->
categories.forEach {
sourceItems = it.sources.sortedBy { sourceItem -> sourceItem.source.name.lowercase() } + sourceItems
}
// SY <--
if (pinnedSources.isNotEmpty()) {
sourceItems = pinnedSources + sourceItems
}
view?.setSources(sourceItems)
} }
private fun loadLastUsedSource() { // SY -->
// Immediate initial load private suspend fun updateCategories(categories: Set<String>) {
preferences.lastUsedSource().get().let { updateLastUsedSource(it) } _state.update { state ->
state.copy(
// Subsequent updates sourceCategories = categories.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it })
preferences.lastUsedSource().asFlow() )
.drop(1)
.onStart { delay(500) }
.distinctUntilChanged()
.onEach { updateLastUsedSource(it) }
.launchIn(presenterScope)
}
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) } }
private suspend fun updateShowLatest(showLatest: Boolean) {
_state.update { state ->
state.copy(
showLatest = showLatest
)
}
}
// SY <--
fun disableSource(source: Source) {
disableSource.await(source)
} }
fun updateSources() { fun togglePin(source: Source) {
sources = getEnabledSources() toggleSourcePin.await(source)
loadSources()
loadLastUsedSource()
} }
/** fun toggleExcludeFromDataSaver(source: Source) {
* Returns a list of enabled sources ordered by language and name. toggleExcludeFromDataSaver.await(source)
* }
* @return list containing enabled sources.
*/
private fun getEnabledSources(): List<CatalogueSource> {
val languages = preferences.enabledLanguages().get()
val disabledSourceIds = preferences.disabledSources().get()
return sourceManager.getVisibleCatalogueSources() fun setSourceCategories(source: Source, categories: List<String>) {
.filter { it.lang in languages || it.id == LocalSource.ID } setSourceCategories.await(source, categories)
.filterNot { it.id.toString() in disabledSourceIds }
.sortedBy { "(${it.lang}) ${it.name.lowercase()}" }
} }
companion object { companion object {
@ -168,6 +159,29 @@ class SourcePresenter(
} }
} }
// SY --> sealed class UiModel {
data class SourceCategory(val category: String, var sources: MutableList<SourceItem> = mutableListOf()) data class Item(val source: Source) : UiModel()
// SY <-- 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 dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
@ -80,8 +81,8 @@ open class BrowseSourceController(bundle: Bundle) :
ChangeMangaCategoriesDialog.Listener { ChangeMangaCategoriesDialog.Listener {
constructor( constructor(
source: CatalogueSource, sourceId: Long,
searchQuery: String? = null, query: String? = null,
// SY --> // SY -->
smartSearchConfig: SourceController.SmartSearchConfig? = null, smartSearchConfig: SourceController.SmartSearchConfig? = null,
savedSearch: Long? = null, savedSearch: Long? = null,
@ -89,10 +90,9 @@ open class BrowseSourceController(bundle: Bundle) :
// SY <-- // SY <--
) : this( ) : this(
Bundle().apply { Bundle().apply {
putLong(SOURCE_ID_KEY, source.id) putLong(SOURCE_ID_KEY, sourceId)
if (query != null) {
if (searchQuery != null) { putString(SEARCH_QUERY_KEY, query)
putString(SEARCH_QUERY_KEY, searchQuery)
} }
// SY --> // 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() 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.os.Bundle
import android.view.Menu import android.view.Menu
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController 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) { class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) {
constructor(source: Source) : this(
bundleOf(SOURCE_ID_KEY to source.id),
)
// SY -->
constructor(source: CatalogueSource) : this( constructor(source: CatalogueSource) : this(
bundleOf(SOURCE_ID_KEY to source.id), bundleOf(SOURCE_ID_KEY to source.id),
) )
// SY <--
override fun createPresenter(): BrowseSourcePresenter { override fun createPresenter(): BrowseSourcePresenter {
return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY)) return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY))

View File

@ -69,6 +69,17 @@ class SettingsBrowseController : SettingsController() {
} }
// SY <-- // 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 { preferenceCategory {
titleRes = R.string.label_extensions 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:id="@+id/subtitle"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:maxLines="1" android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall" android:textAppearance="?attr/textAppearanceBodySmall"
android:visibility="gone" android:visibility="gone"
@ -49,11 +50,13 @@
tools:text="English" tools:text="English"
tools:visibility="visible" /> tools:visibility="visible" />
<Button <Button
android:id="@+id/source_latest" android:id="@+id/source_latest"
style="?attr/borderlessButtonStyle" style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:maxHeight="48dp" android:maxHeight="48dp"
android:minWidth="0dp" android:minWidth="0dp"
android:minHeight="48dp" android:minHeight="48dp"
@ -62,22 +65,9 @@
android:text="@string/latest" android:text="@string/latest"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" 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_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_push_pin_outline_24dp"
tools:visibility="visible" /> tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -421,6 +421,8 @@
<!-- Browse section --> <!-- Browse section -->
<string name="pref_enable_automatic_extension_updates">Check for extension updates</string> <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_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 --> <!-- Backup section -->
<string name="pref_create_backup">Create backup</string> <string name="pref_create_backup">Create backup</string>