mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Convert Source tab to use Compose (#6987)
* Use Compose in Source tab * Replace hashCode with key function * Add ability to turn off pins moving on top of source list * Changes from review comments
This commit is contained in:
		
							
								
								
									
										13
									
								
								app/src/main/java/eu/kanade/data/source/SourceMapper.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/src/main/java/eu/kanade/data/source/SourceMapper.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| package eu.kanade.data.source | ||||
|  | ||||
| import eu.kanade.domain.source.model.Source | ||||
| import eu.kanade.tachiyomi.source.CatalogueSource | ||||
|  | ||||
| val sourceMapper: (CatalogueSource) -> Source = { source -> | ||||
|     Source( | ||||
|         source.id, | ||||
|         source.lang, | ||||
|         source.name, | ||||
|         source.supportsLatest | ||||
|     ) | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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()) } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| ) | ||||
| @@ -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
									
								
								app/src/main/java/eu/kanade/domain/source/model/Source.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								app/src/main/java/eu/kanade/domain/source/model/Source.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| package eu.kanade.domain.source.model | ||||
|  | ||||
| import androidx.compose.ui.graphics.ImageBitmap | ||||
| import androidx.compose.ui.graphics.asImageBitmap | ||||
| import androidx.core.graphics.drawable.toBitmap | ||||
| import eu.kanade.tachiyomi.extension.ExtensionManager | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| data class Source( | ||||
|     val id: Long, | ||||
|     val lang: String, | ||||
|     val name: String, | ||||
|     val supportsLatest: Boolean, | ||||
|     val pin: Pins = Pins.unpinned, | ||||
|     val isUsedLast: Boolean = false | ||||
| ) { | ||||
|  | ||||
|     val nameWithLanguage: String | ||||
|         get() = "$name (${lang.uppercase()})" | ||||
|  | ||||
|     val icon: ImageBitmap? | ||||
|         get() { | ||||
|             return Injekt.get<ExtensionManager>().getAppIconForSource(id) | ||||
|                 ?.toBitmap() | ||||
|                 ?.asImageBitmap() | ||||
|         } | ||||
|  | ||||
|     val key: () -> Long = { | ||||
|         when { | ||||
|             isUsedLast -> id shr 16 | ||||
|             Pin.Forced in pin -> id shr 32 | ||||
|             else -> id | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| sealed class Pin(val code: Int) { | ||||
|     object Unpinned : Pin(0b00) | ||||
|     object Pinned : Pin(0b01) | ||||
|     object Actual : Pin(0b10) | ||||
|     object Forced : Pin(0b100) | ||||
| } | ||||
|  | ||||
| inline fun Pins(builder: Pins.PinsBuilder.() -> Unit = {}): Pins { | ||||
|     return Pins.PinsBuilder().apply(builder).flags() | ||||
| } | ||||
|  | ||||
| fun Pins(vararg pins: Pin) = Pins { | ||||
|     pins.forEach { +it } | ||||
| } | ||||
|  | ||||
| data class Pins(val code: Int = Pin.Unpinned.code) { | ||||
|  | ||||
|     operator fun contains(pin: Pin): Boolean = pin.code and code == pin.code | ||||
|  | ||||
|     operator fun plus(pin: Pin): Pins = Pins(code or pin.code) | ||||
|  | ||||
|     operator fun minus(pin: Pin): Pins = Pins(code xor pin.code) | ||||
|  | ||||
|     companion object { | ||||
|         val unpinned = Pins(Pin.Unpinned) | ||||
|  | ||||
|         val pinned = Pins(Pin.Pinned, Pin.Actual) | ||||
|     } | ||||
|  | ||||
|     class PinsBuilder(var code: Int = 0) { | ||||
|         operator fun Pin.unaryPlus() { | ||||
|             this@PinsBuilder.code = code or this@PinsBuilder.code | ||||
|         } | ||||
|  | ||||
|         operator fun Pin.unaryMinus() { | ||||
|             this@PinsBuilder.code = code or this@PinsBuilder.code | ||||
|         } | ||||
|  | ||||
|         fun flags(): Pins = Pins(code) | ||||
|     } | ||||
| } | ||||
| @@ -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
									
								
								app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										282
									
								
								app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,282 @@ | ||||
| package eu.kanade.presentation.source | ||||
|  | ||||
| import androidx.compose.foundation.Image | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.combinedClickable | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.WindowInsets | ||||
| import androidx.compose.foundation.layout.asPaddingValues | ||||
| import androidx.compose.foundation.layout.aspectRatio | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.navigationBars | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.foundation.lazy.items | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.PushPin | ||||
| import androidx.compose.material.icons.outlined.PushPin | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.CircularProgressIndicator | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.IconButton | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | ||||
| import androidx.compose.ui.input.nestedscroll.nestedScroll | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.painterResource | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.style.TextOverflow | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.domain.source.model.Pin | ||||
| import eu.kanade.domain.source.model.Source | ||||
| import eu.kanade.presentation.components.EmptyScreen | ||||
| import eu.kanade.presentation.theme.header | ||||
| import eu.kanade.presentation.util.horizontalPadding | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.source.LocalSource | ||||
| import eu.kanade.tachiyomi.ui.browse.source.SourcePresenter | ||||
| import eu.kanade.tachiyomi.ui.browse.source.UiModel | ||||
| import eu.kanade.tachiyomi.util.system.LocaleHelper | ||||
|  | ||||
| @Composable | ||||
| fun SourceScreen( | ||||
|     nestedScrollInterop: NestedScrollConnection, | ||||
|     presenter: SourcePresenter, | ||||
|     onClickItem: (Source) -> Unit, | ||||
|     onClickDisable: (Source) -> Unit, | ||||
|     onClickLatest: (Source) -> Unit, | ||||
|     onClickPin: (Source) -> Unit, | ||||
| ) { | ||||
|     val state by presenter.state.collectAsState() | ||||
|  | ||||
|     when { | ||||
|         state.isLoading -> CircularProgressIndicator() | ||||
|         state.hasError -> Text(text = state.error!!.message!!) | ||||
|         state.isEmpty -> EmptyScreen(message = "") | ||||
|         else -> SourceList( | ||||
|             nestedScrollConnection = nestedScrollInterop, | ||||
|             list = state.sources, | ||||
|             onClickItem = onClickItem, | ||||
|             onClickDisable = onClickDisable, | ||||
|             onClickLatest = onClickLatest, | ||||
|             onClickPin = onClickPin, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun SourceList( | ||||
|     nestedScrollConnection: NestedScrollConnection, | ||||
|     list: List<UiModel>, | ||||
|     onClickItem: (Source) -> Unit, | ||||
|     onClickDisable: (Source) -> Unit, | ||||
|     onClickLatest: (Source) -> Unit, | ||||
|     onClickPin: (Source) -> Unit, | ||||
| ) { | ||||
|     val (sourceState, setSourceState) = remember { mutableStateOf<Source?>(null) } | ||||
|     LazyColumn( | ||||
|         modifier = Modifier | ||||
|             .nestedScroll(nestedScrollConnection), | ||||
|         contentPadding = WindowInsets.navigationBars.asPaddingValues(), | ||||
|     ) { | ||||
|         items( | ||||
|             items = list, | ||||
|             contentType = { | ||||
|                 when (it) { | ||||
|                     is UiModel.Header -> "header" | ||||
|                     is UiModel.Item -> "item" | ||||
|                 } | ||||
|             }, | ||||
|             key = { | ||||
|                 when (it) { | ||||
|                     is UiModel.Header -> it.hashCode() | ||||
|                     is UiModel.Item -> it.source.key() | ||||
|                 } | ||||
|             } | ||||
|         ) { model -> | ||||
|             when (model) { | ||||
|                 is UiModel.Header -> { | ||||
|                     SourceHeader( | ||||
|                         modifier = Modifier.animateItemPlacement(), | ||||
|                         language = model.language | ||||
|                     ) | ||||
|                 } | ||||
|                 is UiModel.Item -> SourceItem( | ||||
|                     modifier = Modifier.animateItemPlacement(), | ||||
|                     item = model.source, | ||||
|                     onClickItem = onClickItem, | ||||
|                     onLongClickItem = { | ||||
|                         setSourceState(it) | ||||
|                     }, | ||||
|                     onClickLatest = onClickLatest, | ||||
|                     onClickPin = onClickPin, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if (sourceState != null) { | ||||
|         SourceOptionsDialog( | ||||
|             source = sourceState, | ||||
|             onClickPin = { | ||||
|                 onClickPin(sourceState) | ||||
|                 setSourceState(null) | ||||
|             }, | ||||
|             onClickDisable = { | ||||
|                 onClickDisable(sourceState) | ||||
|                 setSourceState(null) | ||||
|             }, | ||||
|             onDismiss = { setSourceState(null) } | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun SourceHeader( | ||||
|     modifier: Modifier = Modifier, | ||||
|     language: String | ||||
| ) { | ||||
|     val context = LocalContext.current | ||||
|     Text( | ||||
|         text = LocaleHelper.getSourceDisplayName(language, context), | ||||
|         modifier = modifier | ||||
|             .padding(horizontal = horizontalPadding, vertical = 8.dp), | ||||
|         style = MaterialTheme.typography.header | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun SourceItem( | ||||
|     modifier: Modifier = Modifier, | ||||
|     item: Source, | ||||
|     onClickItem: (Source) -> Unit, | ||||
|     onLongClickItem: (Source) -> Unit, | ||||
|     onClickLatest: (Source) -> Unit, | ||||
|     onClickPin: (Source) -> Unit | ||||
| ) { | ||||
|     Row( | ||||
|         modifier = modifier | ||||
|             .combinedClickable( | ||||
|                 onClick = { onClickItem(item) }, | ||||
|                 onLongClick = { onLongClickItem(item) } | ||||
|             ) | ||||
|             .padding(horizontal = horizontalPadding, vertical = 8.dp), | ||||
|         verticalAlignment = Alignment.CenterVertically, | ||||
|     ) { | ||||
|         SourceIcon(source = item) | ||||
|         Column( | ||||
|             modifier = Modifier | ||||
|                 .padding(horizontal = horizontalPadding) | ||||
|                 .weight(1f) | ||||
|         ) { | ||||
|             Text( | ||||
|                 text = item.name, | ||||
|                 maxLines = 1, | ||||
|                 overflow = TextOverflow.Ellipsis, | ||||
|                 style = MaterialTheme.typography.bodyMedium | ||||
|             ) | ||||
|             Text( | ||||
|                 text = LocaleHelper.getDisplayName(item.lang), | ||||
|                 maxLines = 1, | ||||
|                 overflow = TextOverflow.Ellipsis, | ||||
|                 style = MaterialTheme.typography.bodySmall | ||||
|             ) | ||||
|         } | ||||
|         if (item.supportsLatest) { | ||||
|             TextButton(onClick = { onClickLatest(item) }) { | ||||
|                 Text(text = stringResource(id = R.string.latest)) | ||||
|             } | ||||
|         } | ||||
|         SourcePinButton( | ||||
|             isPinned = Pin.Pinned in item.pin, | ||||
|             onClick = { onClickPin(item) } | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun SourceIcon( | ||||
|     source: Source | ||||
| ) { | ||||
|     val icon = source.icon | ||||
|     val modifier = Modifier | ||||
|         .height(40.dp) | ||||
|         .aspectRatio(1f) | ||||
|     if (icon != null) { | ||||
|         Image( | ||||
|             bitmap = icon, | ||||
|             contentDescription = "", | ||||
|             modifier = modifier, | ||||
|         ) | ||||
|     } else { | ||||
|         Image( | ||||
|             painter = painterResource(id = R.mipmap.ic_local_source), | ||||
|             contentDescription = "", | ||||
|             modifier = modifier, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun SourcePinButton( | ||||
|     isPinned: Boolean, | ||||
|     onClick: () -> Unit | ||||
| ) { | ||||
|     val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin | ||||
|     val tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground | ||||
|     IconButton(onClick = onClick) { | ||||
|         Icon( | ||||
|             imageVector = icon, | ||||
|             contentDescription = "", | ||||
|             tint = tint | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun SourceOptionsDialog( | ||||
|     source: Source, | ||||
|     onClickPin: () -> Unit, | ||||
|     onClickDisable: () -> Unit, | ||||
|     onDismiss: () -> Unit, | ||||
| ) { | ||||
|     AlertDialog( | ||||
|         title = { | ||||
|             Text(text = source.nameWithLanguage) | ||||
|         }, | ||||
|         text = { | ||||
|             Column { | ||||
|                 val textId = if (Pin.Pinned in source.pin) R.string.action_unpin else R.string.action_pin | ||||
|                 Text( | ||||
|                     text = stringResource(id = textId), | ||||
|                     modifier = Modifier | ||||
|                         .clickable(onClick = onClickPin) | ||||
|                         .fillMaxWidth() | ||||
|                         .padding(vertical = 16.dp) | ||||
|                 ) | ||||
|                 if (source.id != LocalSource.ID) { | ||||
|                     Text( | ||||
|                         text = stringResource(id = R.string.action_disable), | ||||
|                         modifier = Modifier | ||||
|                             .clickable(onClick = onClickDisable) | ||||
|                             .fillMaxWidth() | ||||
|                             .padding(vertical = 16.dp) | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         onDismissRequest = onDismiss, | ||||
|         confirmButton = {}, | ||||
|     ) | ||||
| } | ||||
| @@ -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
									
								
								app/src/main/java/eu/kanade/presentation/theme/Typography.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/src/main/java/eu/kanade/presentation/theme/Typography.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| package eu.kanade.presentation.theme | ||||
|  | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Typography | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.text.TextStyle | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
|  | ||||
| val Typography.header: TextStyle | ||||
|     @Composable | ||||
|     get() { | ||||
|         return bodyMedium.copy( | ||||
|             color = MaterialTheme.colorScheme.onSurfaceVariant, | ||||
|             fontWeight = FontWeight.SemiBold | ||||
|         ) | ||||
|     } | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) } | ||||
|         } | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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 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<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) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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<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 updateSources() { | ||||
|         sources = getEnabledSources() | ||||
|         loadSources() | ||||
|         loadLastUsedSource() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns a list of enabled sources ordered by language and name. | ||||
|      * | ||||
|      * @return list containing enabled sources. | ||||
|      */ | ||||
|     private fun getEnabledSources(): List<CatalogueSource> { | ||||
|         val languages = preferences.enabledLanguages().get() | ||||
|         val disabledSourceIds = preferences.disabledSources().get() | ||||
|  | ||||
|         return sourceManager.getCatalogueSources() | ||||
|             .filter { it.lang in languages || it.id == LocalSource.ID } | ||||
|             .filterNot { it.id.toString() in disabledSourceIds } | ||||
|             .sortedBy { "(${it.lang}) ${it.name.lowercase()}" } | ||||
|     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<UiModel>, | ||||
|     val error: Throwable? | ||||
| ) { | ||||
|  | ||||
|     val isLoading: Boolean | ||||
|         get() = sources.isEmpty() && error == null | ||||
|  | ||||
|     val hasError: Boolean | ||||
|         get() = error != null | ||||
|  | ||||
|     val isEmpty: Boolean | ||||
|         get() = sources.isEmpty() | ||||
|  | ||||
|     companion object { | ||||
|         val EMPTY = SourceState(listOf(), null) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -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), | ||||
|     ) | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user