Replace LiveData with StateFlow

feature/kitsu
Koitharu 3 years ago
parent 47f346b42c
commit 5a0c54e00f
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -8,7 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@ -19,7 +18,8 @@ import org.junit.runner.RunWith
import org.koitharu.kotatsu.SampleData import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.awaitForIdle import org.koitharu.kotatsu.awaitForIdle
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)

@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject

@ -22,8 +22,8 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import javax.inject.Inject import javax.inject.Inject

@ -30,6 +30,8 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.reverseAsync import org.koitharu.kotatsu.core.ui.util.reverseAsync
import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@ -81,8 +83,8 @@ class BookmarksFragment :
binding.recyclerView.addItemDecoration(spacingDecoration) binding.recyclerView.addItemDecoration(spacingDecoration)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone)
} }
override fun onDestroyView() { override fun onDestroyView() {

@ -1,18 +1,21 @@
package org.koitharu.kotatsu.bookmarks.ui package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
@ -25,9 +28,9 @@ class BookmarksViewModel @Inject constructor(
private val repository: BookmarksRepository, private val repository: BookmarksRepository,
) : BaseViewModel() { ) : BaseViewModel() {
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
val content: LiveData<List<ListModel>> = repository.observeBookmarks() val content: StateFlow<List<ListModel>> = repository.observeBookmarks()
.map { list -> .map { list ->
if (list.isEmpty()) { if (list.isEmpty()) {
listOf( listOf(
@ -43,12 +46,12 @@ class BookmarksViewModel @Inject constructor(
} }
} }
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) } .catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun removeBookmarks(ids: Map<Manga, Set<Long>>) { fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = repository.removeBookmarks(ids) val handle = repository.removeBookmarks(ids)
onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle)) onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle))
} }
} }
} }

@ -42,9 +42,9 @@ import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher

@ -22,10 +22,7 @@ class DialogErrorObserver(
fragment: Fragment?, fragment: Fragment?,
) : this(host, fragment, null, null) ) : this(host, fragment, null, null)
override fun onChanged(value: Throwable?) { override suspend fun emit(value: Throwable) {
if (value == null) {
return
}
val listener = DialogListener(value) val listener = DialogListener(value)
val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context) val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context)
.setMessage(value.getDisplayMessage(host.context.resources)) .setMessage(value.getDisplayMessage(host.context.resources))

@ -7,8 +7,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.findActivity
@ -19,7 +19,7 @@ abstract class ErrorObserver(
protected val fragment: Fragment?, protected val fragment: Fragment?,
private val resolver: ExceptionResolver?, private val resolver: ExceptionResolver?,
private val onResolved: Consumer<Boolean>?, private val onResolved: Consumer<Boolean>?,
) : Observer<Throwable?> { ) : FlowCollector<Throwable> {
protected val activity = host.context.findActivity() protected val activity = host.context.findActivity()

@ -22,10 +22,7 @@ class SnackbarErrorObserver(
fragment: Fragment?, fragment: Fragment?,
) : this(host, fragment, null, null) ) : this(host, fragment, null, null)
override fun onChanged(value: Throwable?) { override suspend fun emit(value: Throwable) {
if (value == null) {
return
}
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT) val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
if (activity is BottomNavOwner) { if (activity is BottomNavOwner) {
snackbar.anchorView = activity.bottomNav snackbar.anchorView = activity.bottomNav

@ -24,6 +24,10 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
return acc.values.max() return acc.values.max()
} }
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.find { it.id == id }
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? { fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters val ch = chapters
if (ch.isNullOrEmpty()) { if (ch.isNullOrEmpty()) {

@ -27,7 +27,7 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity

@ -1,37 +1,24 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt
@Reusable @Reusable
class MangaDataRepository @Inject constructor( class MangaDataRepository @Inject constructor(
@ -104,67 +91,10 @@ class MangaDataRepository @Inject constructor(
} }
} }
/**
* Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide
*/
suspend fun determineMangaIsWebtoon(repository: MangaRepository, pages: List<MangaPage>): Boolean {
val pageIndex = (pages.size * 0.3).roundToInt()
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
val url = repository.getPageUrl(page)
val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") {
runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
}
}
} else {
val request = Request.Builder()
.url(url)
.get()
.tag(MangaSource::class.java, page.source)
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.build()
okHttpClient.newCall(request).await().use {
runInterruptible(Dispatchers.IO) {
getBitmapSize(it.body?.byteStream())
}
}
}
return size.width * MIN_WEBTOON_RATIO < size.height
}
private fun newEntity(mangaId: Long) = MangaPrefsEntity( private fun newEntity(mangaId: Long) = MangaPrefsEntity(
mangaId = mangaId, mangaId = mangaId,
mode = -1, mode = -1,
cfBrightness = 0f, cfBrightness = 0f,
cfContrast = 0f, cfContrast = 0f,
) )
companion object {
private const val MIN_WEBTOON_RATIO = 2
suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.path, options)?.recycle()
options.outMimeType
}
private fun getBitmapSize(input: InputStream?): Size {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(input, null, options)?.recycle()
val imageHeight: Int = options.outHeight
val imageWidth: Int = options.outWidth
check(imageHeight > 0 && imageWidth > 0)
return Size(imageWidth, imageHeight)
}
}
} }

@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter

@ -25,7 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.ShelfSection import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
import java.io.File import java.io.File
import java.net.Proxy import java.net.Proxy
import java.util.Collections import java.util.Collections

@ -1,13 +1,11 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import kotlin.coroutines.CoroutineContext
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer() var lastValue: T = valueProducer()
@ -23,25 +21,9 @@ fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() ->
} }
} }
fun <T> AppSettings.observeAsLiveData(
context: CoroutineContext,
key: String,
valueProducer: AppSettings.() -> T,
) = liveData(context) {
emit(valueProducer())
observe().collect {
if (it == key) {
val value = valueProducer()
if (value != latestValue) {
emit(value)
}
}
}
}
fun <T> AppSettings.observeAsStateFlow( fun <T> AppSettings.observeAsStateFlow(
key: String,
scope: CoroutineScope, scope: CoroutineScope,
key: String,
valueProducer: AppSettings.() -> T, valueProducer: AppSettings.() -> T,
): StateFlow<T> = observe().transform { ): StateFlow<T> = observe().transform {
if (it == key) { if (it == key) {

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -8,9 +7,16 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.ui.util.CountedBooleanLiveData import org.koitharu.kotatsu.core.util.ext.EventFlow
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
@ -18,16 +24,17 @@ import kotlin.coroutines.EmptyCoroutineContext
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
@JvmField @JvmField
protected val loadingCounter = CountedBooleanLiveData() protected val loadingCounter = MutableStateFlow(0)
@JvmField @JvmField
protected val errorEvent = SingleLiveEvent<Throwable>() protected val errorEvent = MutableEventFlow<Throwable>()
val onError: LiveData<Throwable> val onError: EventFlow<Throwable>
get() = errorEvent get() = errorEvent
val isLoading: LiveData<Boolean> val isLoading: StateFlow<Boolean>
get() = loadingCounter get() = loadingCounter.map { it > 0 }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
protected fun launchJob( protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
@ -51,7 +58,11 @@ abstract class BaseViewModel : ViewModel() {
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug() throwable.printStackTraceDebug()
if (throwable !is CancellationException) { if (throwable !is CancellationException) {
errorEvent.postCall(throwable) errorEvent.call(throwable)
} }
} }
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
} }

@ -1,31 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import androidx.annotation.AnyThread
import androidx.lifecycle.LiveData
import java.util.concurrent.atomic.AtomicInteger
class CountedBooleanLiveData : LiveData<Boolean>(false) {
private val counter = AtomicInteger(0)
@AnyThread
fun increment() {
if (counter.getAndIncrement() == 0) {
postValue(true)
}
}
@AnyThread
fun decrement() {
if (counter.decrementAndGet() == 0) {
postValue(false)
}
}
@AnyThread
fun reset() {
if (counter.getAndSet(0) != 0) {
postValue(false)
}
}
}

@ -1,18 +1,15 @@
package org.koitharu.kotatsu.core.ui.util package org.koitharu.kotatsu.core.ui.util
import android.view.View import android.view.View
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
class ReversibleActionObserver( class ReversibleActionObserver(
private val snackbarHost: View, private val snackbarHost: View,
) : Observer<ReversibleAction?> { ) : FlowCollector<ReversibleAction> {
override fun onChanged(value: ReversibleAction?) { override suspend fun emit(value: ReversibleAction) {
if (value == null) {
return
}
val handle = value.handle val handle = value.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length) val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)

@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.util
import kotlinx.coroutines.flow.FlowCollector
class Event<T>(
private val data: T,
) {
private var isConsumed = false
suspend fun consume(collector: FlowCollector<T>) {
if (isConsumed) {
collector.emit(data)
isConsumed = true
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Event<*>
if (data != other.data) return false
return isConsumed == other.isConsumed
}
override fun hashCode(): Int {
var result = data?.hashCode() ?: 0
result = 31 * result + isConsumed.hashCode()
return result
}
override fun toString(): String {
return "Event(data=$data, isConsumed=$isConsumed)"
}
}

@ -1,86 +0,0 @@
package org.koitharu.kotatsu.core.util
import androidx.lifecycle.LiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
private const val DEFAULT_TIMEOUT = 5_000L
/**
* Similar to a CoroutineLiveData but optimized for using within infinite flows
*/
class FlowLiveData<T>(
private val flow: Flow<T>,
defaultValue: T,
context: CoroutineContext = EmptyCoroutineContext,
private val timeoutInMs: Long = DEFAULT_TIMEOUT,
) : LiveData<T>(defaultValue) {
private val scope = CoroutineScope(Dispatchers.Main.immediate + context + SupervisorJob(context[Job]))
private var job: Job? = null
private var cancellationJob: Job? = null
override fun onActive() {
super.onActive()
cancellationJob?.cancel()
cancellationJob = null
if (job?.isActive == true) {
return
}
job = scope.launch {
flow.collect(Collector())
}
}
override fun onInactive() {
super.onInactive()
cancellationJob?.cancel()
cancellationJob = scope.launch(Dispatchers.Main.immediate) {
delay(timeoutInMs)
if (!hasActiveObservers()) {
job?.cancel()
job = null
}
}
}
private inner class Collector : FlowCollector<T> {
private var previousValue: Any? = value
private val dispatcher = Dispatchers.Main.immediate
override suspend fun emit(value: T) {
if (previousValue != value) {
previousValue = value
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
withContext(dispatcher) {
setValue(value)
}
} else {
setValue(value)
}
}
}
}
}
fun <T> Flow<T>.asFlowLiveData(
context: CoroutineContext = EmptyCoroutineContext,
defaultValue: T,
timeoutInMs: Long = DEFAULT_TIMEOUT,
): LiveData<T> = FlowLiveData(this, defaultValue, context, timeoutInMs)
fun <T> StateFlow<T>.asFlowLiveData(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT,
): LiveData<T> = FlowLiveData(this, value, context, timeoutInMs)

@ -1,50 +0,0 @@
package org.koitharu.kotatsu.core.util
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.EmptyCoroutineContext
class SingleLiveEvent<T> : LiveData<T>() {
private val pending = AtomicBoolean(false)
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
super.observe(owner) {
if (pending.compareAndSet(true, false)) {
observer.onChanged(it)
}
}
}
override fun setValue(value: T) {
pending.set(true)
super.setValue(value)
}
@MainThread
fun call(newValue: T) {
setValue(newValue)
}
@AnyThread
fun postCall(newValue: T) {
postValue(newValue)
}
suspend fun emitCall(newValue: T) {
val dispatcher = Dispatchers.Main.immediate
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
withContext(dispatcher) {
setValue(newValue)
}
} else {
setValue(newValue)
}
}
}

@ -0,0 +1,18 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.annotation.AnyThread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.core.util.Event
@Suppress("FunctionName")
fun <T> MutableEventFlow() = MutableStateFlow<Event<T>?>(null)
typealias EventFlow<T> = StateFlow<Event<T>?>
typealias MutableEventFlow<T> = MutableStateFlow<Event<T>?>
@AnyThread
fun <T> MutableEventFlow<T>.call(data: T) {
value = Event(data)
}

@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import org.koitharu.kotatsu.R
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> { fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
var isFirstCall = true var isFirstCall = true
@ -52,3 +53,12 @@ fun <T> Flow<Collection<T>>.flatten(): Flow<T> = flow {
} }
} }
} }
fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
var previous: T? = null
collect { value ->
val result = previous to value
previous = value
emit(result)
}
}

