浏览代码

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
Andreas 2 年之前
父节点
当前提交
29a0989f28
共有 30 个文件被更改,包括 704 次插入537 次删除
  1. 13 0
      app/src/main/java/eu/kanade/data/source/SourceMapper.kt
  2. 18 0
      app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt
  3. 9 0
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  4. 14 0
      app/src/main/java/eu/kanade/domain/source/interactor/DisableSource.kt
  5. 57 0
      app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt
  6. 20 0
      app/src/main/java/eu/kanade/domain/source/interactor/ToggleSourcePin.kt
  7. 78 0
      app/src/main/java/eu/kanade/domain/source/model/Source.kt
  8. 9 0
      app/src/main/java/eu/kanade/domain/source/repository/SourceRepository.kt
  9. 282 0
      app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt
  10. 2 1
      app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt
  11. 16 0
      app/src/main/java/eu/kanade/presentation/theme/Typography.kt
  12. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
  13. 5 1
      app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
  14. 14 0
      app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt
  15. 20 0
      app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt
  16. 0 17
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangHolder.kt
  17. 0 42
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangItem.kt
  18. 0 32
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceAdapter.kt
  19. 38 182
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt
  20. 0 52
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt
  21. 0 56
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt
  22. 79 71
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt
  23. 9 5
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt
  24. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt
  25. 11 0
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt
  26. 0 9
      app/src/main/res/drawable/ic_push_pin_24dp.xml
  27. 0 9
      app/src/main/res/drawable/ic_push_pin_outline_24dp.xml
  28. 0 25
      app/src/main/res/layout/source_main_controller.xml
  29. 4 33
      app/src/main/res/layout/source_main_controller_item.xml
  30. 2 0
      app/src/main/res/values/strings.xml

+ 13 - 0
app/src/main/java/eu/kanade/data/source/SourceMapper.kt

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

+ 18 - 0
app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt

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

+ 9 - 0
app/src/main/java/eu/kanade/domain/DomainModule.kt

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

+ 14 - 0
app/src/main/java/eu/kanade/domain/source/interactor/DisableSource.kt

@@ -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()
+    }
+}

+ 57 - 0
app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt

@@ -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
+)

+ 20 - 0
app/src/main/java/eu/kanade/domain/source/interactor/ToggleSourcePin.kt

@@ -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()
+        }
+    }
+}

+ 78 - 0
app/src/main/java/eu/kanade/domain/source/model/Source.kt

@@ -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() {
+            [email protected] = code or [email protected]
+        }
+
+        operator fun Pin.unaryMinus() {
+            [email protected] = code or [email protected]
+        }
+
+        fun flags(): Pins = Pins(code)
+    }
+}

+ 9 - 0
app/src/main/java/eu/kanade/domain/source/repository/SourceRepository.kt

@@ -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>>
+}

+ 282 - 0
app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt

@@ -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 = {},
+    )
+}

+ 2 - 1
app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt

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

+ 16 - 0
app/src/main/java/eu/kanade/presentation/theme/Typography.kt

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

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt

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

+ 5 - 1
app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt

@@ -66,7 +66,11 @@ class ExtensionManager(
         }
 
     fun getAppIconForSource(source: Source): Drawable? {
-        val pkgName = installedExtensions.find { ext -> ext.sources.any { it.id == source.id } }?.pkgName
+        return getAppIconForSource(source.id)
+    }
+
+    fun getAppIconForSource(sourceId: Long): Drawable? {
+        val pkgName = installedExtensions.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
         if (pkgName != null) {
             return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) }
         }

+ 14 - 0
app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt

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

+ 20 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt

@@ -7,6 +7,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
 import eu.kanade.presentation.theme.TachiyomiTheme
 import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
