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
This commit is contained in:
Andreas 2022-04-24 20:35:59 +02:00 committed by GitHub
parent 558b18899c
commit 29a0989f28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 705 additions and 538 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,17 @@
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.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 +27,9 @@ 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()) }
} }
} }

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,57 @@
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)
}
.combine(repository.getSources()) { (pinList, enabledLanguages, disabledSources, lastUsedSource), sources ->
val pinsOnTop = preferences.pinsOnTop().get()
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
val source = it.copy(pin = flag)
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))
}
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
)

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,78 @@
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
) {
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
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,282 @@
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.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.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.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,
) {
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,
onClickItem = onClickItem,
onClickDisable = onClickDisable,
onClickLatest = onClickLatest,
onClickPin = onClickPin,
)
}
}
@Composable
fun SourceList(
nestedScrollConnection: NestedScrollConnection,
list: List<UiModel>,
onClickItem: (Source) -> Unit,
onClickDisable: (Source) -> Unit,
onClickLatest: (Source) -> Unit,
onClickPin: (Source) -> Unit,
) {
val (sourceState, setSourceState) = remember { mutableStateOf<Source?>(null) }
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
)
}
is UiModel.Item -> SourceItem(
modifier = Modifier.animateItemPlacement(),
item = model.source,
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)
},
onDismiss = { setSourceState(null) }
)
}
}
@Composable
fun SourceHeader(
modifier: Modifier = Modifier,
language: String
) {
val context = LocalContext.current
Text(
text = LocaleHelper.getSourceDisplayName(language, context),
modifier = modifier
.padding(horizontal = horizontalPadding, vertical = 8.dp),
style = MaterialTheme.typography.header
)
}
@Composable
fun SourceItem(
modifier: Modifier = Modifier,
item: Source,
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) {
TextButton(onClick = { onClickLatest(item) }) {
Text(text = stringResource(id = R.string.latest))
}
}
SourcePinButton(
isPinned = Pin.Pinned in item.pin,
onClick = { onClickPin(item) }
)
}
}
@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,
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)
)
}
}
},
onDismissRequest = onDismiss,
confirmButton = {},
)
}

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

@ -324,6 +324,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

@ -66,7 +66,11 @@ 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) }
} }

View File