@ -0,0 +1,35 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.Event
fun <T> Flow<T>.observe(owner: LifecycleOwner, collector: FlowCollector<T>) {
if (BuildConfig.DEBUG) {
require((this as? StateFlow)?.value !is Event<*>)
}
val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT
owner.lifecycleScope.launch(start = start) {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect(collector)
}
}
}
fun <T> Flow<Event<T>?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector<T>) {
owner.lifecycleScope.launch {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect {
it?.consume(collector)
}
}
}
}

@ -1,32 +0,0 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.BufferedObserver
import kotlin.coroutines.EmptyCoroutineContext
fun <T> LiveData<T>.requireValue(): T = checkNotNull(value) {
"LiveData value is null"
}
fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver<T>) {
var previous: T? = null
this.observe(owner) {
observer.onChanged(it, previous)
previous = it
}
}
suspend fun <T> MutableLiveData<T>.emitValue(newValue: T) {
val dispatcher = Dispatchers.Main.immediate
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
withContext(dispatcher) {
value = newValue
}
} else {
value = newValue
}
}

@ -6,23 +6,21 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@Deprecated("")
class DetailsInteractor @Inject constructor( class DetailsInteractor @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
@ -56,18 +54,6 @@ class DetailsInteractor @Inject constructor(
} }
} }
suspend fun deleteLocalManga(manga: Manga) {
val victim = if (manga.isLocal) manga else localMangaRepository.findSavedManga(manga)?.manga
checkNotNull(victim) { "Cannot find saved manga for ${manga.title}" }
val original = if (manga.isLocal) localMangaRepository.getRemoteManga(manga) else manga
localMangaRepository.delete(victim) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(victim, original)
}.onFailure {
it.printStackTraceDebug()
}
}
fun observeIncognitoMode(mangaFlow: Flow<Manga?>): Flow<Boolean> { fun observeIncognitoMode(mangaFlow: Flow<Manga?>): Flow<Boolean> {
return mangaFlow return mangaFlow
.distinctUntilChangedBy { it?.isNsfw } .distinctUntilChangedBy { it?.isNsfw }

@ -1,27 +1,26 @@
package org.koitharu.kotatsu.local.domain package org.koitharu.kotatsu.details.domain
import dagger.Reusable
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@Reusable class DoubleMangaLoadUseCase @Inject constructor(
class DoubleMangaLoader @Inject constructor(
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
suspend fun load(manga: Manga): DoubleManga = coroutineScope { suspend operator fun invoke(manga: Manga): DoubleManga = coroutineScope {
val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) } val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) }
val localDeferred = async(Dispatchers.Default) { loadLocal(manga) } val localDeferred = async(Dispatchers.Default) { loadLocal(manga) }
DoubleManga( DoubleManga(
@ -30,14 +29,14 @@ class DoubleMangaLoader @Inject constructor(
) )
} }
suspend fun load(mangaId: Long): DoubleManga { suspend operator fun invoke(mangaId: Long): DoubleManga {
val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE() val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE()
return load(manga) return invoke(manga)
} }
suspend fun load(intent: MangaIntent): DoubleManga { suspend operator fun invoke(intent: MangaIntent): DoubleManga {
val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE() val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE()
return load(manga) return invoke(manga)
} }
private suspend fun loadLocal(manga: Manga): Result<Manga>? { private suspend fun loadLocal(manga: Manga): Result<Manga>? {

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.details.domain.model
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter

@ -10,7 +10,7 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource

@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter

@ -18,10 +18,11 @@ import androidx.core.graphics.Insets
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
@ -32,6 +33,8 @@ import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.util.ViewBadge import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
@ -90,10 +93,10 @@ class DetailsActivity :
ChaptersMenuProvider(viewModel, null) ChaptersMenuProvider(viewModel, null)
} }
viewModel.manga.observe(this, ::onMangaUpdated) viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.onError.observe( viewModel.onError.observeEvent(
this, this,
SnackbarErrorObserver( SnackbarErrorObserver(
host = viewBinding.containerDetails, host = viewBinding.containerDetails,
@ -106,11 +109,11 @@ class DetailsActivity :
}, },
), ),
) )
viewModel.onShowToast.observe(this) { viewModel.onShowToast.observeEvent(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show() makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
} }
viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranchName.observe(this) { viewModel.selectedBranch.observe(this) {
viewBinding.headerChapters?.subtitle = it viewBinding.headerChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it viewBinding.textViewSubtitle?.textAndVisible = it
} }
@ -124,7 +127,7 @@ class DetailsActivity :
viewBinding.buttonDropdown.isVisible = it.size > 1 viewBinding.buttonDropdown.isVisible = it.size > 1
} }
viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(viewBinding.containerDetails)) viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.containerDetails))
addMenuProvider( addMenuProvider(
DetailsMenuProvider( DetailsMenuProvider(
@ -165,12 +168,12 @@ class DetailsActivity :
} }
R.id.action_pages_thumbs -> { R.id.action_pages_thumbs -> {
val history = viewModel.historyInfo.value?.history val history = viewModel.historyInfo.value.history
PagesThumbnailsSheet.show( PagesThumbnailsSheet.show(
fm = supportFragmentManager, fm = supportFragmentManager,
manga = viewModel.manga.value ?: return false, manga = viewModel.manga.value ?: return false,
chapterId = history?.chapterId chapterId = history?.chapterId
?: viewModel.chapters.value?.firstOrNull()?.chapter?.id ?: viewModel.chapters.value.firstOrNull()?.chapter?.id
?: return false, ?: return false,
currentPage = history?.page ?: 0, currentPage = history?.page ?: 0,
) )
@ -253,14 +256,14 @@ class DetailsActivity :
.setCancelable(true) .setCancelable(true)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.translations) .setTitle(R.string.translations)
.setItems(viewModel.branches.value.orEmpty()) .setItems(viewModel.branches.value)
.create() .create()
.also { it.show() } .also { it.show() }
} }
private fun openReader(isIncognitoMode: Boolean) { private fun openReader(isIncognitoMode: Boolean) {
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value?.history?.chapterId val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT) val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
snackbar.show() snackbar.show()
@ -301,11 +304,11 @@ class DetailsActivity :
private class PrefetchObserver( private class PrefetchObserver(
private val context: Context, private val context: Context,
) : Observer<List<ChapterListItem>?> { ) : FlowCollector<List<ChapterListItem>?> {
private var isCalled = false private var isCalled = false
override fun onChanged(value: List<ChapterListItem>?) { override suspend fun emit(value: List<ChapterListItem>?) {
if (value.isNullOrEmpty()) { if (value.isNullOrEmpty()) {
return return
} }

@ -18,6 +18,7 @@ import coil.request.ImageRequest
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
@ -34,6 +35,7 @@ import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
@ -42,7 +44,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -82,7 +84,7 @@ class DetailsFragment :
binding.infoLayout.textViewSource.setOnClickListener(this) binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.filterNotNull().observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)

@ -7,10 +7,7 @@ import android.text.style.ForegroundColorSpan
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.getSpans import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -18,9 +15,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -31,27 +28,28 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DoubleMangaLoader import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
@ -69,7 +67,8 @@ class DetailsViewModel @Inject constructor(
private val downloadScheduler: DownloadWorker.Scheduler, private val downloadScheduler: DownloadWorker.Scheduler,
private val interactor: DetailsInteractor, private val interactor: DetailsInteractor,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val mangaLoader: DoubleMangaLoader, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
) : BaseViewModel() { ) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle) private val intent = MangaIntent(savedStateHandle)
@ -77,47 +76,46 @@ class DetailsViewModel @Inject constructor(
private val doubleManga: MutableStateFlow<DoubleManga?> = MutableStateFlow(intent.manga?.let { DoubleManga(it) }) private val doubleManga: MutableStateFlow<DoubleManga?> = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private var loadingJob: Job private var loadingJob: Job
val onShowToast = SingleLiveEvent<Int>() val onShowToast = MutableEventFlow<Int>()
val onDownloadStarted = SingleLiveEvent<Unit>() val onDownloadStarted = MutableEventFlow<Unit>()
private val mangaData = doubleManga.map { it?.any } val manga = doubleManga.map { it?.any }
.stateIn(viewModelScope, SharingStarted.Eagerly, doubleManga.value?.any) .stateIn(viewModelScope, SharingStarted.Eagerly, doubleManga.value?.any)
private val history = historyRepository.observeOne(mangaId) val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val favourite = interactor.observeIsFavourite(mangaId) val favouriteCategories = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = interactor.observeNewChapters(mangaId) val newChaptersCount = interactor.observeNewChapters(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("") private val chaptersQuery = MutableStateFlow("")
private val selectedBranch = MutableStateFlow<String?>(null) val selectedBranch = MutableStateFlow<String?>(null)
private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse } val isChaptersReversed = settings.observeAsStateFlow(
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_REVERSE_CHAPTERS,
val manga = mangaData.filterNotNull().asLiveData(viewModelScope.coroutineContext) valueProducer = { chaptersReverse },
val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext) )
val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext)
val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
val historyInfo: LiveData<HistoryInfo> = combine( val historyInfo: StateFlow<HistoryInfo> = combine(
mangaData, manga,
selectedBranch, selectedBranch,
history, history,
interactor.observeIncognitoMode(mangaData), interactor.observeIncognitoMode(manga),
) { m, b, h, im -> ) { m, b, h, im ->
HistoryInfo(m, b, h, im) HistoryInfo(m, b, h, im)
}.asFlowLiveData( }.stateIn(
context = viewModelScope.coroutineContext + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
defaultValue = HistoryInfo(null, null, null, false), started = SharingStarted.Eagerly,
initialValue = HistoryInfo(null, null, null, false),
) )
val bookmarks = mangaData.flatMapLatest { val bookmarks = manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val localSize = doubleManga val localSize = doubleManga
.map { .map {
@ -128,9 +126,9 @@ class DetailsViewModel @Inject constructor(
} else { } else {
0L 0L
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, 0) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0)
val description = mangaData val description = manga
.distinctUntilChangedBy { it?.description.orEmpty() } .distinctUntilChangedBy { it?.description.orEmpty() }
.transformLatest { .transformLatest {
val description = it?.description val description = it?.description
@ -140,16 +138,16 @@ class DetailsViewModel @Inject constructor(
emit(description.parseAsHtml().filterSpans()) emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans()) emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null)
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = MutableEventFlow<Manga>()
val isScrobblingAvailable: Boolean val isScrobblingAvailable: Boolean
get() = scrobblers.any { it.isAvailable } get() = scrobblers.any { it.isAvailable }
val scrobblingInfo: LiveData<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId) val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val branches: LiveData<List<MangaBranch>> = combine( val branches: StateFlow<List<MangaBranch>> = combine(
doubleManga, doubleManga,
selectedBranch, selectedBranch,
) { m, b -> ) { m, b ->
@ -158,32 +156,29 @@ class DetailsViewModel @Inject constructor(
chapters.groupBy { x -> x.branch } chapters.groupBy { x -> x.branch }
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) } .map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator()) .sortedWith(BranchComparator())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val selectedBranchName = selectedBranch
.asFlowLiveData(viewModelScope.coroutineContext, null)
val isChaptersEmpty: LiveData<Boolean> = combine( val isChaptersEmpty: StateFlow<Boolean> = combine(
doubleManga, doubleManga,
isLoading.asFlow(), isLoading,
) { manga, loading -> ) { manga, loading ->
manga?.any != null && manga.chapters.isNullOrEmpty() && !loading manga?.any != null && manga.chapters.isNullOrEmpty() && !loading
}.asFlowLiveData(viewModelScope.coroutineContext, false) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
val chapters = combine( val chapters = combine(
combine( combine(
doubleManga, doubleManga,
history, history,
selectedBranch, selectedBranch,
newChapters, newChaptersCount,
) { manga, history, branch, news -> ) { manga, history, branch, news ->
mapChapters(manga?.remote, manga?.local, history, news, branch) mapChapters(manga?.remote, manga?.local, history, news, branch)
}, },
chaptersReversed, isChaptersReversed,
chaptersQuery, chaptersQuery,
) { list, reversed, query -> ) { list, reversed, query ->
(if (reversed) list.asReversed() else list).filterSearch(query) (if (reversed) list.asReversed() else list).filterSearch(query)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val selectedBranchValue: String? val selectedBranchValue: String?
get() = selectedBranch.value get() = selectedBranch.value
@ -208,8 +203,8 @@ class DetailsViewModel @Inject constructor(
return return
} }
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
interactor.deleteLocalManga(m) deleteLocalMangaUseCase(m)
onMangaRemoved.emitCall(m) onMangaRemoved.call(m)
} }
} }
@ -276,12 +271,12 @@ class DetailsViewModel @Inject constructor(
doubleManga.requireValue().requireAny(), doubleManga.requireValue().requireAny(),
chaptersIds, chaptersIds,
) )
onDownloadStarted.emitCall(Unit) onDownloadStarted.call(Unit)
} }
} }
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
val result = mangaLoader.load(intent) val result = doubleMangaLoadUseCase(intent)
val manga = result.requireAny() val manga = result.requireAny()
// find default branch // find default branch
val hist = historyRepository.getOne(manga) val hist = historyRepository.getOne(manga)
@ -317,7 +312,7 @@ class DetailsViewModel @Inject constructor(
} }
private fun getScrobbler(index: Int): Scrobbler? { private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value?.getOrNull(index) val info = scrobblingInfo.value.getOrNull(index)
val scrobbler = if (info != null) { val scrobbler = if (info != null) {
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable } scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable }
} else { } else {

@ -21,6 +21,8 @@ import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
@ -59,7 +61,7 @@ class ScrobblingInfoBottomSheet :
override fun onViewBindingCreated(binding: SheetScrobblingBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: SheetScrobblingBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.onError.observe(viewLifecycleOwner) { viewModel.onError.observeEvent(viewLifecycleOwner) {
Toast.makeText(binding.root.context, it.getDisplayMessage(binding.root.resources), Toast.LENGTH_SHORT) Toast.makeText(binding.root.context, it.getDisplayMessage(binding.root.resources), Toast.LENGTH_SHORT)
.show() .show()
} }
@ -105,7 +107,7 @@ class ScrobblingInfoBottomSheet :
when (v.id) { when (v.id) {
R.id.button_menu -> menu?.show() R.id.button_menu -> menu?.show()
R.id.imageView_cover -> { R.id.imageView_cover -> {
val coverUrl = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.coverUrl ?: return val coverUrl = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return
val options = scaleUpActivityOptionsOf(v) val options = scaleUpActivityOptionsOf(v)
startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options.toBundle()) startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options.toBundle())
} }
@ -135,7 +137,7 @@ class ScrobblingInfoBottomSheet :
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.action_browser -> { R.id.action_browser -> {
val url = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.externalUrl ?: return false val url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.externalUrl ?: return false
val intent = Intent(Intent.ACTION_VIEW, url.toUri()) val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity( startActivity(
Intent.createChooser(intent, getString(R.string.open_in_browser)), Intent.createChooser(intent, getString(R.string.open_in_browser)),
@ -149,7 +151,7 @@ class ScrobblingInfoBottomSheet :
R.id.action_edit -> { R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false val manga = viewModel.manga.value ?: return false
val scrobblerService = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.scrobbler val scrobblerService = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.scrobbler
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga, scrobblerService) ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga, scrobblerService)
dismiss() dismiss()
} }

@ -1,8 +1,8 @@
package org.koitharu.kotatsu.download.domain package org.koitharu.kotatsu.download.domain
import androidx.work.Data import androidx.work.Data
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.util.Date

@ -11,14 +11,16 @@ import androidx.annotation.Px
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.PausingReceiver import org.koitharu.kotatsu.download.ui.worker.PausingReceiver
@ -61,8 +63,8 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
viewModel.items.observe(this) { viewModel.items.observe(this) {
downloadsAdapter.items = it downloadsAdapter.items = it
} }
viewModel.onActionDone.observe(this, ReversibleActionObserver(viewBinding.recyclerView)) viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView))
val menuObserver = Observer<Any> { _ -> invalidateOptionsMenu() } val menuObserver = FlowCollector<Any> { _ -> invalidateOptionsMenu() }
viewModel.hasActiveWorks.observe(this, menuObserver) viewModel.hasActiveWorks.observe(this, menuObserver)
viewModel.hasPausedWorks.observe(this, menuObserver) viewModel.hasPausedWorks.observe(this, menuObserver)
viewModel.hasCancellableWorks.observe(this, menuObserver) viewModel.hasCancellableWorks.observe(this, menuObserver)

@ -20,8 +20,8 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
@ -47,23 +47,23 @@ class DownloadsViewModel @Inject constructor(
.mapLatest { it.toDownloadsList() } .mapLatest { it.toDownloadsList() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
val items = works.map { val items = works.map {
it?.toUiList() ?: listOf(LoadingState) it?.toUiList() ?: listOf(LoadingState)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
val hasPausedWorks = works.map { val hasPausedWorks = works.map {
it?.any { x -> x.canResume } == true it?.any { x -> x.canResume } == true
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false)
val hasActiveWorks = works.map { val hasActiveWorks = works.map {
it?.any { x -> x.canPause } == true it?.any { x -> x.canPause } == true
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false)
val hasCancellableWorks = works.map { val hasCancellableWorks = works.map {
it?.any { x -> !x.workState.isFinished } == true it?.any { x -> !x.workState.isFinished } == true
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false)
fun cancel(id: UUID) { fun cancel(id: UUID) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@ -79,14 +79,14 @@ class DownloadsViewModel @Inject constructor(
workScheduler.cancel(work.id) workScheduler.cancel(work.id)
} }
} }
onActionDone.emitCall(ReversibleAction(R.string.downloads_cancelled, null)) onActionDone.call(ReversibleAction(R.string.downloads_cancelled, null))
} }
} }
fun cancelAll() { fun cancelAll() {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
workScheduler.cancelAll() workScheduler.cancelAll()
onActionDone.emitCall(ReversibleAction(R.string.downloads_cancelled, null)) onActionDone.call(ReversibleAction(R.string.downloads_cancelled, null))
} }
} }
@ -146,14 +146,14 @@ class DownloadsViewModel @Inject constructor(
workScheduler.delete(work.id) workScheduler.delete(work.id)
} }
} }
onActionDone.emitCall(ReversibleAction(R.string.downloads_removed, null)) onActionDone.call(ReversibleAction(R.string.downloads_removed, null))
} }
} }
fun removeCompleted() { fun removeCompleted() {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
workScheduler.removeCompleted() workScheduler.removeCompleted()
onActionDone.emitCall(ReversibleAction(R.string.downloads_removed, null)) onActionDone.call(ReversibleAction(R.string.downloads_removed, null))
} }
} }