+import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import nucleus.presenter.Presenter
 
 /**
@@ -52,3 +53,22 @@ abstract class BasicComposeController : BaseController<ComposeControllerBinding>
 
     @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)
+}

+ 0 - 17
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangHolder.kt

@@ -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)
-    }
-}

+ 0 - 42
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangItem.kt

@@ -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)
-    }
-}

+ 0 - 32
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceAdapter.kt

@@ -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)
-    }
-}

+ 38 - 182
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt

@@ -1,182 +1,76 @@
 package eu.kanade.tachiyomi.ui.browse.source
 
 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.MenuInflater
 import android.view.MenuItem
 import android.view.View
-import androidx.recyclerview.widget.LinearLayoutManager
-import com.bluelinelabs.conductor.ControllerChangeHandler
-import com.bluelinelabs.conductor.ControllerChangeType
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dev.chrisbanes.insetter.applyInsetter
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.IFlexible
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import eu.kanade.domain.source.model.Source
+import eu.kanade.presentation.source.SourceScreen
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.databinding.SourceMainControllerBinding
-import eu.kanade.tachiyomi.source.CatalogueSource
-import eu.kanade.tachiyomi.source.LocalSource
-import eu.kanade.tachiyomi.source.Source
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
-import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
+import eu.kanade.tachiyomi.ui.base.controller.SearchableComposeController
 import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
-import eu.kanade.tachiyomi.ui.browse.BrowseController
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
 import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
 import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
 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
 
 /**
  * This controller shows and manages the different catalogues enabled by the user.
  * This controller should only handle UI actions, IO actions should be done by [SourcePresenter]
- * [SourceAdapter.OnSourceClickListener] call function data on browse item click.
- * [SourceAdapter.OnLatestClickListener] call function data on latest item click
  */
-class SourceController :
-    SearchableNucleusController<SourceMainControllerBinding, SourcePresenter>(),
-    FlexibleAdapter.OnItemClickListener,
-    FlexibleAdapter.OnItemLongClickListener,
-    SourceAdapter.OnSourceClickListener {
+class SourceController : SearchableComposeController<SourcePresenter>() {
 
     private val preferences: PreferencesHelper by injectLazy()
 
-    private var adapter: SourceAdapter? = null
-
     init {
         setHasOptionsMenu(true)
     }
 
-    override fun getTitle(): String? {
-        return applicationContext?.getString(R.string.label_sources)
-    }
-
-    override fun createPresenter(): SourcePresenter {
-        return SourcePresenter()
+    override fun getTitle(): String? =
+        resources?.getString(R.string.label_sources)
+
+    override fun createPresenter(): SourcePresenter =
+        SourcePresenter()
+
+    @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 createBinding(inflater: LayoutInflater) = SourceMainControllerBinding.inflate(inflater)
-
     override fun onViewCreated(view: View) {
         super.onViewCreated(view)
-
-        binding.recycler.applyInsetter {
-            type(navigationBars = true) {
-                padding()
-            }
-        }
-
-        adapter = SourceAdapter(this)
-
-        // Create recycler and set adapter.
-        binding.recycler.layoutManager = LinearLayoutManager(view.context)
-        binding.recycler.adapter = adapter
-        binding.recycler.onAnimationsFinished {
-            (activity as? MainActivity)?.ready = true
-        }
-        adapter?.fastScroller = binding.fastScroller
-
         requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
-
-        // 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.
      */
-    private fun openSource(source: CatalogueSource, controller: BrowseSourceController) {
+    private fun openSource(source: Source, controller: BrowseSourceController) {
         if (!preferences.incognitoMode().get()) {
             preferences.lastUsedSource().set(source.id)
         }
@@ -190,51 +84,13 @@ class SourceController :
      * @return True if this event has been consumed, false if it has not.
      */
     override fun onOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
+        return when (item.itemId) {
             // Initialize option to open catalogue settings.
             R.id.action_settings -> {
                 parentController!!.router.pushController(SourceFilterController())
+                true
             }
-        }
-        return super.onOptionsItemSelected(item)
-    }
-
-    /**
-     * Called to update adapter containing sources.
-     */
-    fun setSources(sources: List<IFlexible<*>>) {
-        adapter?.updateDataSet(sources)
-    }
-
-    /**
-     * Called to set the last used catalogue at the top of the view.
-     */
-    fun setLastUsedSource(item: SourceItem?) {
-        adapter?.removeAllScrollableHeaders()
-        if (item != null) {
-            adapter?.addScrollableHeader(item)
-            adapter?.addScrollableHeader(LangItem(SourcePresenter.LAST_USED_KEY))
-        }
-    }
-
-    class SourceOptionsDialog(bundle: Bundle? = null) : DialogController(bundle) {
-
-        private lateinit var source: String
-        private lateinit var items: List<Pair<String, () -> Unit>>
-
-        constructor(source: String, items: List<Pair<String, () -> Unit>>) : this() {
-            this.source = source
-            this.items = items
-        }
-
-        override fun onCreateDialog(savedViewState: Bundle?): Dialog {
-            return MaterialAlertDialogBuilder(activity!!)
-                .setTitle(source)
-                .setItems(items.map { it.first }.toTypedArray()) { dialog, which ->
-                    items[which].second()
-                    dialog.dismiss()
-                }
-                .create()
+            else -> super.onOptionsItemSelected(item)
         }
     }
 

+ 0 - 52
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt

@@ -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)
-        }
-    }
-}

+ 0 - 56
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt

@@ -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
-    }
-}

