From 29a0989f2889d3361f583285091878c9b4570a52 Mon Sep 17 00:00:00 2001 From: Andreas Date: Sun, 24 Apr 2022 20:35:59 +0200 Subject: [PATCH] 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 --- .../eu/kanade/data/source/SourceMapper.kt | 13 + .../data/source/SourceRepositoryImpl.kt | 18 ++ .../java/eu/kanade/domain/DomainModule.kt | 9 + .../domain/source/interactor/DisableSource.kt | 14 + .../source/interactor/GetEnabledSources.kt | 57 ++++ .../source/interactor/ToggleSourcePin.kt | 20 ++ .../eu/kanade/domain/source/model/Source.kt | 78 +++++ .../source/repository/SourceRepository.kt | 9 + .../presentation/source/SourceScreen.kt | 282 ++++++++++++++++++ .../presentation/theme/TachiyomiTheme.kt | 3 +- .../kanade/presentation/theme/Typography.kt | 16 + .../data/preference/PreferencesHelper.kt | 2 + .../tachiyomi/extension/ExtensionManager.kt | 6 +- .../kanade/tachiyomi/source/SourceManager.kt | 14 + .../ui/base/controller/ComposeController.kt | 20 ++ .../tachiyomi/ui/browse/source/LangHolder.kt | 17 -- .../tachiyomi/ui/browse/source/LangItem.kt | 42 --- .../ui/browse/source/SourceAdapter.kt | 32 -- .../ui/browse/source/SourceController.kt | 218 +++----------- .../ui/browse/source/SourceHolder.kt | 52 ---- .../tachiyomi/ui/browse/source/SourceItem.kt | 56 ---- .../ui/browse/source/SourcePresenter.kt | 154 +++++----- .../source/browse/BrowseSourceController.kt | 14 +- .../source/latest/LatestUpdatesController.kt | 4 +- .../ui/setting/SettingsBrowseController.kt | 11 + .../main/res/drawable/ic_push_pin_24dp.xml | 9 - .../res/drawable/ic_push_pin_outline_24dp.xml | 9 - .../res/layout/source_main_controller.xml | 25 -- .../layout/source_main_controller_item.xml | 37 +-- app/src/main/res/values/strings.xml | 2 + 30 files changed, 705 insertions(+), 538 deletions(-) create mode 100644 app/src/main/java/eu/kanade/data/source/SourceMapper.kt create mode 100644 app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/DisableSource.kt create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/ToggleSourcePin.kt create mode 100644 app/src/main/java/eu/kanade/domain/source/model/Source.kt create mode 100644 app/src/main/java/eu/kanade/domain/source/repository/SourceRepository.kt create mode 100644 app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/theme/Typography.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt delete mode 100644 app/src/main/res/drawable/ic_push_pin_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_push_pin_outline_24dp.xml delete mode 100644 app/src/main/res/layout/source_main_controller.xml diff --git a/app/src/main/java/eu/kanade/data/source/SourceMapper.kt b/app/src/main/java/eu/kanade/data/source/SourceMapper.kt new file mode 100644 index 000000000..a54022cd6 --- /dev/null +++ b/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 + ) +} diff --git a/app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt new file mode 100644 index 000000000..b31f24f88 --- /dev/null +++ b/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> { + return sourceManager.catalogueSources.map { sources -> + sources.map(sourceMapper) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 9462ae7f9..71e1dcef2 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/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 { SourceRepositoryImpl(get()) } + addFactory { GetEnabledSources(get(), get()) } + addFactory { DisableSource(get()) } + addFactory { ToggleSourcePin(get()) } } } diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/DisableSource.kt b/app/src/main/java/eu/kanade/domain/source/interactor/DisableSource.kt new file mode 100644 index 000000000..336be8b99 --- /dev/null +++ b/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() + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt b/app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt new file mode 100644 index 000000000..980738794 --- /dev/null +++ b/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> { + 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 = setOf(), + val enabledSources: Set = setOf(), + val disabledSources: Set = setOf(), + val lastUsedSource: Long? = null +) diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSourcePin.kt b/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSourcePin.kt new file mode 100644 index 000000000..5a5e92df4 --- /dev/null +++ b/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() + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/model/Source.kt b/app/src/main/java/eu/kanade/domain/source/model/Source.kt new file mode 100644 index 000000000..4878ada02 --- /dev/null +++ b/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().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) + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/repository/SourceRepository.kt b/app/src/main/java/eu/kanade/domain/source/repository/SourceRepository.kt new file mode 100644 index 000000000..dc139e93e --- /dev/null +++ b/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> +} diff --git a/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt b/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt new file mode 100644 index 000000000..5621b73a3 --- /dev/null +++ b/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, + onClickItem: (Source) -> Unit, + onClickDisable: (Source) -> Unit, + onClickLatest: (Source) -> Unit, + onClickPin: (Source) -> Unit, +) { + val (sourceState, setSourceState) = remember { mutableStateOf(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 = {}, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt b/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt index adb6644d2..1ce2b579e 100644 --- a/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt +++ b/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( diff --git a/app/src/main/java/eu/kanade/presentation/theme/Typography.kt b/app/src/main/java/eu/kanade/presentation/theme/Typography.kt new file mode 100644 index 000000000..74bd03fdf --- /dev/null +++ b/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 + ) + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 82135985c..678be90e1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index 61444da11..b9ba0c80c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index 5172dca91..551aa920f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/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() private val stubSourcesMap = mutableMapOf() + private val _catalogueSources: MutableStateFlow> = MutableStateFlow(listOf()) + val catalogueSources: Flow> = _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() + } } private fun createInternalSources(): List = listOf( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt index 8e2c9ed2e..cfe61a12e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt +++ b/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 @Composable abstract fun ComposeContent(nestedScrollInterop: NestedScrollConnection) } + +abstract class SearchableComposeController

> : SearchableNucleusController() { + + 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) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangHolder.kt deleted file mode 100644 index 2a7ce220e..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangHolder.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangItem.kt deleted file mode 100644 index a9d02b014..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangItem.kt +++ /dev/null @@ -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() { - - /** - * 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>): LangHolder { - return LangHolder(view, adapter) - } - - /** - * Binds this item to the given view holder. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: LangHolder, - position: Int, - payloads: MutableList, - ) { - holder.bind(this) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceAdapter.kt deleted file mode 100644 index 66d3e7572..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceAdapter.kt +++ /dev/null @@ -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>(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt index 2afcda1e8..824f0670a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt +++ b/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(), - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - SourceAdapter.OnSourceClickListener { +class SourceController : SearchableComposeController() { 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 getTitle(): String? = + resources?.getString(R.string.label_sources) - override fun createPresenter(): SourcePresenter { - return SourcePresenter() - } + override fun createPresenter(): 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) { 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>) { - 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 Unit>> - - constructor(source: String, items: List 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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt deleted file mode 100644 index db0678a58..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt +++ /dev/null @@ -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) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt deleted file mode 100644 index bb0f45fab..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt +++ /dev/null @@ -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(header) { - - override fun getLayoutRes(): Int { - return R.layout.source_main_controller_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): SourceHolder { - return SourceHolder(view, adapter as SourceAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: SourceHolder, - position: Int, - payloads: MutableList, - ) { - 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 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt index 40858b052..2aed114eb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt +++ b/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,87 +23,68 @@ 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() { - var sources = getEnabledSources() + private val _state: MutableStateFlow = MutableStateFlow(SourceState.EMPTY) + val state: StateFlow = _state.asStateFlow() - /** - * Unsubscribe and create a new subscription to fetch enabled sources. - */ - private fun loadSources() { - val pinnedSources = mutableListOf() - 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> { d1, d2 -> + private suspend fun collectLatestSources(sources: List) { + val map = TreeMap> { 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 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 { - 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()}" } + fun togglePin(source: Source) { + toggleSourcePin.await(source) } companion object { @@ -108,3 +92,27 @@ class SourcePresenter( 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, + 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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt index 78c145673..67d76ac94 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt +++ b/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() /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt index 2561eeb57..f065e5b16 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt +++ b/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), ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt index b66a02a0b..6a3b22587 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt +++ b/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 diff --git a/app/src/main/res/drawable/ic_push_pin_24dp.xml b/app/src/main/res/drawable/ic_push_pin_24dp.xml deleted file mode 100644 index 8bc324eed..000000000 --- a/app/src/main/res/drawable/ic_push_pin_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_push_pin_outline_24dp.xml b/app/src/main/res/drawable/ic_push_pin_outline_24dp.xml deleted file mode 100644 index e481d37d6..000000000 --- a/app/src/main/res/drawable/ic_push_pin_outline_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/layout/source_main_controller.xml b/app/src/main/res/layout/source_main_controller.xml deleted file mode 100644 index 64af2528c..000000000 --- a/app/src/main/res/layout/source_main_controller.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/source_main_controller_item.xml b/app/src/main/res/layout/source_main_controller_item.xml index c86b09598..3580d090c 100644 --- a/app/src/main/res/layout/source_main_controller_item.xml +++ b/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" /> -