@ -1,8 +1,8 @@
package org.koitharu.kotatsu.download.ui.worker package org.koitharu.kotatsu.download.ui.worker
import android.view.View import android.view.View
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
@ -10,9 +10,9 @@ import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
class DownloadStartedObserver( class DownloadStartedObserver(
private val snackbarHost: View, private val snackbarHost: View,
) : Observer<Unit> { ) : FlowCollector<Unit> {
override fun onChanged(value: Unit) { override suspend fun emit(value: Unit) {
val snackbar = Snackbar.make(snackbarHost, R.string.download_started, Snackbar.LENGTH_LONG) val snackbar = Snackbar.make(snackbarHost, R.string.download_started, Snackbar.LENGTH_LONG)
(snackbarHost.context.findActivity() as? BottomNavOwner)?.let { (snackbarHost.context.findActivity() as? BottomNavOwner)?.let {
snackbar.anchorView = it.bottomNav snackbar.anchorView = it.bottomNav

@ -48,12 +48,12 @@ import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource

@ -4,7 +4,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.almostEquals import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.core.util.ext.asArrayList import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable

@ -25,6 +25,8 @@ import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
@ -74,9 +76,9 @@ class ExploreFragment :
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
exploreAdapter?.items = it exploreAdapter?.items = it
} }
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
viewModel.onShowSuggestionsTip.observe(viewLifecycleOwner) { viewModel.onShowSuggestionsTip.observe(viewLifecycleOwner) {
showSuggestionsTip() showSuggestionsTip()

@ -1,16 +1,17 @@
package org.koitharu.kotatsu.explore.ui package org.koitharu.kotatsu.explore.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@ -18,8 +19,8 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -34,29 +35,28 @@ class ExploreViewModel @Inject constructor(
private val exploreRepository: ExploreRepository, private val exploreRepository: ExploreRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val gridMode = settings.observeAsStateFlow( val isGrid = settings.observeAsStateFlow(
key = AppSettings.KEY_SOURCES_GRID, key = AppSettings.KEY_SOURCES_GRID,
scope = viewModelScope + Dispatchers.IO, scope = viewModelScope + Dispatchers.IO,
valueProducer = { isSourcesGridMode }, valueProducer = { isSourcesGridMode },
) )
val onOpenManga = SingleLiveEvent<Manga>() val onOpenManga = MutableEventFlow<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
val onShowSuggestionsTip = SingleLiveEvent<Unit>() val onShowSuggestionsTip = MutableEventFlow<Unit>()
val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext)
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading -> val content: StateFlow<List<ExploreItem>> = isLoading.flatMapLatest { loading ->
if (loading) { if (loading) {
flowOf(listOf(ExploreItem.Loading)) flowOf(listOf(ExploreItem.Loading))
} else { } else {
createContentFlow() createContentFlow()
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(ExploreItem.Loading))
init { init {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
if (!settings.isSuggestionsEnabled && settings.isTipEnabled(TIP_SUGGESTIONS)) { if (!settings.isSuggestionsEnabled && settings.isTipEnabled(TIP_SUGGESTIONS)) {
onShowSuggestionsTip.emitCall(Unit) onShowSuggestionsTip.call(Unit)
} }
} }
} }
@ -64,7 +64,7 @@ class ExploreViewModel @Inject constructor(
fun openRandom() { fun openRandom() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = exploreRepository.findRandomManga(tagsLimit = 8) val manga = exploreRepository.findRandomManga(tagsLimit = 8)
onOpenManga.emitCall(manga) onOpenManga.call(manga)
} }
} }
@ -74,7 +74,7 @@ class ExploreViewModel @Inject constructor(
val rollback = ReversibleHandle { val rollback = ReversibleHandle {
settings.hiddenSources -= source.name settings.hiddenSources -= source.name
} }
onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback)) onActionDone.call(ReversibleAction(R.string.source_disabled, rollback))
} }
} }
@ -95,7 +95,7 @@ class ExploreViewModel @Inject constructor(
} }
.onStart { emit("") } .onStart { emit("") }
.map { settings.getMangaSources(includeHidden = false) } .map { settings.getMangaSources(includeHidden = false) }
.combine(gridMode) { content, grid -> buildList(content, grid) } .combine(isGrid) { content, grid -> buildList(content, grid) }
private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> { private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> {
val result = ArrayList<ExploreItem>(sources.size + 3) val result = ArrayList<ExploreItem>(sources.size + 3)

@ -24,6 +24,8 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
@ -71,7 +73,7 @@ class FavouriteCategoriesActivity :
onBackPressedDispatcher.addCallback(exitReorderModeCallback) onBackPressedDispatcher.addCallback(exitReorderModeCallback)
viewModel.detalizedCategories.observe(this, ::onCategoriesChanged) viewModel.detalizedCategories.observe(this, ::onCategoriesChanged)
viewModel.onError.observe(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.isInReorderMode.observe(this, ::onReorderModeChanged) viewModel.isInReorderMode.observe(this, ::onReorderModeChanged)
} }

@ -1,17 +1,17 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
@ -27,23 +27,11 @@ class FavouritesCategoriesViewModel @Inject constructor(
) : BaseViewModel() { ) : BaseViewModel() {
private var reorderJob: Job? = null private var reorderJob: Job? = null
private val isReorder = MutableStateFlow(false) val isInReorderMode = MutableStateFlow(false)
val isInReorderMode = isReorder.asLiveData(viewModelScope.coroutineContext)
val allCategories = repository.observeCategories()
.mapItems {
CategoryListModel(
mangaCount = 0,
covers = listOf(),
category = it,
isReorderMode = false,
)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val detalizedCategories = combine( val detalizedCategories = combine(
repository.observeCategoriesWithCovers(), repository.observeCategoriesWithCovers(),
isReorder, isInReorderMode,
) { list, reordering -> ) { list, reordering ->
list.map { (category, covers) -> list.map { (category, covers) ->
CategoryListModel( CategoryListModel(
@ -62,7 +50,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
), ),
) )
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun deleteCategory(id: Long) { fun deleteCategory(id: Long) {
launchJob { launchJob {
@ -80,12 +68,12 @@ class FavouritesCategoriesViewModel @Inject constructor(
settings.isAllFavouritesVisible = isVisible settings.isAllFavouritesVisible = isVisible
} }
fun isInReorderMode(): Boolean = isReorder.value fun isInReorderMode(): Boolean = isInReorderMode.value
fun isEmpty(): Boolean = detalizedCategories.value?.none { it is CategoryListModel } ?: true fun isEmpty(): Boolean = detalizedCategories.value.none { it is CategoryListModel }
fun setReorderMode(isReorderMode: Boolean) { fun setReorderMode(isReorderMode: Boolean) {
isReorder.value = isReorderMode isInReorderMode.value = isReorderMode
} }
fun reorderCategories(oldPos: Int, newPos: Int) { fun reorderCategories(oldPos: Int, newPos: Int) {

@ -22,6 +22,8 @@ import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getSerializableCompat import org.koitharu.kotatsu.core.util.ext.getSerializableCompat
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
@ -50,10 +52,10 @@ class FavouritesCategoryEditActivity :
viewBinding.editName.addTextChangedListener(this) viewBinding.editName.addTextChangedListener(this)
afterTextChanged(viewBinding.editName.text) afterTextChanged(viewBinding.editName.text)
viewModel.onSaved.observe(this) { finishAfterTransition() } viewModel.onSaved.observeEvent(this) { finishAfterTransition() }
viewModel.category.observe(this, ::onCategoryChanged) viewModel.category.observe(this, ::onCategoryChanged)
viewModel.isLoading.observe(this, ::onLoadingStateChanged) viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.onError.observe(this, ::onError) viewModel.onError.observeEvent(this, ::onError)
viewModel.isTrackerEnabled.observe(this) { viewModel.isTrackerEnabled.observe(this) {
viewBinding.switchTracker.isVisible = it viewBinding.switchTracker.isVisible = it
} }

@ -1,16 +1,19 @@
package org.koitharu.kotatsu.favourites.ui.categories.edit package org.koitharu.kotatsu.favourites.ui.categories.edit
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.emitValue import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.EXTRA_ID import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.EXTRA_ID
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID
@ -26,22 +29,20 @@ class FavouritesCategoryEditViewModel @Inject constructor(
private val categoryId = savedStateHandle[EXTRA_ID] ?: NO_ID private val categoryId = savedStateHandle[EXTRA_ID] ?: NO_ID
val onSaved = SingleLiveEvent<Unit>() val onSaved = MutableEventFlow<Unit>()
val category = MutableLiveData<FavouriteCategory?>() val category = MutableStateFlow<FavouriteCategory?>(null)
val isTrackerEnabled = liveData(viewModelScope.coroutineContext + Dispatchers.Default) { val isTrackerEnabled = flow {
emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources) emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources)
} }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
init { init {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
category.emitValue( category.value = if (categoryId != NO_ID) {
if (categoryId != NO_ID) {
repository.getCategory(categoryId) repository.getCategory(categoryId)
} else { } else {
null null
}, }
)
} }
} }
@ -58,7 +59,7 @@ class FavouritesCategoryEditViewModel @Inject constructor(
} else { } else {
repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf) repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf)
} }
onSaved.emitCall(Unit) onSaved.call(Unit)
} }
} }
} }