+ 79 - 71
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt

@@ -1,16 +1,19 @@
 package eu.kanade.tachiyomi.ui.browse.source
 
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.source.CatalogueSource
-import eu.kanade.tachiyomi.source.LocalSource
-import eu.kanade.tachiyomi.source.SourceManager
+import android.os.Bundle
+import eu.kanade.domain.source.interactor.DisableSource
+import eu.kanade.domain.source.interactor.GetEnabledSources
+import eu.kanade.domain.source.interactor.ToggleSourcePin
+import eu.kanade.domain.source.model.Pin
+import eu.kanade.domain.source.model.Source
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.drop
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.onStart
+import eu.kanade.tachiyomi.util.lang.launchIO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.update
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 import java.util.TreeMap
@@ -20,91 +23,96 @@ import java.util.TreeMap
  * Function calls should be done from here. UI calls should be done from the controller.
  */
 class SourcePresenter(
-    val sourceManager: SourceManager = Injekt.get(),
-    private val preferences: PreferencesHelper = Injekt.get(),
+    private val getEnabledSources: GetEnabledSources = Injekt.get(),
+    private val disableSource: DisableSource = Injekt.get(),
+    private val toggleSourcePin: ToggleSourcePin = Injekt.get()
 ) : 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)
+        }
+    }
 