@ -6,6 +6,9 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import rx.Observable import rx.Observable
open class SourceManager(private val context: Context) { open class SourceManager(private val context: Context) {
@ -13,6 +16,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
init { init {
createInternalSources().forEach { registerSource(it) } createInternalSources().forEach { registerSource(it) }
} }
@ -38,10 +44,18 @@ open class SourceManager(private val context: Context) {
if (!stubSourcesMap.containsKey(source.id)) { if (!stubSourcesMap.containsKey(source.id)) {
stubSourcesMap[source.id] = StubSource(source.id) stubSourcesMap[source.id] = StubSource(source.id)
} }
triggerCatalogueSources()
} }
internal fun unregisterSource(source: Source) { internal fun unregisterSource(source: Source) {
sourcesMap.remove(source.id) sourcesMap.remove(source.id)
triggerCatalogueSources()
}
private fun triggerCatalogueSources() {
_catalogueSources.update {
sourcesMap.values.filterIsInstance<CatalogueSource>()
}
} }
private fun createInternalSources(): List<Source> = listOf( private fun createInternalSources(): List<Source> = listOf(

View File

@ -7,6 +7,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
/** /**
@ -52,3 +53,22 @@ abstract class BasicComposeController : BaseController<ComposeControllerBinding>
@Composable abstract fun ComposeContent(nestedScrollInterop: NestedScrollConnection) @Composable abstract fun ComposeContent(nestedScrollInterop: NestedScrollConnection)
} }
abstract class SearchableComposeController<P : BasePresenter<*>> : SearchableNucleusController<ComposeControllerBinding, P>() {
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)
}

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,182 +1,76 @@
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.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.recyclerview.widget.LinearLayoutManager import androidx.compose.runtime.Composable
import com.bluelinelabs.conductor.ControllerChangeHandler import androidx.compose.runtime.LaunchedEffect
import com.bluelinelabs.conductor.ControllerChangeType import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
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.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.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.view.onAnimationsFinished
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 : class SourceController : SearchableComposeController<SourcePresenter>() {
SearchableNucleusController<SourceMainControllerBinding, SourcePresenter>(),
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
SourceAdapter.OnSourceClickListener {
private val preferences: PreferencesHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy()
private var adapter: SourceAdapter? = null
init { init {
setHasOptionsMenu(true) setHasOptionsMenu(true)
} }
override fun getTitle(): String? { override fun getTitle(): String? =
return applicationContext?.getString(R.string.label_sources) resources?.getString(R.string.label_sources)
}
override fun createPresenter(): SourcePresenter { override fun createPresenter(): SourcePresenter =
return SourcePresenter() SourcePresenter()
}
override fun createBinding(inflater: LayoutInflater) = SourceMainControllerBinding.inflate(inflater) @Composable
override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
SourceScreen(
nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
onClickItem = { source ->
openSource(source, BrowseSourceController(source))
},
onClickDisable = { source ->
presenter.disableSource(source)
},
onClickLatest = { source ->
openSource(source, LatestUpdatesController(source))
},
onClickPin = { source ->
presenter.togglePin(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)
// Update list on extension changes (e.g. new installation)
(parentController as BrowseController).extensionListUpdateRelay
.skip(1) // Skip first update when ExtensionController created
.subscribeUntilDestroy {
presenter.updateSources()
}
}
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
openSource(source, BrowseSourceController(source))
}
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) })
}
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()
}
/**
* 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: BrowseSourceController) {
if (!preferences.incognitoMode().get()) { if (!preferences.incognitoMode().get()) {
preferences.lastUsedSource().set(source.id) preferences.lastUsedSource().set(source.id)
} }
@ -190,51 +84,13 @@ class SourceController :
* @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,52 +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) :
FlexibleViewHolder(view, adapter) {
private val binding = SourceMainControllerItemBinding.bind(view)
init {
binding.sourceLatest.setOnClickListener {
adapter.clickListener.onLatestClick(bindingAdapterPosition)
}
binding.pin.setOnClickListener {
adapter.clickListener.onPinClick(bindingAdapterPosition)
}
}
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
binding.pin.isVisible = true
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,56 +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,
) :
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)
}
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,19 @@
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.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,87 +23,68 @@ 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()
) : BasePresenter<SourceController>() { ) : BasePresenter<SourceController>() {
var sources = getEnabledSources() private val _state: MutableStateFlow<SourceState> = MutableStateFlow(SourceState.EMPTY)
val state: StateFlow<SourceState> = _state.asStateFlow()
/** override fun onCreate(savedState: Bundle?) {
* Unsubscribe and create a new subscription to fetch enabled sources. super.onCreate(savedState)
*/ presenterScope.launchIO {
private fun loadSources() { getEnabledSources.subscribe()
val pinnedSources = mutableListOf<SourceItem>() .catch { exception ->
val pinnedSourceIds = preferences.pinnedSources().get() _state.update { state ->
state.copy(sources = listOf(), error = exception)
}
}
.collectLatest(::collectLatestSources)
}
}
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.isUsedLast -> LAST_USED_KEY
it.value.map { source -> Pin.Actual in it.pin -> PINNED_KEY
val isPinned = source.id.toString() in pinnedSourceIds else -> it.lang
if (isPinned) {
pinnedSources.add(SourceItem(source, LangItem(PINNED_KEY), isPinned))
} }
}
SourceItem(source, langItem, isPinned) _state.update { state ->
state.copy(
sources = byLang.flatMap {
listOf(
UiModel.Header(it.key),
*it.value.map { source ->
UiModel.Item(source)
}.toTypedArray()
)
},
error = null
)
} }
} }
if (pinnedSources.isNotEmpty()) { fun disableSource(source: Source) {
sourceItems = pinnedSources + sourceItems 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)
}
private fun updateLastUsedSource(sourceId: Long) {
val source = (sourceManager.get(sourceId) as? CatalogueSource)?.let {
val isPinned = it.id.toString() in preferences.pinnedSources().get()
SourceItem(it, null, isPinned)
}
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.getCatalogueSources()
.filter { it.lang in languages || it.id == LocalSource.ID }
.filterNot { it.id.toString() in disabledSourceIds }
.sortedBy { "(${it.lang}) ${it.name.lowercase()}" }
} }
companion object { companion object {
@ -108,3 +92,27 @@ class SourcePresenter(
const val LAST_USED_KEY = "last_used" const val LAST_USED_KEY = "last_used"
} }
} }
sealed class UiModel {
data class Item(val source: Source) : UiModel()
data class Header(val language: String) : UiModel()
}
data class SourceState(
val sources: List<UiModel>,
val error: Throwable?
) {
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)
}
}

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
@ -69,16 +70,19 @@ open class BrowseSourceController(bundle: Bundle) :
FlexibleAdapter.EndlessScrollListener, FlexibleAdapter.EndlessScrollListener,
ChangeMangaCategoriesDialog.Listener { ChangeMangaCategoriesDialog.Listener {
constructor(source: CatalogueSource, searchQuery: String? = null) : this( constructor(sourceId: Long, query: String? = null) : this(
Bundle().apply { Bundle().apply {
putLong(SOURCE_ID_KEY, source.id) putLong(SOURCE_ID_KEY, sourceId)
query?.let { query ->
if (searchQuery != null) { putString(SEARCH_QUERY_KEY, query)
putString(SEARCH_QUERY_KEY, searchQuery)
} }
}, },
) )
constructor(source: CatalogueSource, query: String? = null) : this(source.id, query)
constructor(source: Source, query: String? = null) : this(source.id, query)
private val preferences: PreferencesHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy()
/** /**

View File

@ -3,8 +3,8 @@ 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.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
*/ */
class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) { class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) {
constructor(source: CatalogueSource) : this( constructor(source: Source) : this(
bundleOf(SOURCE_ID_KEY to source.id), bundleOf(SOURCE_ID_KEY to source.id),
) )

View File

@ -21,6 +21,17 @@ class SettingsBrowseController : SettingsController() {
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
titleRes = R.string.browse titleRes = R.string.browse
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

@ -23,13 +23,14 @@
android:id="@+id/title" android:id="@+id/title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="1" android:maxLines="1"
android:paddingStart="0dp" android:paddingStart="0dp"
android:paddingEnd="8dp" android:paddingEnd="8dp"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toTopOf="@id/subtitle" app:layout_constraintBottom_toTopOf="@id/subtitle"
app:layout_constraintEnd_toStartOf="@+id/source_latest" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/image" app:layout_constraintStart_toEndOf="@+id/image"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" app:layout_constraintVertical_chainStyle="packed"
@ -39,45 +40,15 @@
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"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/source_latest" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/image" app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toBottomOf="@+id/title" app:layout_constraintTop_toBottomOf="@+id/title"
tools:text="English" tools:text="English"
tools:visibility="visible" /> tools:visibility="visible" />
<Button
android:id="@+id/source_latest"
style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxHeight="48dp"
android:minWidth="0dp"
android:minHeight="48dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
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> </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>