@ -15,6 +15,8 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseBottomSheet import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
@ -46,7 +48,7 @@ class FavouriteCategoriesBottomSheet :
binding.headerBar.toolbar.setOnMenuItemClickListener(this) binding.headerBar.toolbar.setOnMenuItemClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observeEvent(viewLifecycleOwner, ::onError)
} }
override fun onDestroyView() { override fun onDestroyView() {

@ -4,11 +4,13 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet.Companion.KEY_MANGA_LIST import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
@ -33,7 +35,7 @@ class MangaCategoriesViewModel @Inject constructor(
isChecked = it.id in checked, isChecked = it.id in checked,
) )
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
fun setChecked(categoryId: Long, isChecked: Boolean) { fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {

@ -12,6 +12,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity

@ -1,25 +1,28 @@
package org.koitharu.kotatsu.favourites.ui.list package org.koitharu.kotatsu.favourites.ui.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@ -43,12 +46,12 @@ class FavouritesListViewModel @Inject constructor(
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
val sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) { val sortOrder: StateFlow<SortOrder?> = if (categoryId == NO_ID) {
MutableLiveData(null) MutableStateFlow(null)
} else { } else {
repository.observeCategory(categoryId) repository.observeCategory(categoryId)
.map { it?.order } .map { it?.order }
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
} }
override val content = combine( override val content = combine(
@ -57,7 +60,7 @@ class FavouritesListViewModel @Inject constructor(
} else { } else {
repository.observeAll(categoryId) repository.observeAll(categoryId)
}, },
listModeFlow, listMode,
) { list, mode -> ) { list, mode ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
@ -77,7 +80,7 @@ class FavouritesListViewModel @Inject constructor(
} }
}.catch { }.catch {
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
override fun onRefresh() = Unit override fun onRefresh() = Unit
@ -93,7 +96,7 @@ class FavouritesListViewModel @Inject constructor(
} else { } else {
repository.removeFromCategory(categoryId, ids) repository.removeFromCategory(categoryId, ids)
} }
onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle)) onActionDone.call(ReversibleAction(R.string.removed_from_favourites, handle))
} }
} }

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.history.domain package org.koitharu.kotatsu.history.data
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
@ -17,8 +17,7 @@ import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler

@ -0,0 +1,38 @@
package org.koitharu.kotatsu.history.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
class HistoryUpdateUseCase @Inject constructor(
private val historyRepository: HistoryRepository,
) {
suspend operator fun invoke(manga: Manga, readerState: ReaderState, percent: Float) {
historyRepository.addOrUpdate(
manga = manga,
chapterId = readerState.chapterId,
page = readerState.page,
scroll = readerState.scroll,
percent = percent,
)
}
fun invokeAsync(
manga: Manga,
readerState: ReaderState,
percent: Float
) = processLifecycleScope.launch(Dispatchers.Default) {
runCatchingCancellable {
invoke(manga, readerState, percent)
}.onFailure {
it.printStackTraceDebug()
}
}
}

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.history.domain package org.koitharu.kotatsu.history.domain.model
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga

@ -9,6 +9,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource

@ -1,28 +1,28 @@
package org.koitharu.kotatsu.history.ui package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.core.util.ext.emitValue
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@ -45,15 +45,16 @@ class HistoryListViewModel @Inject constructor(
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, downloadScheduler) {
val isGroupingEnabled = MutableLiveData<Boolean>() val isGroupingEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled } key = AppSettings.KEY_HISTORY_GROUPING,
.onEach { isGroupingEnabled.emitValue(it) } valueProducer = { isHistoryGroupingEnabled },
)
override val content = combine( override val content = combine(
repository.observeAllWithHistory(), repository.observeAllWithHistory(),
historyGrouping, isGroupingEnabled,
listModeFlow, listMode,
) { list, grouped, mode -> ) { list, grouped, mode ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
@ -73,7 +74,7 @@ class HistoryListViewModel @Inject constructor(
loadingCounter.decrement() loadingCounter.decrement()
}.catch { }.catch {
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
override fun onRefresh() = Unit override fun onRefresh() = Unit
@ -91,7 +92,7 @@ class HistoryListViewModel @Inject constructor(
} }
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = repository.delete(ids) val handle = repository.delete(ids)
onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle)) onActionDone.call(ReversibleAction(R.string.removed_from_history, handle))
} }
} }