-        val map = TreeMap<String, MutableList<CatalogueSource>> { d1, d2 ->
+    private suspend fun collectLatestSources(sources: List<Source>) {
+        val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
             // Catalogues without a lang defined will be placed at the end
             when {
+                d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1
+                d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1
+                d1 == PINNED_KEY && d2 != PINNED_KEY -> -1
+                d2 == PINNED_KEY && d1 != PINNED_KEY -> 1
                 d1 == "" && d2 != "" -> 1
                 d2 == "" && d1 != "" -> -1
                 else -> d1.compareTo(d2)
             }
         }
-        val byLang = sources.groupByTo(map) { it.lang }
-        var sourceItems = byLang.flatMap {
-            val langItem = LangItem(it.key)
-            it.value.map { source ->
-                val isPinned = source.id.toString() in pinnedSourceIds
-                if (isPinned) {
-                    pinnedSources.add(SourceItem(source, LangItem(PINNED_KEY), isPinned))
-                }
-
-                SourceItem(source, langItem, isPinned)
+        val byLang = sources.groupByTo(map) {
+            when {
+                it.isUsedLast -> LAST_USED_KEY
+                Pin.Actual in it.pin -> PINNED_KEY
+                else -> it.lang
             }
         }
-
-        if (pinnedSources.isNotEmpty()) {
-            sourceItems = pinnedSources + sourceItems
+        _state.update { state ->
+            state.copy(
+                sources = byLang.flatMap {
+                    listOf(
+                        UiModel.Header(it.key),
+                        *it.value.map { source ->
+                            UiModel.Item(source)
+                        }.toTypedArray()
+                    )
+                },
+                error = null
+            )
         }
-
-        view?.setSources(sourceItems)
     }
 
-    private fun loadLastUsedSource() {
-        // Immediate initial load
-        preferences.lastUsedSource().get().let { updateLastUsedSource(it) }
-
-        // Subsequent updates
-        preferences.lastUsedSource().asFlow()
-            .drop(1)
-            .onStart { delay(500) }
-            .distinctUntilChanged()
-            .onEach { updateLastUsedSource(it) }
-            .launchIn(presenterScope)
+    fun disableSource(source: Source) {
+        disableSource.await(source)
     }
 
-    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 togglePin(source: Source) {
+        toggleSourcePin.await(source)
     }
 
-    fun updateSources() {
-        sources = getEnabledSources()
-        loadSources()
-        loadLastUsedSource()
+    companion object {
+        const val PINNED_KEY = "pinned"
+        const val LAST_USED_KEY = "last_used"
     }
+}
 
-    /**
-     * 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()
+sealed class UiModel {
+    data class Item(val source: Source) : UiModel()
+    data class Header(val language: String) : UiModel()
+}
 
-        return sourceManager.getCatalogueSources()
-            .filter { it.lang in languages || it.id == LocalSource.ID }
-            .filterNot { it.id.toString() in disabledSourceIds }
-            .sortedBy { "(${it.lang}) ${it.name.lowercase()}" }
-    }
+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 {
-        const val PINNED_KEY = "pinned"
-        const val LAST_USED_KEY = "last_used"
+        val EMPTY = SourceState(listOf(), null)
     }
 }

+ 9 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt

@@ -20,6 +20,7 @@ import com.google.android.material.snackbar.Snackbar
 import dev.chrisbanes.insetter.applyInsetter
 import eu.davidea.flexibleadapter.FlexibleAdapter
 import eu.davidea.flexibleadapter.items.IFlexible
+import eu.kanade.domain.source.model.Source
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.models.Category
 import eu.kanade.tachiyomi.data.database.models.Manga
@@ -69,16 +70,19 @@ open class BrowseSourceController(bundle: Bundle) :
     FlexibleAdapter.EndlessScrollListener,
     ChangeMangaCategoriesDialog.Listener {
 
-    constructor(source: CatalogueSource, searchQuery: String? = null) : this(
+    constructor(sourceId: Long, query: String? = null) : this(
         Bundle().apply {
-            putLong(SOURCE_ID_KEY, source.id)
-
-            if (searchQuery != null) {
-                putString(SEARCH_QUERY_KEY, searchQuery)
+            putLong(SOURCE_ID_KEY, sourceId)
+            query?.let { query ->
+                putString(SEARCH_QUERY_KEY, query)
             }
         },
     )
 
+    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()
 
     /**

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt

@@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.ui.browse.source.latest
 import android.os.Bundle
 import android.view.Menu
 import androidx.core.os.bundleOf
+import eu.kanade.domain.source.model.Source
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.source.CatalogueSource
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
 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) {
 
-    constructor(source: CatalogueSource) : this(
+    constructor(source: Source) : this(
         bundleOf(SOURCE_ID_KEY to source.id),
     )
 

+ 11 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt

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

+ 0 - 9
app/src/main/res/drawable/ic_push_pin_24dp.xml

@@ -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>

+ 0 - 9
app/src/main/res/drawable/ic_push_pin_outline_24dp.xml

@@ -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>

+ 0 - 25
app/src/main/res/layout/source_main_controller.xml

@@ -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>

+ 4 - 33
app/src/main/res/layout/source_main_controller_item.xml

@@ -23,13 +23,14 @@
         android:id="@+id/title"
         android:layout_width="0dp"
         android:layout_height="wrap_content"
+        android:layout_marginEnd="8dp"
         android:ellipsize="end"
         android:maxLines="1"
         android:paddingStart="0dp"
         android:paddingEnd="8dp"
         android:textAppearance="?attr/textAppearanceBodyMedium"
         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_constraintTop_toTopOf="parent"
         app:layout_constraintVertical_chainStyle="packed"
@@ -39,45 +40,15 @@
         android:id="@+id/subtitle"
         android:layout_width="0dp"
         android:layout_height="wrap_content"
+        android:layout_marginEnd="8dp"
         android:maxLines="1"
         android:textAppearance="?attr/textAppearanceBodySmall"
         android:visibility="gone"
         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_constraintTop_toBottomOf="@+id/title"
         tools:text="English"
         tools:visibility="visible" />
 
-    <Button
-        android:id="@+id/source_latest"
-        style="?attr/borderlessButtonStyle"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android: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>

+ 2 - 0
app/src/main/res/values/strings.xml

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