@ -13,7 +13,7 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.scale import org.koitharu.kotatsu.core.util.ext.scale
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
class ReadingProgressDrawable( class ReadingProgressDrawable(
context: Context, context: Context,

@ -12,7 +12,7 @@ import androidx.annotation.AttrRes
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
class ReadingProgressView @JvmOverloads constructor( class ReadingProgressView @JvmOverloads constructor(
context: Context, context: Context,

@ -37,6 +37,8 @@ import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.clearItemDecorations import org.koitharu.kotatsu.core.util.ext.clearItemDecorations
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
@ -123,9 +125,9 @@ abstract class MangaListFragment :
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.onDownloadStarted.observe(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView)) viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView))
} }
override fun onDestroyView() { override fun onDestroyView() {

@ -1,39 +1,38 @@
package org.koitharu.kotatsu.list.ui package org.koitharu.kotatsu.list.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
abstract class MangaListViewModel( abstract class MangaListViewModel(
private val settings: AppSettings, settings: AppSettings,
private val downloadScheduler: DownloadWorker.Scheduler, private val downloadScheduler: DownloadWorker.Scheduler,
) : BaseViewModel() { ) : BaseViewModel() {
abstract val content: LiveData<List<ListModel>> abstract val content: StateFlow<List<ListModel>>
protected val listModeFlow = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, settings.listMode) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.listMode)
val listMode = listModeFlow.asFlowLiveData(viewModelScope.coroutineContext) val onActionDone = MutableEventFlow<ReversibleAction>()
val onActionDone = SingleLiveEvent<ReversibleAction>() val gridScale = settings.observeAsStateFlow(
val gridScale = settings.observeAsLiveData( scope = viewModelScope + Dispatchers.Default,
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_GRID_SIZE, key = AppSettings.KEY_GRID_SIZE,
valueProducer = { gridSize / 100f }, valueProducer = { gridSize / 100f },
) )
val onDownloadStarted = SingleLiveEvent<Unit>() val onDownloadStarted = MutableEventFlow<Unit>()
open fun onUpdateFilter(tags: Set<MangaTag>) = Unit open fun onUpdateFilter(tags: Set<MangaTag>) = Unit
@ -44,7 +43,7 @@ abstract class MangaListViewModel(
fun download(items: Set<Manga>) { fun download(items: Set<Manga>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
downloadScheduler.schedule(items) downloadScheduler.schedule(items)
onDownloadStarted.emitCall(Unit) onDownloadStarted.call(Unit)
} }
} }
} }

@ -12,7 +12,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel

@ -16,7 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag

@ -11,6 +11,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseBottomSheet import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentFragmentViewModels import org.koitharu.kotatsu.core.util.ext.parentFragmentViewModels
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel

@ -1,22 +1,25 @@
package org.koitharu.kotatsu.list.ui.filter package org.koitharu.kotatsu.list.ui.filter
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.text.Collator import java.text.Collator
@ -31,13 +34,13 @@ class FilterCoordinator(
private val currentState = MutableStateFlow(FilterState(repository.defaultSortOrder, emptySet())) private val currentState = MutableStateFlow(FilterState(repository.defaultSortOrder, emptySet()))
private var searchQuery = MutableStateFlow("") private var searchQuery = MutableStateFlow("")
private val localTagsDeferred = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { private val localTags = SuspendLazy {
dataRepository.findTags(repository.source) dataRepository.findTags(repository.source)
} }
private var availableTagsDeferred = loadTagsAsync() private var availableTagsDeferred = loadTagsAsync()
val items: LiveData<List<FilterItem>> = getItemsFlow() val items: StateFlow<List<FilterItem>> = getItemsFlow()
.asFlowLiveData(coroutineScope.coroutineContext + Dispatchers.Default, listOf(FilterItem.Loading)) .stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(FilterItem.Loading))
init { init {
observeState() observeState()
@ -97,7 +100,7 @@ class FilterCoordinator(
} }
private fun getTagsAsFlow() = flow { private fun getTagsAsFlow() = flow {
val localTags = localTagsDeferred.await() val localTags = localTags.get()
emit(TagsWrapper(localTags, isLoading = true, isError = false)) emit(TagsWrapper(localTags, isLoading = true, isError = false))
val remoteTags = tryLoadTags() val remoteTags = tryLoadTags()
if (remoteTags == null) { if (remoteTags == null) {

@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.ifZero import org.koitharu.kotatsu.core.util.ext.ifZero
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.local.domain package org.koitharu.kotatsu.local.data
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toFile
@ -15,13 +15,10 @@ import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.CompositeMutex import org.koitharu.kotatsu.core.util.CompositeMutex
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage

@ -16,10 +16,10 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.util.ext.resolveName import org.koitharu.kotatsu.core.util.ext.resolveName
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.domain.model.LocalManga
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject

@ -9,9 +9,9 @@ import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted import org.koitharu.kotatsu.core.util.ext.toListSorted
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.ImageFileFilter import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage

@ -2,7 +2,7 @@ package org.koitharu.kotatsu.local.data.input
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toFile
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage

@ -10,9 +10,9 @@ import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.util.ext.readText
import org.koitharu.kotatsu.core.util.ext.toListSorted import org.koitharu.kotatsu.core.util.ext.toListSorted
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage

@ -0,0 +1,42 @@
package org.koitharu.kotatsu.local.domain
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.IOException
import javax.inject.Inject
class DeleteLocalMangaUseCase @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val historyRepository: HistoryRepository,
) {
suspend operator fun invoke(manga: Manga) {
val victim = if (manga.isLocal) manga else localMangaRepository.findSavedManga(manga)?.manga
checkNotNull(victim) { "Cannot find saved manga for ${manga.title}" }
val original = if (manga.isLocal) localMangaRepository.getRemoteManga(manga) else manga
localMangaRepository.delete(victim) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(victim, original)
}.onFailure {
it.printStackTraceDebug()
}
}
suspend operator fun invoke(ids: Set<Long>) {
val list = localMangaRepository.getList(0, null, null)
var removed = 0
for (manga in list) {
if (manga.id in ids) {
invoke(manga)
removed++
}
}
check(removed == ids.size) {
"Removed $removed files but ${ids.size} requested"
}
}
}

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.domain.model
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
@ -38,9 +38,7 @@ class LocalManga(
other as LocalManga other as LocalManga
if (manga != other.manga) return false if (manga != other.manga) return false
if (file != other.file) return false return file == other.file
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {

@ -15,9 +15,9 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject import javax.inject.Inject

@ -15,6 +15,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@ -26,7 +27,7 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick)) addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() } viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
} }
override fun onEmptyActionClick() { override fun onEmptyActionClick() {

@ -1,7 +1,5 @@
package org.koitharu.kotatsu.local.ui package org.koitharu.kotatsu.local.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -10,18 +8,20 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@ -29,15 +29,14 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.io.IOException
import java.util.LinkedList import java.util.LinkedList
import javax.inject.Inject import javax.inject.Inject
@ -49,11 +48,12 @@ class LocalListViewModel @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val tagHighlighter: MangaTagHighlighter, private val tagHighlighter: MangaTagHighlighter,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider { ) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider {
val onMangaRemoved = SingleLiveEvent<Unit>() val onMangaRemoved = MutableEventFlow<Unit>()
val sortOrder = MutableLiveData(settings.localListOrder) val sortOrder = MutableStateFlow(settings.localListOrder)
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val selectedTags = MutableStateFlow<Set<MangaTag>>(emptySet()) private val selectedTags = MutableStateFlow<Set<MangaTag>>(emptySet())
@ -61,8 +61,8 @@ class LocalListViewModel @Inject constructor(
override val content = combine( override val content = combine(
mangaList, mangaList,
listModeFlow, listMode,
sortOrder.asFlow(), sortOrder,
selectedTags, selectedTags,
listError, listError,
) { list, mode, order, tags, error -> ) { list, mode, order, tags, error ->
@ -83,7 +83,7 @@ class LocalListViewModel @Inject constructor(
list.toUi(this, mode, this@LocalListViewModel, tagHighlighter) list.toUi(this, mode, this@LocalListViewModel, tagHighlighter)
} }
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init { init {
onRefresh() onRefresh()
@ -120,18 +120,8 @@ class LocalListViewModel @Inject constructor(
fun delete(ids: Set<Long>) { fun delete(ids: Set<Long>) {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids } deleteLocalMangaUseCase(ids)
for (manga in itemsToRemove) { onMangaRemoved.call(Unit)
val original = repository.getRemoteManga(manga)
repository.delete(manga) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original)
}
mangaList.update { list ->
list?.filterNot { it.id == manga.id }
}
}
onMangaRemoved.emitCall(Unit)
} }
} }

@ -11,7 +11,7 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@HiltWorker @HiltWorker

@ -45,6 +45,8 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.util.ext.drawableEnd import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.hideKeyboard import org.koitharu.kotatsu.core.util.ext.hideKeyboard
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.resolve import org.koitharu.kotatsu.core.util.ext.resolve
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
@ -137,8 +139,8 @@ class MainActivity :
onFirstStart() onFirstStart()
} }
viewModel.onOpenReader.observe(this, this::onOpenReader) viewModel.onOpenReader.observeEvent(this, this::onOpenReader)
viewModel.onError.observe(this, SnackbarErrorObserver(viewBinding.container, null)) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.container, null))
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged) viewModel.counters.observe(this, ::onCountersChanged)

@ -5,17 +5,20 @@ import androidx.core.util.set
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject import javax.inject.Inject
@ -24,21 +27,25 @@ import javax.inject.Inject
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val appUpdateRepository: AppUpdateRepository, private val appUpdateRepository: AppUpdateRepository,
private val trackingRepository: TrackingRepository, trackingRepository: TrackingRepository,
private val settings: AppSettings, settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
val onOpenReader = SingleLiveEvent<Manga>() val onOpenReader = MutableEventFlow<Manga>()
val isResumeEnabled = combine( val isResumeEnabled = combine(
historyRepository.observeHasItems(), historyRepository.observeHasItems(),
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }, settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
) { hasItems, incognito -> ) { hasItems, incognito ->
hasItems && !incognito hasItems && !incognito
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) }.stateIn(
scope = viewModelScope + Dispatchers.Default,
started = SharingStarted.WhileSubscribed(5000),
initialValue = false,
)
val isFeedAvailable = settings.observeAsLiveData( val isFeedAvailable = settings.observeAsStateFlow(
context = viewModelScope.coroutineContext + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_TRACKER_ENABLED, key = AppSettings.KEY_TRACKER_ENABLED,
valueProducer = { isTrackerEnabled }, valueProducer = { isTrackerEnabled },
) )
@ -51,7 +58,11 @@ class MainViewModel @Inject constructor(
a[R.id.nav_tools] = if (appUpdate != null) 1 else 0 a[R.id.nav_tools] = if (appUpdate != null) 1 else 0
a[R.id.nav_feed] = tracks a[R.id.nav_feed] = tracks
a a
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0)) }.stateIn(
scope = viewModelScope + Dispatchers.Default,
started = SharingStarted.WhileSubscribed(5000),
initialValue = SparseIntArray(0),
)
init { init {
launchJob { launchJob {
@ -62,7 +73,7 @@ class MainViewModel @Inject constructor(
fun openLastReader() { fun openLastReader() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException() val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException()
onOpenReader.emitCall(manga) onOpenReader.call(manga)
} }
} }
} }

@ -22,6 +22,8 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityProtectBinding import org.koitharu.kotatsu.databinding.ActivityProtectBinding
@AndroidEntryPoint @AndroidEntryPoint
@ -42,9 +44,9 @@ class ProtectActivity :
viewBinding.buttonNext.setOnClickListener(this) viewBinding.buttonNext.setOnClickListener(this)
viewBinding.buttonCancel.setOnClickListener(this) viewBinding.buttonCancel.setOnClickListener(this)
viewModel.onError.observe(this, this::onError) viewModel.onError.observeEvent(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onUnlockSuccess.observe(this) { viewModel.onUnlockSuccess.observeEvent(this) {
val intent = intent.getParcelableExtraCompat<Intent>(EXTRA_INTENT) val intent = intent.getParcelableExtraCompat<Intent>(EXTRA_INTENT)
startActivity(intent) startActivity(intent)
finishAfterTransition() finishAfterTransition()

@ -6,7 +6,8 @@ import kotlinx.coroutines.delay
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.parsers.util.md5 import org.koitharu.kotatsu.parsers.util.md5
import javax.inject.Inject import javax.inject.Inject
@ -20,7 +21,7 @@ class ProtectViewModel @Inject constructor(
private var job: Job? = null private var job: Job? = null
val onUnlockSuccess = SingleLiveEvent<Unit>() val onUnlockSuccess = MutableEventFlow<Unit>()
val isBiometricEnabled val isBiometricEnabled
get() = settings.isBiometricProtectionEnabled get() = settings.isBiometricProtectionEnabled

@ -4,8 +4,8 @@ import android.util.LongSparseArray
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject import javax.inject.Inject

@ -0,0 +1,97 @@
package org.koitharu.kotatsu.reader.domain
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.math.roundToInt
class DetectReaderModeUseCase @Inject constructor(
private val dataRepository: MangaDataRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
@MangaHttpClient private val okHttpClient: OkHttpClient,
) {
suspend operator fun invoke(manga: Manga, state: ReaderState?): ReaderMode {
dataRepository.getReaderMode(manga.id)?.let { return it }
val defaultMode = settings.defaultReaderMode
if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) {
return defaultMode
}
val chapter = state?.let { manga.findChapter(it.chapterId) }
?: manga.chapters?.firstOrNull()
?: error("There are no chapters in this manga")
val repo = mangaRepositoryFactory.create(manga.source)
val pages = repo.getPages(chapter)
return runCatchingCancellable {
val isWebtoon = guessMangaIsWebtoon(repo, pages)
if (isWebtoon) ReaderMode.WEBTOON else defaultMode
}.onSuccess {
dataRepository.saveReaderMode(manga, it)
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(defaultMode)
}
/**
* Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide
*/
private suspend fun guessMangaIsWebtoon(repository: MangaRepository, pages: List<MangaPage>): Boolean {
val pageIndex = (pages.size * 0.3).roundToInt()
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
val url = repository.getPageUrl(page)
val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") {
runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
}
}
} else {
val request = PageLoader.createPageRequest(page, url)
okHttpClient.newCall(request).await().use {
runInterruptible(Dispatchers.IO) {
getBitmapSize(it.body?.byteStream())
}
}
}
return size.width * MIN_WEBTOON_RATIO < size.height
}
companion object {
private const val MIN_WEBTOON_RATIO = 1.8
private fun getBitmapSize(input: InputStream?): Size {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(input, null, options)?.recycle()
val imageHeight: Int = options.outHeight
val imageWidth: Int = options.outWidth
check(imageHeight > 0 && imageWidth > 0)
return Size(imageWidth, imageHeight)
}
}
}

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@ -15,7 +16,6 @@ import okio.IOException
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source import okio.source
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
@ -74,7 +74,7 @@ class PageSaveHelper @Inject constructor(
var extension = name.substringAfterLast('.', "") var extension = name.substringAfterLast('.', "")
name = name.substringBeforeLast('.') name = name.substringBeforeLast('.')
if (extension.length !in 2..4) { if (extension.length !in 2..4) {
val mimeType = MangaDataRepository.getImageMimeType(file) val mimeType = getImageMimeType(file)
extension = if (mimeType != null) { extension = if (mimeType != null) {
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK
} else { } else {
@ -83,4 +83,12 @@ class PageSaveHelper @Inject constructor(
} }
return name.toFileNameSafe().take(MAX_FILENAME_LENGTH) + "." + extension return name.toFileNameSafe().take(MAX_FILENAME_LENGTH) + "." + extension
} }
private suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.path, options)?.recycle()
options.outMimeType
}
} }

@ -42,9 +42,11 @@ import org.koitharu.kotatsu.core.util.IdlingDetector
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.hasGlobalPoint import org.koitharu.kotatsu.core.util.ext.hasGlobalPoint
import org.koitharu.kotatsu.core.util.ext.isRtl import org.koitharu.kotatsu.core.util.ext.isRtl
import org.koitharu.kotatsu.core.util.ext.observeWithPrevious import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.postDelayed import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.zipWithPrevious
import org.koitharu.kotatsu.databinding.ActivityReaderBinding import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@ -108,7 +110,7 @@ class ReaderActivity :
insetsDelegate.interceptingWindowInsetsListener = this insetsDelegate.interceptingWindowInsetsListener = this
idlingDetector.bindToLifecycle(this) idlingDetector.bindToLifecycle(this)
viewModel.onError.observe( viewModel.onError.observeEvent(
this, this,
DialogErrorObserver( DialogErrorObserver(
host = viewBinding.container, host = viewBinding.container,
@ -117,23 +119,23 @@ class ReaderActivity :
onResolved = { isResolved -> onResolved = { isResolved ->
if (isResolved) { if (isResolved) {
viewModel.reload() viewModel.reload()
} else if (viewModel.content.value?.pages.isNullOrEmpty()) { } else if (viewModel.content.value.pages.isEmpty()) {
finishAfterTransition() finishAfterTransition()
} }
}, },
), ),
) )
viewModel.readerMode.observe(this, this::onInitReader) viewModel.readerMode.observe(this, this::onInitReader)
viewModel.onPageSaved.observe(this, this::onPageSaved) viewModel.onPageSaved.observeEvent(this, this::onPageSaved)
viewModel.uiState.observeWithPrevious(this, this::onUiStateChanged) viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.content.observe(this) { viewModel.content.observe(this) {
onLoadingStateChanged(viewModel.isLoading.value == true) onLoadingStateChanged(viewModel.isLoading.value)
} }
viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure) viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure)
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged) viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged) viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged)
viewModel.onShowToast.observe(this) { msgId -> viewModel.onShowToast.observeEvent(this) { msgId ->
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT) Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
.setAnchorView(viewBinding.appbarBottom) .setAnchorView(viewBinding.appbarBottom)
.show() .show()
@ -150,7 +152,10 @@ class ReaderActivity :
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
} }
private fun onInitReader(mode: ReaderMode) { private fun onInitReader(mode: ReaderMode?) {
if (mode == null) {
return
}
if (readerManager.currentMode != mode) { if (readerManager.currentMode != mode) {
readerManager.replace(mode) readerManager.replace(mode)
} }
@ -190,7 +195,7 @@ class ReaderActivity :
} }
R.id.action_bookmark -> { R.id.action_bookmark -> {
if (viewModel.isBookmarkAdded.value == true) { if (viewModel.isBookmarkAdded.value) {
viewModel.removeBookmark() viewModel.removeBookmark()
} else { } else {
viewModel.addBookmark() viewModel.addBookmark()
@ -209,7 +214,7 @@ class ReaderActivity :
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
val hasPages = !viewModel.content.value?.pages.isNullOrEmpty() val hasPages = viewModel.content.value.pages.isNotEmpty()
viewBinding.layoutLoading.isVisible = isLoading && !hasPages viewBinding.layoutLoading.isVisible = isLoading && !hasPages
if (isLoading && hasPages) { if (isLoading && hasPages) {
viewBinding.toastView.show(R.string.loading_) viewBinding.toastView.show(R.string.loading_)
@ -260,7 +265,7 @@ class ReaderActivity :
override fun onPageSelected(page: ReaderPage) { override fun onPageSelected(page: ReaderPage) {
lifecycleScope.launch(Dispatchers.Default) { lifecycleScope.launch(Dispatchers.Default) {
val pages = viewModel.content.value?.pages ?: return@launch val pages = viewModel.content.value.pages
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id } val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
if (index != -1) { if (index != -1) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -311,7 +316,7 @@ class ReaderActivity :
TransitionManager.beginDelayedTransition(viewBinding.root, transition) TransitionManager.beginDelayedTransition(viewBinding.root, transition)
viewBinding.appbarTop.isVisible = isUiVisible viewBinding.appbarTop.isVisible = isUiVisible
viewBinding.appbarBottom?.isVisible = isUiVisible viewBinding.appbarBottom?.isVisible = isUiVisible
viewBinding.infoBar.isGone = isUiVisible || (viewModel.isInfoBarEnabled.value == false) viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
if (isUiVisible) { if (isUiVisible) {
showSystemUI() showSystemUI()
} else { } else {
@ -367,7 +372,8 @@ class ReaderActivity :
menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark) menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark)
} }
private fun onUiStateChanged(uiState: ReaderUiState?, previous: ReaderUiState?) { private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
val (uiState: ReaderUiState?, previous: ReaderUiState?) = pair
title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_) title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_)
viewBinding.infoBar.update(uiState) viewBinding.infoBar.update(uiState)
if (uiState == null) { if (uiState == null) {

@ -5,8 +5,6 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -28,35 +26,32 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.emitValue
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.local.domain.DoubleMangaLoader import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
@ -70,7 +65,6 @@ private const val PREFETCH_LIMIT = 10
@HiltViewModel @HiltViewModel
class ReaderViewModel @Inject constructor( class ReaderViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val dataRepository: MangaDataRepository, private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
@ -79,7 +73,9 @@ class ReaderViewModel @Inject constructor(
private val pageLoader: PageLoader, private val pageLoader: PageLoader,
private val chaptersLoader: ChaptersLoader, private val chaptersLoader: ChaptersLoader,
private val shortcutsUpdater: ShortcutsUpdater, private val shortcutsUpdater: ShortcutsUpdater,
private val mangaLoader: DoubleMangaLoader, private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
private val historyUpdateUseCase: HistoryUpdateUseCase,
private val detectReaderModeUseCase: DetectReaderModeUseCase,
) : BaseViewModel() { ) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle) private val intent = MangaIntent(savedStateHandle)
@ -95,29 +91,29 @@ class ReaderViewModel @Inject constructor(
private val mangaFlow: Flow<Manga?> private val mangaFlow: Flow<Manga?>
get() = mangaData.map { it?.any } get() = mangaData.map { it?.any }
val readerMode = MutableLiveData<ReaderMode>() val readerMode = MutableStateFlow<ReaderMode?>(null)
val onPageSaved = SingleLiveEvent<Uri?>() val onPageSaved = MutableEventFlow<Uri?>()
val onShowToast = SingleLiveEvent<Int>() val onShowToast = MutableEventFlow<Int>()
val uiState = MutableLiveData<ReaderUiState?>(null) val uiState = MutableStateFlow<ReaderUiState?>(null)
val content = MutableLiveData(ReaderContent(emptyList(), null)) val content = MutableStateFlow(ReaderContent(emptyList(), null))
val manga: DoubleManga? val manga: DoubleManga?
get() = mangaData.value get() = mangaData.value
val readerAnimation = settings.observeAsLiveData( val readerAnimation = settings.observeAsStateFlow(
context = viewModelScope.coroutineContext + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_READER_ANIMATION, key = AppSettings.KEY_READER_ANIMATION,
valueProducer = { readerAnimation }, valueProducer = { readerAnimation },
) )
val isInfoBarEnabled = settings.observeAsLiveData( val isInfoBarEnabled = settings.observeAsStateFlow(
context = viewModelScope.coroutineContext + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_READER_BAR, key = AppSettings.KEY_READER_BAR,
valueProducer = { isReaderBarEnabled }, valueProducer = { isReaderBarEnabled },
) )
val isWebtoonZoomEnabled = settings.observeAsLiveData( val isWebtoonZoomEnabled = settings.observeAsStateFlow(
context = viewModelScope.coroutineContext + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_WEBTOON_ZOOM, key = AppSettings.KEY_WEBTOON_ZOOM,
valueProducer = { isWebtoonZoomEnable }, valueProducer = { isWebtoonZoomEnable },
) )
@ -136,9 +132,9 @@ class ReaderViewModel @Inject constructor(
) { manga, policy -> ) { manga, policy ->
policy == ScreenshotsPolicy.BLOCK_ALL || policy == ScreenshotsPolicy.BLOCK_ALL ||
(policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw) (policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
val isBookmarkAdded: LiveData<Boolean> = currentState.flatMapLatest { state -> val isBookmarkAdded = currentState.flatMapLatest { state ->
val manga = mangaData.value?.any val manga = mangaData.value?.any
if (state == null || manga == null) { if (state == null || manga == null) {
flowOf(false) flowOf(false)
@ -146,7 +142,7 @@ class ReaderViewModel @Inject constructor(
bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) bookmarksRepository.observeBookmark(manga, state.chapterId, state.page)
.map { it != null } .map { it != null }
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
init { init {
loadImpl() loadImpl()
@ -173,10 +169,8 @@ class ReaderViewModel @Inject constructor(
mode = newMode, mode = newMode,
) )
readerMode.value = newMode readerMode.value = newMode
content.value?.run { content.update {
content.value = copy( it.copy(state = getCurrentState())
state = getCurrentState(),
)
} }
} }
} }
@ -189,9 +183,9 @@ class ReaderViewModel @Inject constructor(
return return
} }
val readerState = state ?: currentState.value ?: return val readerState = state ?: currentState.value ?: return
historyRepository.saveStateAsync( historyUpdateUseCase.invokeAsync(
manga = mangaData.value?.any ?: return, manga = mangaData.value?.any ?: return,
state = readerState, readerState = readerState,
percent = computePercent(readerState.chapterId, readerState.page), percent = computePercent(readerState.chapterId, readerState.page),
) )
} }
@ -212,12 +206,12 @@ class ReaderViewModel @Inject constructor(
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
try { try {
val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher) val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher)
onPageSaved.emitCall(dest) onPageSaved.call(dest)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTraceDebug() e.printStackTraceDebug()
onPageSaved.emitCall(null) onPageSaved.call(null)
} }
} }
} }
@ -233,7 +227,7 @@ class ReaderViewModel @Inject constructor(
fun getCurrentPage(): MangaPage? { fun getCurrentPage(): MangaPage? {
val state = currentState.value ?: return null val state = currentState.value ?: return null
return content.value?.pages?.find { return content.value.pages.find {
it.chapterId == state.chapterId && it.index == state.page it.chapterId == state.chapterId && it.index == state.page
}?.toMangaPage() }?.toMangaPage()
} }
@ -242,9 +236,9 @@ class ReaderViewModel @Inject constructor(
val prevJob = loadingJob val prevJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
content.postValue(ReaderContent(emptyList(), null)) content.value = ReaderContent(emptyList(), null)
chaptersLoader.loadSingleChapter(id) chaptersLoader.loadSingleChapter(id)
content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0))) content.value = ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0))
} }
} }
@ -254,7 +248,7 @@ class ReaderViewModel @Inject constructor(
stateChangeJob = launchJob(Dispatchers.Default) { stateChangeJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
loadingJob?.join() loadingJob?.join()
val pages = content.value?.pages ?: return@launchJob val pages = content.value.pages
pages.getOrNull(position)?.let { page -> pages.getOrNull(position)?.let { page ->
currentState.update { cs -> currentState.update { cs ->
cs?.copy(chapterId = page.chapterId, page = page.index) cs?.copy(chapterId = page.chapterId, page = page.index)
@ -296,7 +290,7 @@ class ReaderViewModel @Inject constructor(
percent = computePercent(state.chapterId, state.page), percent = computePercent(state.chapterId, state.page),
) )
bookmarksRepository.addBookmark(bookmark) bookmarksRepository.addBookmark(bookmark)
onShowToast.emitCall(R.string.bookmark_added) onShowToast.call(R.string.bookmark_added)
} }
} }
@ -318,32 +312,31 @@ class ReaderViewModel @Inject constructor(
var manga = var manga =
DoubleManga(dataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "")) DoubleManga(dataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", ""))
mangaData.value = manga mangaData.value = manga
manga = mangaLoader.load(intent) manga = doubleMangaLoadUseCase(intent)
chaptersLoader.init(manga) chaptersLoader.init(manga)
// determine mode // determine mode
val singleManga = manga.requireAny() val singleManga = manga.requireAny()
val mode = detectReaderMode(singleManga)
// obtain state // obtain state
if (currentState.value == null) { if (currentState.value == null) {
currentState.value = historyRepository.getOne(singleManga)?.let { currentState.value = historyRepository.getOne(singleManga)?.let {
ReaderState(it) ReaderState(it)
} ?: ReaderState(singleManga, preselectedBranch) } ?: ReaderState(singleManga, preselectedBranch)
} }
val mode = detectReaderModeUseCase.invoke(singleManga, currentState.value)
val branch = chaptersLoader.peekChapter(currentState.value?.chapterId ?: 0L)?.branch val branch = chaptersLoader.peekChapter(currentState.value?.chapterId ?: 0L)?.branch
mangaData.value = manga.filterChapters(branch) mangaData.value = manga.filterChapters(branch)
readerMode.emitValue(mode) readerMode.value = mode
chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId) chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId)
// save state // save state
if (!isIncognito) { if (!isIncognito) {
currentState.value?.let { currentState.value?.let {
val percent = computePercent(it.chapterId, it.page) val percent = computePercent(it.chapterId, it.page)
historyRepository.addOrUpdate(singleManga, it.chapterId, it.page, it.scroll, percent) historyUpdateUseCase.invoke(singleManga, it, percent)
} }
} }
notifyStateChanged() notifyStateChanged()
content.emitValue(ReaderContent(chaptersLoader.snapshot(), currentState.value)) content.value = ReaderContent(chaptersLoader.snapshot(), currentState.value)
} }
} }
@ -353,7 +346,7 @@ class ReaderViewModel @Inject constructor(
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.join() prevJob?.join()
chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext) chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext)
content.emitValue(ReaderContent(chaptersLoader.snapshot(), null)) content.value = ReaderContent(chaptersLoader.snapshot(), null)
} }
} }
@ -367,27 +360,6 @@ class ReaderViewModel @Inject constructor(
} }
} }
private suspend fun detectReaderMode(manga: Manga): ReaderMode {
dataRepository.getReaderMode(manga.id)?.let { return it }
val defaultMode = settings.defaultReaderMode
if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) {
return defaultMode
}
val chapter = currentState.value?.chapterId?.let { chaptersLoader.peekChapter(it) }
?: manga.chapters?.randomOrNull()
?: error("There are no chapters in this manga")
val repo = mangaRepositoryFactory.create(manga.source)
val pages = repo.getPages(chapter)
return runCatchingCancellable {
val isWebtoon = dataRepository.determineMangaIsWebtoon(repo, pages)
if (isWebtoon) ReaderMode.WEBTOON else defaultMode
}.onSuccess {
dataRepository.saveReaderMode(manga, it)
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(defaultMode)
}
@WorkerThread @WorkerThread
private fun notifyStateChanged() { private fun notifyStateChanged() {
val state = getCurrentState() val state = getCurrentState()
@ -402,7 +374,7 @@ class ReaderViewModel @Inject constructor(
isSliderEnabled = settings.isReaderSliderEnabled, isSliderEnabled = settings.isReaderSliderEnabled,
percent = if (state != null) computePercent(state.chapterId, state.page) else PROGRESS_NONE, percent = if (state != null) computePercent(state.chapterId, state.page) else PROGRESS_NONE,
) )
uiState.postValue(newState) uiState.value = newState
} }
private fun computePercent(chapterId: Long, pageIndex: Int): Float { private fun computePercent(chapterId: Long, pageIndex: Int): Float {
@ -419,23 +391,3 @@ class ReaderViewModel @Inject constructor(
return ppc * chapterIndex + ppc * pagePercent return ppc * chapterIndex + ppc * pagePercent
} }
} }
/**
* This function is not a member of the ReaderViewModel
* because it should work independently of the ViewModel's lifecycle.
*/
private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job {
return processLifecycleScope.launch(Dispatchers.Default) {
runCatchingCancellable {
addOrUpdate(
manga = manga,
chapterId = state.chapterId,
page = state.page,
scroll = state.scroll,
percent = percent,
)
}.onFailure {
it.printStackTraceDebug()
}
}
}

@ -24,6 +24,8 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.indicator import org.koitharu.kotatsu.core.util.ext.indicator
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.databinding.ActivityColorFilterBinding import org.koitharu.kotatsu.databinding.ActivityColorFilterBinding
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -64,7 +66,7 @@ class ColorFilterConfigActivity :
viewModel.colorFilter.observe(this, this::onColorFilterChanged) viewModel.colorFilter.observe(this, this::onColorFilterChanged)
viewModel.isLoading.observe(this, this::onLoadingChanged) viewModel.isLoading.observe(this, this::onLoadingChanged)
viewModel.preview.observe(this, this::onPreviewChanged) viewModel.preview.observe(this, this::onPreviewChanged)
viewModel.onDismiss.observe(this) { viewModel.onDismiss.observeEvent(this) {
finishAfterTransition() finishAfterTransition()
} }
} }

@ -5,6 +5,7 @@ import android.content.DialogInterface
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.call
class ColorFilterConfigBackPressedDispatcher( class ColorFilterConfigBackPressedDispatcher(
private val context: Context, private val context: Context,

@ -1,16 +1,16 @@
package org.koitharu.kotatsu.reader.ui.colorfilter package org.koitharu.kotatsu.reader.ui.colorfilter
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.emitValue import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA
@ -26,9 +26,9 @@ class ColorFilterConfigViewModel @Inject constructor(
private val manga = checkNotNull(savedStateHandle.get<ParcelableManga>(EXTRA_MANGA)?.manga) private val manga = checkNotNull(savedStateHandle.get<ParcelableManga>(EXTRA_MANGA)?.manga)
private var initialColorFilter: ReaderColorFilter? = null private var initialColorFilter: ReaderColorFilter? = null
val colorFilter = MutableLiveData<ReaderColorFilter?>(null) val colorFilter = MutableStateFlow<ReaderColorFilter?>(null)
val onDismiss = SingleLiveEvent<Unit>() val onDismiss = MutableEventFlow<Unit>()
val preview = MutableLiveData<MangaPage?>(null) val preview = MutableStateFlow<MangaPage?>(null)
val isChanged: Boolean val isChanged: Boolean
get() = colorFilter.value != initialColorFilter get() = colorFilter.value != initialColorFilter
@ -44,13 +44,11 @@ class ColorFilterConfigViewModel @Inject constructor(
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val repository = mangaRepositoryFactory.create(page.source) val repository = mangaRepositoryFactory.create(page.source)
val url = repository.getPageUrl(page) val url = repository.getPageUrl(page)
preview.emitValue( preview.value = MangaPage(
MangaPage(
id = page.id, id = page.id,
url = url, url = url,
preview = page.preview, preview = page.preview,
source = page.source, source = page.source,
),
) )
} }
} }
@ -72,7 +70,7 @@ class ColorFilterConfigViewModel @Inject constructor(
fun save() { fun save() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
mangaDataRepository.saveColorFilter(manga, colorFilter.value) mangaDataRepository.saveColorFilter(manga, colorFilter.value)
onDismiss.emitCall(Unit) onDismiss.call(Unit)
} }
} }
} }

@ -18,12 +18,14 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseBottomSheet import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.util.ScreenOrientationHelper import org.koitharu.kotatsu.core.util.ScreenOrientationHelper
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
@ -75,8 +77,8 @@ class ReaderConfigBottomSheet :
binding.sliderTimer.addOnChangeListener(this) binding.sliderTimer.addOnChangeListener(this)
binding.switchScrollTimer.setOnCheckedChangeListener(this) binding.switchScrollTimer.setOnCheckedChangeListener(this)
settings.observeAsLiveData( settings.observeAsStateFlow(
context = lifecycleScope.coroutineContext + Dispatchers.Default, scope = lifecycleScope + Dispatchers.Default,
key = AppSettings.KEY_READER_AUTOSCROLL_SPEED, key = AppSettings.KEY_READER_AUTOSCROLL_SPEED,
valueProducer = { readerAutoscrollSpeed }, valueProducer = { readerAutoscrollSpeed },
).observe(viewLifecycleOwner) { ).observe(viewLifecycleOwner) {

@ -6,6 +6,7 @@ import androidx.fragment.app.activityViewModels
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel

@ -10,6 +10,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.resetTransformations import org.koitharu.kotatsu.core.util.ext.resetTransformations
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope

@ -10,6 +10,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.resetTransformations import org.koitharu.kotatsu.core.util.ext.resetTransformations
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope

@ -11,6 +11,7 @@ import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader

@ -20,6 +20,8 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.ScrollListenerInvalidationObserver import org.koitharu.kotatsu.core.ui.list.ScrollListenerInvalidationObserver
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetPagesBinding import org.koitharu.kotatsu.databinding.SheetPagesBinding
@ -93,7 +95,7 @@ class PagesThumbnailsSheet :
viewModel.branch.observe(viewLifecycleOwner) { viewModel.branch.observe(viewLifecycleOwner) {
onExpansionStateChanged(binding.headerBar, binding.headerBar.isExpanded) onExpansionStateChanged(binding.headerBar, binding.headerBar.isExpanded)
} }
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
} }
override fun onDestroyView() { override fun onDestroyView() {

@ -1,18 +1,17 @@
package org.koitharu.kotatsu.reader.ui.thumbnails package org.koitharu.kotatsu.reader.ui.thumbnails
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.emitValue import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.local.domain.DoubleMangaLoader
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import javax.inject.Inject import javax.inject.Inject
@ -22,7 +21,7 @@ class PagesThumbnailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
private val chaptersLoader: ChaptersLoader, private val chaptersLoader: ChaptersLoader,
private val mangaLoader: DoubleMangaLoader, private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
) : BaseViewModel() { ) : BaseViewModel() {
private val currentPageIndex: Int = savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1 private val currentPageIndex: Int = savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1
@ -31,9 +30,9 @@ class PagesThumbnailsViewModel @Inject constructor(
private val repository = mangaRepositoryFactory.create(manga.source) private val repository = mangaRepositoryFactory.create(manga.source)
private val mangaDetails = SuspendLazy { private val mangaDetails = SuspendLazy {
mangaLoader.load(manga).let { doubleMangaLoadUseCase(manga).let {
val b = manga.chapters?.find { ch -> ch.id == initialChapterId }?.branch val b = manga.chapters?.find { ch -> ch.id == initialChapterId }?.branch
branch.emitValue(b) branch.value = b
it.filterChapters(b) it.filterChapters(b)
} }
} }
@ -41,8 +40,8 @@ class PagesThumbnailsViewModel @Inject constructor(
private var loadingPrevJob: Job? = null private var loadingPrevJob: Job? = null
private var loadingNextJob: Job? = null private var loadingNextJob: Job? = null
val thumbnails = MutableLiveData<List<ListModel>>() val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
val branch = MutableLiveData<String?>() val branch = MutableStateFlow<String?>(null)
val title = manga.title val title = manga.title
init { init {
@ -100,6 +99,6 @@ class PagesThumbnailsViewModel @Inject constructor(
add(LoadingFooter(1)) add(LoadingFooter(1))
} }
} }
thumbnails.emitValue(pages) thumbnails.value = pages
} }
} }

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.remotelist.ui package org.koitharu.kotatsu.remotelist.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -9,11 +8,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@ -21,7 +24,7 @@ import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
@ -65,12 +68,12 @@ class RemoteListViewModel @Inject constructor(
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private var loadingJob: Job? = null private var loadingJob: Job? = null
val filterItems: LiveData<List<FilterItem>> val filterItems: StateFlow<List<FilterItem>>
get() = filter.items get() = filter.items
override val content = combine( override val content = combine(
mangaList, mangaList,
listModeFlow, listMode,
createHeaderFlow(), createHeaderFlow(),
listError, listError,
hasNextPage, hasNextPage,
@ -90,7 +93,7 @@ class RemoteListViewModel @Inject constructor(
} }
} }
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init { init {
filter.observeState() filter.observeState()
@ -163,7 +166,7 @@ class RemoteListViewModel @Inject constructor(
e.printStackTraceDebug() e.printStackTraceDebug()
listError.value = e listError.value = e
if (!mangaList.value.isNullOrEmpty()) { if (!mangaList.value.isNullOrEmpty()) {
errorEvent.emitCall(e) errorEvent.call(e)
} }
} }
} }

@ -19,6 +19,8 @@ import org.koitharu.kotatsu.core.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
@ -64,7 +66,7 @@ class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
viewModel.content.observe(this, listAdapter::setItems) viewModel.content.observe(this, listAdapter::setItems)
viewModel.user.observe(this, this::onUserChanged) viewModel.user.observe(this, this::onUserChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onError.observe(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.onLoggedOut.observe(this) { viewModel.onLoggedOut.observe(this) {
finishAfterTransition() finishAfterTransition()
} }

@ -1,23 +1,24 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config package org.koitharu.kotatsu.scrobbling.common.ui.config
import android.net.Uri import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.emitValue
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@ -40,34 +41,34 @@ class ScrobblerConfigViewModel @Inject constructor(
val titleResId = scrobbler.scrobblerService.titleResId val titleResId = scrobbler.scrobblerService.titleResId
val user = MutableLiveData<ScrobblerUser?>(null) val user = MutableStateFlow<ScrobblerUser?>(null)
val onLoggedOut = SingleLiveEvent<Unit>() val onLoggedOut = MutableEventFlow<Unit>()
val content = scrobbler.observeAllScrobblingInfo() val content = scrobbler.observeAllScrobblingInfo()
.onStart { loadingCounter.increment() } .onStart { loadingCounter.increment() }
.onFirst { loadingCounter.decrement() } .onFirst { loadingCounter.decrement() }
.catch { errorEvent.postCall(it) } .catch { errorEvent.call(it) }
.map { buildContentList(it) } .map { buildContentList(it) }
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
init { init {
scrobbler.user scrobbler.user
.onEach { user.emitValue(it) } .onEach { user.value = it }
.launchIn(viewModelScope + Dispatchers.Default) .launchIn(viewModelScope + Dispatchers.Default)
} }
fun onAuthCodeReceived(authCode: String) { fun onAuthCodeReceived(authCode: String) {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val newUser = scrobbler.authorize(authCode) val newUser = scrobbler.authorize(authCode)
user.emitValue(newUser) user.value = newUser
} }
} }
fun logout() { fun logout() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
scrobbler.logout() scrobbler.logout()
user.emitValue(null) user.value = null
onLoggedOut.emitCall(Unit) onLoggedOut.call(Unit)
} }
} }

@ -22,6 +22,8 @@ import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@ -72,8 +74,8 @@ class ScrobblingSelectorBottomSheet :
decoration.checkedItemId = it decoration.checkedItemId = it
binding.recyclerView.invalidateItemDecorations() binding.recyclerView.invalidateItemDecorations()
} }
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observeEvent(viewLifecycleOwner, ::onError)
viewModel.onClose.observe(viewLifecycleOwner) { viewModel.onClose.observeEvent(viewLifecycleOwner) {
dismiss() dismiss()
} }
viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index -> viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index ->

@ -1,7 +1,5 @@
package org.koitharu.kotatsu.scrobbling.common.ui.selector package org.koitharu.kotatsu.scrobbling.common.ui.selector
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.RecyclerView.NO_ID import androidx.recyclerview.widget.RecyclerView.NO_ID
@ -9,14 +7,17 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.emitValue
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@ -39,7 +40,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
val availableScrobblers = scrobblers.filter { it.isAvailable } val availableScrobblers = scrobblers.filter { it.isAvailable }
val selectedScrobblerIndex = MutableLiveData(0) val selectedScrobblerIndex = MutableStateFlow(0)
private val scrobblerMangaList = MutableStateFlow<List<ScrobblerManga>>(emptyList()) private val scrobblerMangaList = MutableStateFlow<List<ScrobblerManga>>(emptyList())
private val hasNextPage = MutableStateFlow(true) private val hasNextPage = MutableStateFlow(true)
@ -51,7 +52,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
private val currentScrobbler: Scrobbler private val currentScrobbler: Scrobbler
get() = availableScrobblers[selectedScrobblerIndex.requireValue()] get() = availableScrobblers[selectedScrobblerIndex.requireValue()]
val content: LiveData<List<ListModel>> = combine( val content: StateFlow<List<ListModel>> = combine(
scrobblerMangaList, scrobblerMangaList,
listError, listError,
hasNextPage, hasNextPage,
@ -71,11 +72,11 @@ class ScrobblingSelectorViewModel @Inject constructor(
}, },
) )
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
val selectedItemId = MutableLiveData(NO_ID) val selectedItemId = MutableStateFlow(NO_ID)
val searchQuery = MutableLiveData(manga.title) val searchQuery = MutableStateFlow(manga.title)
val onClose = SingleLiveEvent<Unit>() val onClose = MutableEventFlow<Unit>()
val isEmpty: Boolean val isEmpty: Boolean
get() = scrobblerMangaList.value.isEmpty() get() = scrobblerMangaList.value.isEmpty()
@ -130,13 +131,13 @@ class ScrobblingSelectorViewModel @Inject constructor(
if (doneJob?.isActive == true) { if (doneJob?.isActive == true) {
return return
} }
val targetId = selectedItemId.value ?: NO_ID val targetId = selectedItemId.value
if (targetId == NO_ID) { if (targetId == NO_ID) {
onClose.call(Unit) onClose.call(Unit)
} }
doneJob = launchJob(Dispatchers.Default) { doneJob = launchJob(Dispatchers.Default) {
currentScrobbler.linkManga(manga.id, targetId) currentScrobbler.linkManga(manga.id, targetId)
onClose.emitCall(Unit) onClose.call(Unit)
} }
} }
@ -155,7 +156,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
try { try {
val info = currentScrobbler.getScrobblingInfoOrNull(manga.id) val info = currentScrobbler.getScrobblingInfoOrNull(manga.id)
if (info != null) { if (info != null) {
selectedItemId.emitValue(info.targetId) selectedItemId.value = info.targetId
} }
} finally { } finally {
loadList(append = false) loadList(append = false)

@ -13,6 +13,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showKeyboard import org.koitharu.kotatsu.core.util.ext.showKeyboard
import org.koitharu.kotatsu.databinding.ActivitySearchBinding import org.koitharu.kotatsu.databinding.ActivitySearchBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save