Merge branch 'devel' into feature/sync

pull/163/head
Koitharu 4 years ago
commit 1a6b4ae795
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -44,7 +44,7 @@ body:
label: Kotatsu version label: Kotatsu version
description: You can find your Kotatsu version in **Settings → About**. description: You can find your Kotatsu version in **Settings → About**.
placeholder: | placeholder: |
Example: "3.2" Example: "3.2.3"
validations: validations:
required: true required: true
@ -87,7 +87,7 @@ body:
required: true required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new). - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true required: true
- label: I have updated the app to version **[3.2](https://github.com/nv95/Kotatsu/releases/latest)**. - label: I have updated the app to version **[3.2.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true required: true
- label: I will fill out all of the requested information in this form. - label: I will fill out all of the requested information in this form.
required: true required: true

@ -33,7 +33,7 @@ body:
required: true required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new). - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true required: true
- label: I have updated the app to version **[3.2](https://github.com/nv95/Kotatsu/releases/latest)**. - label: I have updated the app to version **[3.2.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true required: true
- label: I will fill out all of the requested information in this form. - label: I will fill out all of the requested information in this form.
required: true required: true

1
.gitignore vendored

@ -10,6 +10,7 @@
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml /.idea/kotlinScripting.xml
/.idea/deploymentTargetDropDown.xml
.DS_Store .DS_Store
/build /build
/captures /captures

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_API_S.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2021-02-19T19:02:37.198775Z" />
</component>
</project>

@ -4,6 +4,9 @@
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> <inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" /> <inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="KotlinFunctionArgumentsHelper" enabled="true" level="INFORMATION" enabled_by_default="true">
<option name="withoutDefaultValues" value="true" />
</inspection_tool>
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" /> <inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" /> <inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />

@ -1,11 +0,0 @@
language: android
dist: trusty
android:
components:
- android-30
- build-tools-30.0.3
- platform-tools-30.0.5
- tools
before_install:
- yes | sdkmanager "platforms;android-30"
script: ./gradlew -Dorg.gradle.jvmargs=-Xmx1536m assembleDebug lintDebug

@ -2,7 +2,7 @@
Kotatsu is a free and open source manga reader for Android. Kotatsu is a free and open source manga reader for Android.
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
### Download ### Download

@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 405 versionCode 407
versionName '3.2.1' versionName '3.2.3'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -52,11 +52,12 @@ android {
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi',
] ]
} }
lint { lint {
abortOnError false abortOnError false
disable 'MissingTranslation', 'PrivateResource' disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
} }
testOptions { testOptions {
unitTests.includeAndroidResources = true unitTests.includeAndroidResources = true
@ -65,7 +66,7 @@ android {
} }
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:090ad4b256') { implementation('com.github.nv95:kotatsu-parsers:05a93e2380') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@ -86,7 +87,7 @@ dependencies {
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'com.google.android.material:material:1.6.0-rc01' implementation 'com.google.android.material:material:1.7.0-alpha01'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
@ -95,21 +96,22 @@ dependencies {
kapt 'androidx.room:room-compiler:2.4.2' kapt 'androidx.room:room-compiler:2.4.2'
implementation 'com.squareup.okhttp3:okhttp:4.9.3' implementation 'com.squareup.okhttp3:okhttp:4.9.3'
implementation 'com.squareup.okio:okio:3.0.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
implementation 'com.squareup.okio:okio:3.1.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'io.insert-koin:koin-android:3.1.6' implementation 'io.insert-koin:koin-android:3.2.0'
implementation 'io.coil-kt:coil-base:1.4.0' implementation 'io.coil-kt:coil-base:2.0.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5' testImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0'

@ -0,0 +1,3 @@
package org.koitharu.kotatsu.utils.ext
fun Throwable.printStackTraceDebug() = printStackTrace()

@ -83,7 +83,8 @@
<activity <activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity" android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:exported="true" android:exported="true"
android:label="@string/manga_shelf"> android:label="@string/manga_shelf"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter> </intent-filter>
@ -101,8 +102,12 @@
<activity <activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity" android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads" android:label="@string/downloads"
android:launchMode="singleTop" /> android:launchMode="singleTop"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" /> <activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
<activity
android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
<activity android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity" <activity android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:label="@string/sync"/> android:label="@string/sync"/>

@ -7,6 +7,7 @@ import androidx.fragment.app.strictmode.FragmentStrictMode
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koitharu.kotatsu.bookmarks.bookmarksModule
import org.koitharu.kotatsu.core.db.databaseModule import org.koitharu.kotatsu.core.db.databaseModule
import org.koitharu.kotatsu.core.github.githubModule import org.koitharu.kotatsu.core.github.githubModule
import org.koitharu.kotatsu.core.network.networkModule import org.koitharu.kotatsu.core.network.networkModule
@ -69,6 +70,7 @@ class KotatsuApp : Application() {
appWidgetModule, appWidgetModule,
suggestionsModule, suggestionsModule,
syncModule, syncModule,
bookmarksModule,
) )
} }
} }

@ -9,24 +9,26 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.medianOrNull import java.io.File
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.math.roundToInt
object MangaUtils : KoinComponent { object MangaUtils : KoinComponent {
private const val MIN_WEBTOON_RATIO = 2
/** /**
* Automatic determine type of manga by page size * Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide * @return ReaderMode.WEBTOON if page is wide
*/ */
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? { suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean {
try { val pageIndex = (pages.size * 0.3).roundToInt()
val page = pages.medianOrNull() ?: return null val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
val url = MangaRepository(page.source).getPageUrl(page) val url = MangaRepository(page.source).getPageUrl(page)
val uri = Uri.parse(url) val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") { val size = if (uri.scheme == "cbz") {
@ -50,13 +52,15 @@ object MangaUtils : KoinComponent {
} }
} }
} }
return size.width * 2 < size.height return size.width * MIN_WEBTOON_RATIO < size.height
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
} }
return null
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 { private fun getBitmapSize(input: InputStream?): Size {

@ -0,0 +1,19 @@
package org.koitharu.kotatsu.base.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
fun interface ReversibleHandle {
suspend fun reverse()
}
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
reverse()
}
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
this.reverse()
other.reverse()
}

@ -9,11 +9,12 @@ import android.view.ViewGroup.LayoutParams
import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.app.AppCompatDialog
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.R as materialR
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
import com.google.android.material.R as materialR
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() { abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
@ -43,7 +44,9 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return if (resources.getBoolean(R.bool.is_tablet)) { return if (resources.getBoolean(R.bool.is_tablet)) {
AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog) AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
} else super.onCreateDialog(savedInstanceState) } else {
AppBottomSheetDialog(requireContext(), theme)
}
} }
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B

@ -7,10 +7,12 @@ import android.view.View
import android.view.WindowManager import android.view.WindowManager
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
@Suppress("DEPRECATION")
private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
@Suppress("DEPRECATION")
private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
@ -18,7 +20,8 @@ private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
abstract class BaseFullscreenActivity<B : ViewBinding> : BaseActivity<B>(), abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>(),
View.OnSystemUiVisibilityChangeListener { View.OnSystemUiVisibilityChangeListener {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -35,16 +38,19 @@ abstract class BaseFullscreenActivity<B : ViewBinding> : BaseActivity<B>(),
showSystemUI() showSystemUI()
} }
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
final override fun onSystemUiVisibilityChange(visibility: Int) { final override fun onSystemUiVisibilityChange(visibility: Int) {
onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0) onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0)
} }
// TODO WindowInsetsControllerCompat works incorrect // TODO WindowInsetsControllerCompat works incorrect
@Suppress("DEPRECATION")
protected fun hideSystemUI() { protected fun hideSystemUI() {
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN
} }
@Suppress("DEPRECATION")
protected fun showSystemUI() { protected fun showSystemUI() {
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN
} }

@ -1,18 +1,25 @@
package org.koitharu.kotatsu.base.ui package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
val onError = SingleLiveEvent<Throwable>() protected val loadingCounter = CountedBooleanLiveData()
val isLoading = CountedBooleanLiveData() protected val errorEvent = SingleLiveEvent<Throwable>()
val onError: LiveData<Throwable>
get() = errorEvent
val isLoading: LiveData<Boolean>
get() = loadingCounter
protected fun launchJob( protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
@ -25,20 +32,18 @@ abstract class BaseViewModel : ViewModel() {
start: CoroutineStart = CoroutineStart.DEFAULT, start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start) { ): Job = viewModelScope.launch(context + createErrorHandler(), start) {
isLoading.postValue(true) loadingCounter.increment()
try { try {
block() block()
} finally { } finally {
isLoading.postValue(false) loadingCounter.decrement()
} }
} }
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
if (BuildConfig.DEBUG) { throwable.printStackTraceDebug()
throwable.printStackTrace()
}
if (throwable !is CancellationException) { if (throwable !is CancellationException) {
onError.postCall(throwable) errorEvent.postCall(throwable)
} }
} }
} }

@ -0,0 +1,29 @@
package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.graphics.Color
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetDialog
class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
/**
* https://github.com/material-components/material-components-android/issues/2582
*/
@Suppress("DEPRECATION")
override fun onAttachedToWindow() {
val window = window
val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0
super.onAttachedToWindow()
if (window != null) {
// If the navigation bar is translucent at all, the BottomSheet should be edge to edge
val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF
if (drawEdgeToEdge) {
// Copied from super.onAttachedToWindow:
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
// Fix super-class's window flag bug by respecting the intial system UI visibility:
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
}
}
}
}

@ -0,0 +1,20 @@
package org.koitharu.kotatsu.base.ui.list
import android.view.View
import android.view.View.OnClickListener
import android.view.View.OnLongClickListener
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
class AdapterDelegateClickListenerAdapter<I>(
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<I, *>,
private val clickListener: OnListItemClickListener<I>,
) : OnClickListener, OnLongClickListener {
override fun onClick(v: View) {
clickListener.onItemClick(adapterDelegate.item, v)
}
override fun onLongClick(v: View): Boolean {
return clickListener.onItemLongClick(adapterDelegate.item, v)
}
}

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

@ -36,8 +36,7 @@ class ListItemTextView @JvmOverloads constructor(
init { init {
context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) { context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) {
val itemRippleColor = getColorStateList(R.styleable.ListItemTextView_rippleColor) val itemRippleColor = getRippleColor(context)
?: getRippleColorFallback(context)
val shape = createShapeDrawable(this) val shape = createShapeDrawable(this)
background = RippleDrawable( background = RippleDrawable(
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor), RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
@ -108,7 +107,7 @@ class ListItemTextView @JvmOverloads constructor(
ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0), ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0),
).build() ).build()
val shapeDrawable = MaterialShapeDrawable(shapeAppearance) val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundTint) shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundFillColor)
return InsetDrawable( return InsetDrawable(
shapeDrawable, shapeDrawable,
ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0), ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0),
@ -118,7 +117,7 @@ class ListItemTextView @JvmOverloads constructor(
) )
} }
private fun getRippleColorFallback(context: Context): ColorStateList { private fun getRippleColor(context: Context): ColorStateList {
return context.getThemeColorStateList(android.R.attr.colorControlHighlight) return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
?: ColorStateList.valueOf(Color.TRANSPARENT) ?: ColorStateList.valueOf(Color.TRANSPARENT)
} }

@ -0,0 +1,10 @@
package org.koitharu.kotatsu.bookmarks
import org.koin.dsl.module
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
val bookmarksModule
get() = module {
factory { BookmarksRepository(get()) }
}

@ -0,0 +1,28 @@
package org.koitharu.kotatsu.bookmarks.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
tableName = "bookmarks",
primaryKeys = ["manga_id", "page_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
),
]
)
class BookmarkEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Int,
@ColumnInfo(name = "image") val imageUrl: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
)

@ -0,0 +1,23 @@
package org.koitharu.kotatsu.bookmarks.data
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
class BookmarkWithManga(
@Embedded val bookmark: BookmarkEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id"
)
val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>,
)

@ -0,0 +1,26 @@
package org.koitharu.kotatsu.bookmarks.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
abstract class BookmarksDao {
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC")
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
@Insert
abstract suspend fun insert(entity: BookmarkEntity)
@Delete
abstract suspend fun delete(entity: BookmarkEntity)
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun delete(mangaId: Long, pageId: Long)
}

@ -0,0 +1,31 @@
package org.koitharu.kotatsu.bookmarks.data
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
fun BookmarkWithManga.toBookmark() = bookmark.toBookmark(
manga.toManga(tags.toMangaTags())
)
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
pageId = pageId,
chapterId = chapterId,
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = Date(createdAt),
)
fun Bookmark.toEntity() = BookmarkEntity(
mangaId = manga.id,
pageId = pageId,
chapterId = chapterId,
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt.time,
)

@ -0,0 +1,43 @@
package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
class Bookmark(
val manga: Manga,
val pageId: Long,
val chapterId: Long,
val page: Int,
val scroll: Int,
val imageUrl: String,
val createdAt: Date,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Bookmark
if (manga != other.manga) return false
if (pageId != other.pageId) return false
if (chapterId != other.chapterId) return false
if (page != other.page) return false
if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false
return true
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + pageId.hashCode()
result = 31 * result + chapterId.hashCode()
result = 31 * result + page
result = 31 * result + scroll
result = 31 * result + imageUrl.hashCode()
result = 31 * result + createdAt.hashCode()
return result
}
}

@ -0,0 +1,38 @@
package org.koitharu.kotatsu.bookmarks.domain
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.bookmarks.data.toBookmark
import org.koitharu.kotatsu.bookmarks.data.toEntity
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.mapItems
class BookmarksRepository(
private val db: MangaDatabase,
) {
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
}
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
}
suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction {
val tags = bookmark.manga.tags.toEntities()
db.tagsDao.upsert(tags)
db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
db.bookmarksDao.insert(bookmark.toEntity())
}
}
suspend fun removeBookmark(mangaId: Long, pageId: Long) {
db.bookmarksDao.delete(mangaId, pageId)
}
}

@ -0,0 +1,51 @@
package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
fun bookmarkListAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener)
bind {
imageRequest?.dispose()
imageRequest = binding.imageViewThumb.newImageRequest(item.imageUrl)
.referer(item.manga.publicUrl)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.allowRgb565(true)
.scale(Scale.FILL)
.lifecycle(lifecycleOwner)
.enqueueWith(coil)
}
onViewRecycled {
imageRequest?.dispose()
imageRequest = null
CoilUtils.dispose(binding.imageViewThumb)
binding.imageViewThumb.setImageDrawable(null)
}
}

@ -0,0 +1,30 @@
package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
class BookmarksAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) : AsyncListDifferDelegationAdapter<Bookmark>(
DiffCallback(),
bookmarkListAD(coil, lifecycleOwner, clickListener)
) {
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.manga.id == newItem.manga.id && oldItem.chapterId == newItem.chapterId
}
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.imageUrl == newItem.imageUrl
}
}
}

@ -121,6 +121,7 @@ class BackupRepository(private val db: MangaDatabase) {
jo.put("sort_key", sortKey) jo.put("sort_key", sortKey)
jo.put("title", title) jo.put("title", title)
jo.put("order", order) jo.put("order", order)
jo.put("track", track)
return jo return jo
} }

@ -104,6 +104,7 @@ class RestoreRepository(private val db: MangaDatabase) {
sortKey = json.getInt("sort_key"), sortKey = json.getInt("sort_key"),
title = json.getString("title"), title = json.getString("title"),
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name, order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
track = json.getBooleanOrDefault("track", true),
) )
private fun parseFavourite(json: JSONObject) = FavouriteEntity( private fun parseFavourite(json: JSONObject) = FavouriteEntity(

@ -5,5 +5,5 @@ import org.koin.dsl.module
val databaseModule val databaseModule
get() = module { get() = module {
single { MangaDatabase.create(androidContext()) } single { MangaDatabase(androidContext()) }
} }

@ -10,8 +10,8 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
override fun onCreate(db: SupportSQLiteDatabase) { override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL( db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)", "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track) VALUES (?,?,?,?,?)",
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name) arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name, 1)
) )
} }
} }

@ -4,6 +4,8 @@ import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.* import org.koitharu.kotatsu.core.db.dao.*
import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.migrations.* import org.koitharu.kotatsu.core.db.migrations.*
@ -20,9 +22,9 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
], ],
version = 9 version = 11,
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
@ -44,16 +46,10 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val suggestionDao: SuggestionDao abstract val suggestionDao: SuggestionDao
companion object { abstract val bookmarksDao: BookmarksDao
}
const val TABLE_FAVOURITES = "favourites"
const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags"
fun create(context: Context): MangaDatabase = Room.databaseBuilder( fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
context, context,
MangaDatabase::class.java, MangaDatabase::class.java,
"kotatsu-db" "kotatsu-db"
@ -66,8 +62,8 @@ abstract class MangaDatabase : RoomDatabase() {
Migration6To7(), Migration6To7(),
Migration7To8(), Migration7To8(),
Migration8To9(), Migration8To9(),
Migration9To10(),
Migration10To11(),
).addCallback( ).addCallback(
DatabasePrePopulateCallback(context.resources) DatabasePrePopulateCallback(context.resources)
).build() ).build()
}
}

@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.db
const val TABLE_FAVOURITES = "favourites"
const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags"

@ -10,6 +10,9 @@ abstract class TracksDao {
@Query("SELECT * FROM tracks") @Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List<TrackEntity> abstract suspend fun findAll(): List<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId") @Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun find(mangaId: Long): TrackEntity? abstract suspend fun find(mangaId: Long): TrackEntity?

@ -3,7 +3,7 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA import org.koitharu.kotatsu.core.db.TABLE_MANGA
@Entity(tableName = TABLE_MANGA) @Entity(tableName = TABLE_MANGA)
class MangaEntity( class MangaEntity(

@ -3,7 +3,7 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA_TAGS import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
@Entity( @Entity(
tableName = TABLE_MANGA_TAGS, tableName = TABLE_MANGA_TAGS,

@ -3,7 +3,7 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_TAGS import org.koitharu.kotatsu.core.db.TABLE_TAGS
@Entity(tableName = TABLE_TAGS) @Entity(tableName = TABLE_TAGS)
class TagEntity( class TagEntity(

@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration10To11 : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `bookmarks` (
`manga_id` INTEGER NOT NULL,
`page_id` INTEGER NOT NULL,
`chapter_id` INTEGER NOT NULL,
`page` INTEGER NOT NULL,
`scroll` INTEGER NOT NULL,
`image` TEXT NOT NULL,
`created_at` INTEGER NOT NULL,
PRIMARY KEY(`manga_id`, `page_id`),
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
""".trimIndent()
)
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
}
}

@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration9To10 : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
}
}

@ -4,7 +4,5 @@ import org.koin.dsl.module
val githubModule val githubModule
get() = module { get() = module {
single { factory { GithubRepository(get()) }
GithubRepository(get())
}
} }

@ -54,18 +54,16 @@ class VersionId(
return result return result
} }
companion object { private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) {
private fun variantWeight(variantType: String) =
when (variantType.lowercase(Locale.ROOT)) {
"a", "alpha" -> 1 "a", "alpha" -> 1
"b", "beta" -> 2 "b", "beta" -> 2
"rc" -> 4 "rc" -> 4
"" -> 8 "" -> 8
else -> 0 else -> 0
} }
}
fun parse(versionName: String): VersionId { fun VersionId(versionName: String): VersionId {
val parts = versionName.substringBeforeLast('-').split('.') val parts = versionName.substringBeforeLast('-').split('.')
val variant = versionName.substringAfterLast('-', "") val variant = versionName.substringAfterLast('-', "")
return VersionId( return VersionId(
@ -73,8 +71,6 @@ class VersionId(
minor = parts.getOrNull(1)?.toIntOrNull() ?: 0, minor = parts.getOrNull(1)?.toIntOrNull() ?: 0,
build = parts.getOrNull(2)?.toIntOrNull() ?: 0, build = parts.getOrNull(2)?.toIntOrNull() ?: 0,
variantType = variant.filter(Char::isLetter), variantType = variant.filter(Char::isLetter),
variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0 variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0,
) )
} }
}
}

@ -1,9 +1,9 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.os.Parcelable import android.os.Parcelable
import java.util.*
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.*
@Parcelize @Parcelize
data class FavouriteCategory( data class FavouriteCategory(
@ -12,4 +12,5 @@ data class FavouriteCategory(
val sortKey: Int, val sortKey: Int,
val order: SortOrder, val order: SortOrder,
val createdAt: Date, val createdAt: Date,
val isTrackingEnabled: Boolean,
) : Parcelable ) : Parcelable

@ -0,0 +1,84 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Cache
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.net.InetAddress
import java.net.UnknownHostException
class DoHManager(
cache: Cache,
private val settings: AppSettings,
) : Dns {
private val bootstrapClient = OkHttpClient.Builder().cache(cache).build()
private var cachedDelegate: Dns? = null
private var cachedProvider: DoHProvider? = null
override fun lookup(hostname: String): List<InetAddress> {
return getDelegate().lookup(hostname)
}
@Synchronized
private fun getDelegate(): Dns {
var delegate = cachedDelegate
val provider = settings.dnsOverHttps
if (delegate == null || provider != cachedProvider) {
delegate = createDelegate(provider)
cachedDelegate = delegate
cachedProvider = provider
}
return delegate
}
private fun createDelegate(provider: DoHProvider): Dns = when (provider) {
DoHProvider.NONE -> Dns.SYSTEM
DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns.google/dns-query".toHttpUrl())
.bootstrapDnsHosts(
listOfNotNull(
tryGetByIp("8.8.4.4"),
tryGetByIp("8.8.8.8"),
tryGetByIp("2001:4860:4860::8888"),
tryGetByIp("2001:4860:4860::8844"),
)
).build()
DoHProvider.CLOUDFLARE -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
listOfNotNull(
tryGetByIp("162.159.36.1"),
tryGetByIp("162.159.46.1"),
tryGetByIp("1.1.1.1"),
tryGetByIp("1.0.0.1"),
tryGetByIp("162.159.132.53"),
tryGetByIp("2606:4700:4700::1111"),
tryGetByIp("2606:4700:4700::1001"),
tryGetByIp("2606:4700:4700::0064"),
tryGetByIp("2606:4700:4700::6400"),
)
).build()
DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
listOfNotNull(
tryGetByIp("94.140.14.140"),
tryGetByIp("94.140.14.141"),
tryGetByIp("2a10:50c0::1:ff"),
tryGetByIp("2a10:50c0::2:ff"),
)
).build()
}
private fun tryGetByIp(ip: String): InetAddress? = try {
InetAddress.getByName(ip)
} catch (e: UnknownHostException) {
e.printStackTraceDebug()
null
}
}

@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.network
enum class DoHProvider {
NONE, GOOGLE, CLOUDFLARE, ADGUARD
}

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network
import java.util.concurrent.TimeUnit
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.dsl.bind import org.koin.dsl.bind
@ -8,17 +7,20 @@ import org.koin.dsl.module
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import java.util.concurrent.TimeUnit
val networkModule val networkModule
get() = module { get() = module {
single { AndroidCookieJar() } bind CookieJar::class single { AndroidCookieJar() } bind CookieJar::class
single { single {
val cache = get<LocalStorageManager>().createHttpCache()
OkHttpClient.Builder().apply { OkHttpClient.Builder().apply {
connectTimeout(20, TimeUnit.SECONDS) connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS)
cookieJar(get()) cookieJar(get())
cache(get<LocalStorageManager>().createHttpCache()) dns(DoHManager(cache, get()))
cache(cache)
addInterceptor(UserAgentInterceptor()) addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())
}.build() }.build()

@ -5,12 +5,12 @@ import android.content.Context
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import android.media.ThumbnailUtils import android.media.ThumbnailUtils
import android.os.Build import android.os.Build
import android.util.Size
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.PixelSize
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -54,7 +54,7 @@ class ShortcutsRepository(
val bmp = coil.execute( val bmp = coil.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)
.size(iconSize) .size(iconSize.width, iconSize.height)
.build() .build()
).requireBitmap() ).requireBitmap()
ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0) ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
@ -74,14 +74,14 @@ class ShortcutsRepository(
) )
} }
private fun getIconSize(context: Context): PixelSize { private fun getIconSize(context: Context): Size {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
(context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let { (context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let {
PixelSize(it.iconMaxWidth, it.iconMaxHeight) Size(it.iconMaxWidth, it.iconMaxHeight)
} }
} else { } else {
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let { (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let {
PixelSize(it, it) Size(it, it)
} }
} }
} }

@ -2,17 +2,19 @@ package org.koitharu.kotatsu.core.parser
import android.net.Uri import android.net.Uri
import coil.map.Mapper import coil.map.Mapper
import coil.request.Options
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
class FaviconMapper() : Mapper<Uri, HttpUrl> { class FaviconMapper : Mapper<Uri, HttpUrl> {
override fun map(data: Uri): HttpUrl { override fun map(data: Uri, options: Options): HttpUrl? {
if (data.scheme != "favicon") {
return null
}
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart) val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
val repo = MangaRepository(mangaSource) as RemoteMangaRepository val repo = MangaRepository(mangaSource) as RemoteMangaRepository
return repo.getFaviconUrl().toHttpUrl() return repo.getFaviconUrl().toHttpUrl()
} }
override fun handles(data: Uri) = data.scheme == "favicon"
} }

@ -16,6 +16,7 @@ import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
@ -78,7 +79,10 @@ class AppSettings(context: Context) {
get() = prefs.getLong(KEY_APP_UPDATE, 0L) get() = prefs.getLong(KEY_APP_UPDATE, 0L)
set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) } set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) }
val trackerNotifications: Boolean val isTrackerEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
val isTrackerNotificationsEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true) get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
var notificationSound: Uri var notificationSound: Uri
@ -95,8 +99,11 @@ class AppSettings(context: Context) {
val readerAnimation: Boolean val readerAnimation: Boolean
get() = prefs.getBoolean(KEY_READER_ANIMATION, false) get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
val isPreferRtlReader: Boolean val defaultReaderMode: ReaderMode
get() = prefs.getBoolean(KEY_READER_PREFER_RTL, false) get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD)
val isReaderModeDetectionEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true)
var historyGrouping: Boolean var historyGrouping: Boolean
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true) get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
@ -185,6 +192,9 @@ class AppSettings(context: Context) {
get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false) get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false)
set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) } set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) }
val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean { fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
NETWORK_ALWAYS -> true NETWORK_ALWAYS -> true
@ -269,15 +279,19 @@ class AppSettings(context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers" const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACK_SOURCES = "track_sources" const val KEY_TRACK_SOURCES = "track_sources"
const val KEY_TRACK_CATEGORIES = "track_categories"
const val KEY_TRACK_WARNING = "track_warning" const val KEY_TRACK_WARNING = "track_warning"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light" const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
const val KEY_READER_ANIMATION = "reader_animation" const val KEY_READER_ANIMATION = "reader_animation"
const val KEY_READER_PREFER_RTL = "reader_prefer_rtl" const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password" const val KEY_APP_PASSWORD = "app_password"
const val KEY_PROTECT_APP = "protect_app" const val KEY_PROTECT_APP = "protect_app"
const val KEY_APP_VERSION = "app_version" const val KEY_APP_VERSION = "app_version"
@ -297,6 +311,7 @@ class AppSettings(context: Context) {
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

@ -0,0 +1,35 @@
package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.flow.flow
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer()
emit(lastValue)
observe().collect {
if (it == key) {
val value = valueProducer()
if (value != lastValue) {
emit(value)
}
lastValue = value
}
}
}
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)
}
}
}
}

@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import kotlin.system.exitProcess import kotlin.system.exitProcess
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler { class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
@ -13,7 +14,7 @@ class AppCrashHandler(private val applicationContext: Context) : Thread.Uncaught
try { try {
applicationContext.startActivity(intent) applicationContext.startActivity(intent)
} catch (t: Throwable) { } catch (t: Throwable) {
t.printStackTrace() t.printStackTraceDebug()
} }
Log.e("CRASH", e.message, e) Log.e("CRASH", e.message, e)
exitProcess(1) exitProcess(1)

@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.ui
import coil.ComponentRegistry import coil.ComponentRegistry
import coil.ImageLoader import coil.ImageLoader
import coil.util.CoilUtils import coil.disk.DiskCache
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.parser.FaviconMapper import org.koitharu.kotatsu.core.parser.FaviconMapper
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
val uiModule val uiModule
@ -14,15 +16,23 @@ val uiModule
single { single {
val httpClientFactory = { val httpClientFactory = {
get<OkHttpClient>().newBuilder() get<OkHttpClient>().newBuilder()
.cache(CoilUtils.createDefaultCache(androidContext())) .cache(null)
.build()
}
val diskCacheFactory = {
val context = androidContext()
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
.build() .build()
} }
ImageLoader.Builder(androidContext()) ImageLoader.Builder(androidContext())
.okHttpClient(httpClientFactory) .okHttpClient(httpClientFactory)
.launchInterceptorChainOnMainThread(false) .interceptorDispatcher(Dispatchers.Default)
.componentRegistry( .diskCache(diskCacheFactory)
.components(
ComponentRegistry.Builder() ComponentRegistry.Builder()
.add(CbzFetcher()) .add(CbzFetcher.Factory())
.add(FaviconMapper()) .add(FaviconMapper())
.build() .build()
).build() ).build()

@ -8,6 +8,6 @@ val detailsModule
get() = module { get() = module {
viewModel { intent -> viewModel { intent ->
DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get()) DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get())
} }
} }

@ -0,0 +1,6 @@
package org.koitharu.kotatsu.details.domain
class BranchComparator : Comparator<String?> {
override fun compare(o1: String?, o2: String?): Int = compareValues(o1, o2)
}

@ -83,6 +83,9 @@ class DetailsActivity :
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) {
binding.snackbar.show(messageText = getString(it), longDuration = false)
}
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
} }

@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
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 coil.ImageLoader import coil.ImageLoader
@ -21,10 +22,14 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
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
@ -41,7 +46,8 @@ class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(), BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener, View.OnClickListener,
View.OnLongClickListener, View.OnLongClickListener,
ChipsView.OnChipClickListener { ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark> {
private val viewModel by sharedViewModel<DetailsViewModel>() private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE) private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
@ -69,6 +75,7 @@ class DetailsFragment :
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -76,6 +83,24 @@ class DetailsFragment :
inflater.inflate(R.menu.opt_details_info, menu) inflater.inflate(R.menu.opt_details_info, menu)
} }
override fun onItemClick(item: Bookmark, view: View) {
val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.measuredWidth, view.measuredHeight)
startActivity(ReaderActivity.newIntent(view.context, item), options.toBundle())
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_bookmark)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_remove -> viewModel.removeBookmark(item)
}
true
}
menu.show()
return true
}
private fun onMangaUpdated(manga: Manga) { private fun onMangaUpdated(manga: Manga) {
with(binding) { with(binding) {
// Main // Main
@ -176,11 +201,25 @@ class DetailsFragment :
} }
} }
private fun onBookmarksChanged(bookmarks: List<Bookmark>) {
var adapter = binding.recyclerViewBookmarks.adapter as? BookmarksAdapter
binding.groupBookmarks.isGone = bookmarks.isEmpty()
if (adapter != null) {
adapter.items = bookmarks
} else {
adapter = BookmarksAdapter(coil, viewLifecycleOwner, this)
adapter.items = bookmarks
binding.recyclerViewBookmarks.adapter = adapter
val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing)
binding.recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing))
}
}
override fun onClick(v: View) { override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
when (v.id) { when (v.id) {
R.id.button_favorite -> { R.id.button_favorite -> {
FavouriteCategoriesDialog.show(childFragmentManager, manga) FavouriteCategoriesBottomSheet.show(childFragmentManager, manga)
} }
R.id.button_read -> { R.id.button_read -> {
val chapterId = viewModel.readingHistory.value?.chapterId val chapterId = viewModel.readingHistory.value?.chapterId
@ -283,7 +322,7 @@ class DetailsFragment :
.target(binding.imageViewCover) .target(binding.imageViewCover)
if (currentCover != null) { if (currentCover != null) {
request.data(manga.largeCoverUrl ?: return) request.data(manga.largeCoverUrl ?: return)
.placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey) .placeholderMemoryCacheKey(CoilUtils.result(binding.imageViewCover)?.request?.memoryCacheKey)
.fallback(currentCover) .fallback(currentCover)
} else { } else {
request.crossfade(true) request.crossfade(true)

@ -1,121 +1,104 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat import androidx.lifecycle.*
import androidx.lifecycle.asFlow import kotlinx.coroutines.*
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import org.koitharu.kotatsu.R
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
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.details.domain.BranchComparator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
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.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
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.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.iterator import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.IOException import java.io.IOException
class DetailsViewModel( class DetailsViewModel(
private val intent: MangaIntent, intent: MangaIntent,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository, favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val mangaDataRepository: MangaDataRepository, mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
private val delegate = MangaDetailsDelegate(
intent = intent,
settings = settings,
mangaDataRepository = mangaDataRepository,
historyRepository = historyRepository,
localMangaRepository = localMangaRepository,
)
private var loadingJob: Job private var loadingJob: Job
private val mangaData = MutableStateFlow(intent.manga)
private val selectedBranch = MutableStateFlow<String?>(null)
private val history = mangaData.mapNotNull { it?.id } val onShowToast = SingleLiveEvent<Int>()
.distinctUntilChanged()
.flatMapLatest { mangaId ->
historyRepository.observeOne(mangaId)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val favourite = mangaData.mapNotNull { it?.id } private val history = historyRepository.observeOne(delegate.mangaId)
.distinctUntilChanged() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
.flatMapLatest { mangaId ->
favouritesRepository.observeCategoriesIds(mangaId).map { it.isNotEmpty() }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = mangaData.mapNotNull { it?.id } private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
.distinctUntilChanged() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
.mapLatest { mangaId ->
trackingRepository.getNewChaptersCount(mangaId) private val newChapters = viewModelScope.async(Dispatchers.Default) {
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) trackingRepository.getNewChaptersCount(delegate.mangaId)
}
// Remote manga for saved and saved for remote
private val relatedManga = MutableStateFlow<Manga?>(null)
private val chaptersQuery = MutableStateFlow("") private val chaptersQuery = MutableStateFlow("")
private val chaptersReversed = settings.observe() private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse }
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
.map { settings.chaptersReverse }
.onStart { emit(settings.chaptersReverse) }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val manga = mangaData.filterNotNull() val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext)
.asLiveData(viewModelScope.coroutineContext) val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext)
val favouriteCategories = favourite val newChaptersCount = liveData(viewModelScope.coroutineContext) { emit(newChapters.await()) }
.asLiveData(viewModelScope.coroutineContext) val readingHistory = history.asLiveData(viewModelScope.coroutineContext)
val newChaptersCount = newChapters val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
.asLiveData(viewModelScope.coroutineContext)
val readingHistory = history val bookmarks = delegate.manga.flatMapLatest {
.asLiveData(viewModelScope.coroutineContext) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
val isChaptersReversed = chaptersReversed }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Manga>()
val branches = mangaData.map { val branches: LiveData<List<String?>> = delegate.manga.map {
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() val chapters = it?.chapters ?: return@map emptyList()
chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator())
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchIndex = combine( val selectedBranchIndex = combine(
branches.asFlow(), branches.asFlow(),
selectedBranch delegate.selectedBranch
) { branches, selected -> ) { branches, selected ->
branches.indexOf(selected) branches.indexOf(selected)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val isChaptersEmpty = mangaData.mapNotNull { m -> val isChaptersEmpty: LiveData<Boolean> = delegate.manga.map { m ->
m?.run { chapters.isNullOrEmpty() } m != null && m.chapters.isNullOrEmpty()
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
val chapters = combine( val chapters = combine(
combine( combine(
mangaData.map { it?.chapters.orEmpty() }, delegate.manga,
relatedManga, delegate.relatedManga,
history.map { it?.chapterId }, history,
newChapters, delegate.selectedBranch,
selectedBranch ) { manga, related, history, branch ->
) { chapters, related, currentId, newCount, branch -> delegate.mapChapters(manga, related, history, newChapters.await(), branch)
val relatedChapters = related?.chapters
if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
mapChaptersWithSource(chapters, relatedChapters, currentId, newCount, branch)
} else {
mapChapters(chapters, relatedChapters, currentId, newCount, branch)
}
}, },
chaptersReversed, chaptersReversed,
chaptersQuery, chaptersQuery,
@ -124,7 +107,7 @@ class DetailsViewModel(
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchValue: String? val selectedBranchValue: String?
get() = selectedBranch.value get() = delegate.selectedBranch.value
init { init {
loadingJob = doLoad() loadingJob = doLoad()
@ -136,7 +119,11 @@ class DetailsViewModel(
} }
fun deleteLocal() { fun deleteLocal() {
val m = mangaData.value ?: return val m = delegate.manga.value
if (m == null) {
onShowToast.call(R.string.file_not_found)
return
}
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m) val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
@ -149,16 +136,23 @@ class DetailsViewModel(
} }
} }
fun removeBookmark(bookmark: Bookmark) {
launchJob {
bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId)
onShowToast.call(R.string.bookmark_removed)
}
}
fun setChaptersReversed(newValue: Boolean) { fun setChaptersReversed(newValue: Boolean) {
settings.chaptersReverse = newValue settings.chaptersReverse = newValue
} }
fun setSelectedBranch(branch: String?) { fun setSelectedBranch(branch: String?) {
selectedBranch.value = branch delegate.selectedBranch.value = branch
} }
fun getRemoteManga(): Manga? { fun getRemoteManga(): Manga? {
return relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL } return delegate.relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
} }
fun performChapterSearch(query: String?) { fun performChapterSearch(query: String?) {
@ -166,7 +160,7 @@ class DetailsViewModel(
} }
fun onDownloadComplete(downloadedManga: Manga) { fun onDownloadComplete(downloadedManga: Manga) {
val currentManga = mangaData.value ?: return val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.id) { if (currentManga.id != downloadedManga.id) {
return return
} }
@ -177,142 +171,16 @@ class DetailsViewModel(
runCatching { runCatching {
localMangaRepository.getDetails(downloadedManga) localMangaRepository.getDetails(downloadedManga)
}.onSuccess { }.onSuccess {
relatedManga.value = it delegate.relatedManga.value = it
}.onFailure { }.onFailure {
if (BuildConfig.DEBUG) { it.printStackTraceDebug()
it.printStackTrace()
}
} }
} }
} }
} }
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
var manga = mangaDataRepository.resolveIntent(intent) delegate.doLoad()
?: throw MangaNotFoundException("Cannot find manga")
mangaData.value = manga
manga = MangaRepository(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) {
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else {
predictBranch(manga.chapters)
}
mangaData.value = manga
relatedManga.value = runCatching {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
MangaRepository(m.source).getDetails(m)
} else {
localMangaRepository.findSavedManga(manga)
}
}.onFailure { error ->
if (BuildConfig.DEBUG) error.printStackTrace()
}.getOrNull()
}
private fun mapChapters(
chapters: List<MangaChapter>,
downloadedChapters: List<MangaChapter>?,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size)
val dateFormat = settings.getDateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapToSet { it.id }
for (i in chapters.indices) {
val chapter = chapters[i]
if (chapter.branch != branch) {
continue
}
result += chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = downloadedIds?.contains(chapter.id) == true,
dateFormat = dateFormat,
)
}
return result
}
private fun mapChaptersWithSource(
chapters: List<MangaChapter>,
sourceChapters: List<MangaChapter>,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount
val dateFormat = settings.getDateFormat()
for (i in sourceChapters.indices) {
val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id)
if (chapter.branch != branch) {
continue
}
result += localChapter?.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
) ?: chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = true,
isDownloaded = false,
dateFormat = dateFormat,
)
}
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapNotNullTo(result) {
if (it.branch == branch) {
it.toListItem(
isCurrent = false,
isUnread = true,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
} else {
null
}
}
result.sortBy { it.chapter.number }
}
return result
}
private fun predictBranch(chapters: List<MangaChapter>?): String? {
if (chapters.isNullOrEmpty()) {
return null
}
val groups = chapters.groupBy { it.branch }
for (locale in LocaleListCompat.getAdjustedDefault()) {
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.getDisplayName(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
}
return groups.maxByOrNull { it.value.size }?.key
} }
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> { private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {

@ -0,0 +1,184 @@
package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class MangaDetailsDelegate(
private val intent: MangaIntent,
private val settings: AppSettings,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
) {
private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow<String?>(null)
// Remote manga for saved and saved for remote
val relatedManga = MutableStateFlow<Manga?>(null)
val manga: StateFlow<Manga?>
get() = mangaData
val mangaId = intent.manga?.id ?: intent.mangaId
suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga")
mangaData.value = manga
manga = MangaRepository(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) {
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else {
predictBranch(manga.chapters)
}
mangaData.value = manga
relatedManga.value = runCatching {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
MangaRepository(m.source).getDetails(m)
} else {
localMangaRepository.findSavedManga(manga)
}
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
}
fun mapChapters(
manga: Manga?,
related: Manga?,
history: MangaHistory?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chapters = manga?.chapters ?: return emptyList()
val relatedChapters = related?.chapters
return if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
mapChaptersWithSource(chapters, relatedChapters, history?.chapterId, newCount, branch)
} else {
mapChapters(chapters, relatedChapters, history?.chapterId, newCount, branch)
}
}
private fun mapChapters(
chapters: List<MangaChapter>,
downloadedChapters: List<MangaChapter>?,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size)
val dateFormat = settings.getDateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapToSet { it.id }
for (i in chapters.indices) {
val chapter = chapters[i]
if (chapter.branch != branch) {
continue
}
result += chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = downloadedIds?.contains(chapter.id) == true,
dateFormat = dateFormat,
)
}
return result
}
private fun mapChaptersWithSource(
chapters: List<MangaChapter>,
sourceChapters: List<MangaChapter>,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount
val dateFormat = settings.getDateFormat()
for (i in sourceChapters.indices) {
val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id)
if (chapter.branch != branch) {
continue
}
result += localChapter?.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
) ?: chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = true,
isDownloaded = false,
dateFormat = dateFormat,
)
}
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapNotNullTo(result) {
if (it.branch == branch) {
it.toListItem(
isCurrent = false,
isUnread = true,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
} else {
null
}
}
result.sortBy { it.chapter.number }
}
return result
}
private fun predictBranch(chapters: List<MangaChapter>?): String? {
if (chapters.isNullOrEmpty()) {
return null
}
val groups = chapters.groupBy { it.branch }
for (locale in LocaleListCompat.getAdjustedDefault()) {
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.getDisplayName(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
}
return groups.maxByOrNull { it.value.size }?.key
}
}

@ -1,9 +1,9 @@
package org.koitharu.kotatsu.details.ui.adapter package org.koitharu.kotatsu.details.ui.adapter
import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@ -21,11 +21,7 @@ fun chapterListItemAD(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
) { ) {
val eventListener = object : View.OnClickListener, View.OnLongClickListener { val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
override fun onClick(v: View) = clickListener.onItemClick(item, v)
override fun onLongClick(v: View) = clickListener.onItemLongClick(item, v)
}
itemView.setOnClickListener(eventListener) itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener) itemView.setOnLongClickListener(eventListener)

@ -12,7 +12,6 @@ import kotlinx.coroutines.sync.Semaphore
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okio.IOException import okio.IOException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@ -24,6 +23,7 @@ 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.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork import org.koitharu.kotatsu.utils.ext.waitForNetwork
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
@ -156,9 +156,7 @@ class DownloadManager(
outState.value = DownloadState.Cancelled(startId, manga, cover) outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { e.printStackTraceDebug()
e.printStackTrace()
}
outState.value = DownloadState.Error(startId, manga, cover, e) outState.value = DownloadState.Error(startId, manga, cover, e)
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {

@ -3,10 +3,8 @@ package org.koitharu.kotatsu.download.ui
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@ -17,7 +15,7 @@ import org.koin.android.ext.android.get
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection import org.koitharu.kotatsu.utils.bindServiceWithLifecycle
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() { class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@ -28,11 +26,10 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
val adapter = DownloadsAdapter(lifecycleScope, get()) val adapter = DownloadsAdapter(lifecycleScope, get())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
LifecycleAwareServiceConnection.bindService( bindServiceWithLifecycle(
this, owner = this,
this, service = Intent(this, DownloadService::class.java),
Intent(this, DownloadService::class.java), flags = 0,
0
).service.flatMapLatest { binder -> ).service.flatMapLatest { binder ->
(binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null) (binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
}.onEach { }.onEach {

@ -59,6 +59,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setStyle(null) builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap()) builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions() builder.clearActions()
builder.setVisibility(
if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
}
)
when (state) { when (state) {
is DownloadState.Cancelled -> { is DownloadState.Cancelled -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)

@ -99,8 +99,9 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) { private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch { lifecycleScope.launch {
val startId = job.progressValue.startId val startId = job.progressValue.startId
val timeLeftEstimator = TimeLeftEstimator()
val notification = DownloadNotification(this@DownloadService, startId) val notification = DownloadNotification(this@DownloadService, startId)
try {
val timeLeftEstimator = TimeLeftEstimator()
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L)) notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
job.progressAsFlow() job.progressAsFlow()
.onEach { state -> .onEach { state ->
@ -117,6 +118,7 @@ class DownloadService : BaseService() {
notificationSwitcher.notify(startId, notification.create(state, timeLeft)) notificationSwitcher.notify(startId, notification.create(state, timeLeft))
} }
job.join() job.join()
} finally {
(job.progressValue as? DownloadState.Done)?.let { (job.progressValue as? DownloadState.Done)?.let {
sendBroadcast( sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE) Intent(ACTION_DOWNLOAD_COMPLETE)
@ -134,6 +136,7 @@ class DownloadService : BaseService() {
stopSelf(startId) stopSelf(startId)
} }
} }
}
private fun Flow<DownloadState>.whileActive(): Flow<DownloadState> = transformWhile { state -> private fun Flow<DownloadState>.whileActive(): Flow<DownloadState> = transformWhile { state ->
emit(state) emit(state)

@ -4,13 +4,14 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditViewModel
import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel
val favouritesModule val favouritesModule
get() = module { get() = module {
single { FavouritesRepository(get()) } factory { FavouritesRepository(get(), get()) }
viewModel { categoryId -> viewModel { categoryId ->
FavouritesListViewModel(categoryId.get(), get(), get(), get()) FavouritesListViewModel(categoryId.get(), get(), get(), get())
@ -19,4 +20,5 @@ val favouritesModule
viewModel { manga -> viewModel { manga ->
MangaCategoriesViewModel(manga.get(), get()) MangaCategoriesViewModel(manga.get(), get())
} }
viewModel { params -> FavouritesCategoryEditViewModel(params[0], get(), get()) }
} }

@ -11,4 +11,5 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
sortKey = sortKey, sortKey = sortKey,
order = SortOrder(order, SortOrder.NEWEST), order = SortOrder(order, SortOrder.NEWEST),
createdAt = Date(createdAt), createdAt = Date(createdAt),
isTrackingEnabled = track,
) )

@ -6,6 +6,9 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
abstract class FavouriteCategoriesDao { abstract class FavouriteCategoriesDao {
@Query("SELECT * FROM favourite_categories WHERE category_id = :id")
abstract suspend fun find(id: Int): FavouriteCategoryEntity
@Query("SELECT * FROM favourite_categories ORDER BY sort_key") @Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract suspend fun findAll(): List<FavouriteCategoryEntity> abstract suspend fun findAll(): List<FavouriteCategoryEntity>
@ -13,7 +16,7 @@ abstract class FavouriteCategoriesDao {
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>> abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE category_id = :id") @Query("SELECT * FROM favourite_categories WHERE category_id = :id")
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity> abstract fun observe(id: Long): Flow<FavouriteCategoryEntity?>
@Insert(onConflict = OnConflictStrategy.ABORT) @Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@ -27,9 +30,15 @@ abstract class FavouriteCategoriesDao {
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id") @Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun updateTitle(id: Long, title: String) abstract suspend fun updateTitle(id: Long, title: String)
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean)
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id") @Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
abstract suspend fun updateOrder(id: Long, order: String) abstract suspend fun updateOrder(id: Long, order: String)
@Query("UPDATE favourite_categories SET `track` = :isEnabled WHERE category_id = :id")
abstract suspend fun updateTracking(id: Long, isEnabled: Boolean)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id") @Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun updateSortKey(id: Long, sortKey: Int) abstract suspend fun updateSortKey(id: Long, sortKey: Int)

@ -3,7 +3,7 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITE_CATEGORIES import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
@Entity(tableName = TABLE_FAVOURITE_CATEGORIES) @Entity(tableName = TABLE_FAVOURITE_CATEGORIES)
class FavouriteCategoryEntity( class FavouriteCategoryEntity(
@ -13,4 +13,5 @@ class FavouriteCategoryEntity(
@ColumnInfo(name = "sort_key") val sortKey: Int, @ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "order") val order: String, @ColumnInfo(name = "order") val order: String,
@ColumnInfo(name = "track") val track: Boolean,
) )

@ -3,11 +3,12 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity( @Entity(
tableName = TABLE_FAVOURITES, primaryKeys = ["manga_id", "category_id"], tableName = TABLE_FAVOURITES,
primaryKeys = ["manga_id", "category_id"],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,

@ -43,6 +43,9 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga> abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE category_id = :categoryId)")
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
abstract suspend fun findAllManga(): List<MangaEntity> abstract suspend fun findAllManga(): List<MangaEntity>

@ -1,10 +1,7 @@
package org.koitharu.kotatsu.favourites.domain package org.koitharu.kotatsu.favourites.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
@ -13,9 +10,13 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
class FavouritesRepository(private val db: MangaDatabase) { class FavouritesRepository(
private val db: MangaDatabase,
private val channels: TrackerNotificationChannels,
) {
suspend fun getAllManga(): List<Manga> { suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll() val entities = db.favouritesDao.findAll()
@ -48,6 +49,11 @@ class FavouritesRepository(private val db: MangaDatabase) {
}.distinctUntilChanged() }.distinctUntilChanged()
} }
fun observeCategory(id: Long): Flow<FavouriteCategory?> {
return db.favouriteCategoriesDao.observe(id)
.map { it?.toFavouriteCategory() }
}
fun observeCategories(mangaId: Long): Flow<List<FavouriteCategory>> { fun observeCategories(mangaId: Long): Flow<List<FavouriteCategory>> {
return db.favouritesDao.observe(mangaId).map { entity -> return db.favouritesDao.observe(mangaId).map { entity ->
entity?.categories?.map { it.toFavouriteCategory() }.orEmpty() entity?.categories?.map { it.toFavouriteCategory() }.orEmpty()
@ -58,6 +64,29 @@ class FavouritesRepository(private val db: MangaDatabase) {
return db.favouritesDao.observeIds(mangaId).map { it.toSet() } return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
} }
suspend fun getCategory(id: Long): FavouriteCategory {
return db.favouriteCategoriesDao.find(id.toInt()).toFavouriteCategory()
}
suspend fun createCategory(title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean): FavouriteCategory {
val entity = FavouriteCategoryEntity(
title = title,
createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0,
order = sortOrder.name,
track = isTrackerEnabled,
)
val id = db.favouriteCategoriesDao.insert(entity)
val category = entity.toFavouriteCategory(id)
channels.createChannel(category)
return category
}
suspend fun updateCategory(id: Long, title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean) {
db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled)
}
suspend fun addCategory(title: String): FavouriteCategory { suspend fun addCategory(title: String): FavouriteCategory {
val entity = FavouriteCategoryEntity( val entity = FavouriteCategoryEntity(
title = title, title = title,
@ -65,23 +94,32 @@ class FavouritesRepository(private val db: MangaDatabase) {
sortKey = db.favouriteCategoriesDao.getNextSortKey(), sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0, categoryId = 0,
order = SortOrder.NEWEST.name, order = SortOrder.NEWEST.name,
track = true,
) )
val id = db.favouriteCategoriesDao.insert(entity) val id = db.favouriteCategoriesDao.insert(entity)
return entity.toFavouriteCategory(id) val category = entity.toFavouriteCategory(id)
channels.createChannel(category)
return category
} }
suspend fun renameCategory(id: Long, title: String) { suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.updateTitle(id, title) db.favouriteCategoriesDao.updateTitle(id, title)
channels.renameChannel(id, title)
} }
suspend fun removeCategory(id: Long) { suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id) db.favouriteCategoriesDao.delete(id)
channels.deleteChannel(id)
} }
suspend fun setCategoryOrder(id: Long, order: SortOrder) { suspend fun setCategoryOrder(id: Long, order: SortOrder) {
db.favouriteCategoriesDao.updateOrder(id, order.name) db.favouriteCategoriesDao.updateOrder(id, order.name)
} }
suspend fun setCategoryTracking(id: Long, isEnabled: Boolean) {
db.favouriteCategoriesDao.updateTracking(id, isEnabled)
}
suspend fun reorderCategories(orderedIds: List<Long>) { suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao val dao = db.favouriteCategoriesDao
db.withTransaction { db.withTransaction {
@ -121,6 +159,7 @@ class FavouritesRepository(private val db: MangaDatabase) {
private fun observeOrder(categoryId: Long): Flow<SortOrder> { private fun observeOrder(categoryId: Long): Flow<SortOrder> {
return db.favouriteCategoriesDao.observe(categoryId) return db.favouriteCategoriesDao.observe(categoryId)
.filterNotNull()
.map { x -> SortOrder(x.order, SortOrder.NEWEST) } .map { x -> SortOrder(x.order, SortOrder.NEWEST) }
.distinctUntilChanged() .distinctUntilChanged()
} }

@ -6,6 +6,7 @@ import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -16,12 +17,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.util.ActionModeListener import org.koitharu.kotatsu.base.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
@ -31,13 +33,15 @@ class FavouritesContainerFragment :
BaseFragment<FragmentFavouritesBinding>(), BaseFragment<FragmentFavouritesBinding>(),
FavouritesTabLongClickListener, FavouritesTabLongClickListener,
CategoriesEditDelegate.CategoriesEditCallback, CategoriesEditDelegate.CategoriesEditCallback,
ActionModeListener { ActionModeListener,
View.OnClickListener {
private val viewModel by viewModel<FavouritesCategoriesViewModel>() private val viewModel by viewModel<FavouritesCategoriesViewModel>()
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) { private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
CategoriesEditDelegate(requireContext(), this) CategoriesEditDelegate(requireContext(), this)
} }
private var pagerAdapter: FavouritesPagerAdapter? = null private var pagerAdapter: FavouritesPagerAdapter? = null
private var stubBinding: ItemEmptyStateBinding? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -52,9 +56,7 @@ class FavouritesContainerFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val adapter = FavouritesPagerAdapter(this, this) val adapter = FavouritesPagerAdapter(this, this)
viewModel.visibleCategories.value?.let { viewModel.visibleCategories.value?.let(::onCategoriesChanged)
adapter.replaceData(it)
}
binding.pager.adapter = adapter binding.pager.adapter = adapter
pagerAdapter = adapter pagerAdapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
@ -66,6 +68,7 @@ class FavouritesContainerFragment :
override fun onDestroyView() { override fun onDestroyView() {
pagerAdapter = null pagerAdapter = null
stubBinding = null
super.onDestroyView() super.onDestroyView()
} }
@ -101,6 +104,15 @@ class FavouritesContainerFragment :
private fun onCategoriesChanged(categories: List<CategoryListModel>) { private fun onCategoriesChanged(categories: List<CategoryListModel>) {
pagerAdapter?.replaceData(categories) pagerAdapter?.replaceData(categories)
if (categories.isEmpty()) {
binding.pager.isVisible = false
binding.tabs.isVisible = false
showStub()
} else {
binding.pager.isVisible = true
binding.tabs.isVisible = true
(stubBinding?.root ?: binding.stubEmptyState).isVisible = false
}
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -130,26 +142,14 @@ class FavouritesContainerFragment :
return true return true
} }
override fun onDeleteCategory(category: FavouriteCategory) { override fun onClick(v: View) {
viewModel.deleteCategory(category.id) when (v.id) {
R.id.button_retry -> startActivity(FavouritesCategoryEditActivity.newIntent(v.context))
} }
override fun onRenameCategory(category: FavouriteCategory, newName: String) {
viewModel.renameCategory(category.id, newName)
}
override fun onCreateCategory(name: String) {
viewModel.createCategory(name)
} }
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) { override fun onDeleteCategory(category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return viewModel.deleteCategory(category.id)
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
menuItem.isCheckable = true
menuItem.isChecked = item == category.order
}
submenu.setGroupCheckable(R.id.group_order, true, true)
} }
private fun TabLayout.setTabsEnabled(enabled: Boolean) { private fun TabLayout.setTabsEnabled(enabled: Boolean) {
@ -162,18 +162,11 @@ class FavouritesContainerFragment :
private fun showCategoryMenu(tabView: View, category: FavouriteCategory) { private fun showCategoryMenu(tabView: View, category: FavouriteCategory) {
val menu = PopupMenu(tabView.context, tabView) val menu = PopupMenu(tabView.context, tabView)
menu.inflate(R.menu.popup_category) menu.inflate(R.menu.popup_category)
createOrderSubmenu(menu.menu, category)
menu.setOnMenuItemClickListener { menu.setOnMenuItemClickListener {
when (it.itemId) { when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category) R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_rename -> editDelegate.renameCategory(category) R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(tabView.context, category.id))
R.id.action_create -> editDelegate.createCategory() else -> return@setOnMenuItemClickListener false
R.id.action_order -> return@setOnMenuItemClickListener false
else -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
?: return@setOnMenuItemClickListener false
viewModel.setCategoryOrder(category.id, order)
}
} }
true true
} }
@ -185,7 +178,7 @@ class FavouritesContainerFragment :
menu.inflate(R.menu.popup_category_all) menu.inflate(R.menu.popup_category_all)
menu.setOnMenuItemClickListener { menu.setOnMenuItemClickListener {
when (it.itemId) { when (it.itemId) {
R.id.action_create -> editDelegate.createCategory() R.id.action_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
R.id.action_hide -> viewModel.setAllCategoriesVisible(false) R.id.action_hide -> viewModel.setAllCategoriesVisible(false)
} }
true true
@ -193,6 +186,18 @@ class FavouritesContainerFragment :
menu.show() menu.show()
} }
private fun showStub() {
val stub = stubBinding ?: ItemEmptyStateBinding.bind(binding.stubEmptyState.inflate())
stub.root.isVisible = true
stub.icon.setImageResource(R.drawable.ic_heart_outline)
stub.textPrimary.setText(R.string.text_empty_holder_primary)
stub.textSecondary.setText(R.string.empty_favourite_categories)
stub.buttonRetry.setText(R.string.add)
stub.buttonRetry.isVisible = true
stub.buttonRetry.setOnClickListener(this)
stubBinding = stub
}
companion object { companion object {
fun newInstance() = FavouritesContainerFragment() fun newInstance() = FavouritesContainerFragment()

@ -3,7 +3,6 @@ package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
@ -19,9 +18,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
@ -30,7 +29,8 @@ class CategoriesActivity :
BaseActivity<ActivityCategoriesBinding>(), BaseActivity<ActivityCategoriesBinding>(),
OnListItemClickListener<FavouriteCategory>, OnListItemClickListener<FavouriteCategory>,
View.OnClickListener, View.OnClickListener,
CategoriesEditDelegate.CategoriesEditCallback, AllCategoriesToggleListener { CategoriesEditDelegate.CategoriesEditCallback,
AllCategoriesToggleListener {
private val viewModel by viewModel<FavouritesCategoriesViewModel>() private val viewModel by viewModel<FavouritesCategoriesViewModel>()
@ -56,23 +56,17 @@ class CategoriesActivity :
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.fab_add -> editDelegate.createCategory() R.id.fab_add -> startActivity(FavouritesCategoryEditActivity.newIntent(this))
} }
} }
override fun onItemClick(item: FavouriteCategory, view: View) { override fun onItemClick(item: FavouriteCategory, view: View) {
val menu = PopupMenu(view.context, view) val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_category) menu.inflate(R.menu.popup_category)
createOrderSubmenu(menu.menu, item)
menu.setOnMenuItemClickListener { menuItem -> menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) { when (menuItem.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(item) R.id.action_remove -> editDelegate.deleteCategory(item)
R.id.action_rename -> editDelegate.renameCategory(item) R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(this, item.id))
R.id.action_order -> return@setOnMenuItemClickListener false
else -> {
val order = SORT_ORDERS.getOrNull(menuItem.order) ?: return@setOnMenuItemClickListener false
viewModel.setCategoryOrder(item.id, order)
}
} }
true true
} }
@ -116,29 +110,6 @@ class CategoriesActivity :
viewModel.deleteCategory(category.id) viewModel.deleteCategory(category.id)
} }
override fun onRenameCategory(category: FavouriteCategory, newName: String) {
viewModel.renameCategory(category.id, newName)
}
override fun onCreateCategory(name: String) {
viewModel.createCategory(name)
}
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for ((i, item) in SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(
R.id.group_order,
Menu.NONE,
i,
item.titleRes
)
menuItem.isCheckable = true
menuItem.isChecked = item == category.order
}
submenu.setGroupCheckable(R.id.group_order, true, true)
}
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback( private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0 ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
) { ) {

@ -40,7 +40,10 @@ class CategoriesAdapter(
newItem: CategoryListModel, newItem: CategoryListModel,
): Any? = when { ): Any? = when {
oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit
else -> super.getChangePayload(oldItem, newItem) oldItem is CategoryListModel.CategoryItem &&
newItem is CategoryListModel.CategoryItem &&
oldItem.category.title != newItem.category.title -> null
else -> Unit
} }
} }
} }

@ -1,15 +1,10 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context import android.content.Context
import android.text.InputType
import android.widget.Toast
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.base.ui.dialog.TextInputDialog
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
private const val MAX_TITLE_LENGTH = 24
class CategoriesEditDelegate( class CategoriesEditDelegate(
private val context: Context, private val context: Context,
private val callback: CategoriesEditCallback private val callback: CategoriesEditCallback
@ -26,49 +21,8 @@ class CategoriesEditDelegate(
.show() .show()
} }
fun renameCategory(category: FavouriteCategory) {
TextInputDialog.Builder(context)
.setTitle(R.string.rename)
.setText(category.title)
.setHint(R.string.enter_category_name)
.setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
.setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.rename) { _, name ->
val trimmed = name.trim()
if (trimmed.isEmpty()) {
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
} else {
callback.onRenameCategory(category, name)
}
}.create()
.show()
}
fun createCategory() {
TextInputDialog.Builder(context)
.setTitle(R.string.add_new_category)
.setHint(R.string.enter_category_name)
.setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
.setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.add) { _, name ->
val trimmed = name.trim()
if (trimmed.isEmpty()) {
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
} else {
callback.onCreateCategory(trimmed)
}
}.create()
.show()
}
interface CategoriesEditCallback { interface CategoriesEditCallback {
fun onDeleteCategory(category: FavouriteCategory) fun onDeleteCategory(category: FavouriteCategory)
fun onRenameCategory(category: FavouriteCategory, newName: String)
fun onCreateCategory(name: String)
} }
} }

@ -3,13 +3,13 @@ package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
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.prefs.observeAsFlow
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
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import java.util.* import java.util.*
@ -31,33 +31,15 @@ class FavouritesCategoriesViewModel(
repository.observeCategories(), repository.observeCategories(),
observeAllCategoriesVisible(), observeAllCategoriesVisible(),
) { list, showAll -> ) { list, showAll ->
mapCategories(list, showAll, showAll) mapCategories(list, showAll, showAll && list.isNotEmpty())
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
fun createCategory(name: String) {
launchJob {
repository.addCategory(name)
}
}
fun renameCategory(id: Long, name: String) {
launchJob {
repository.renameCategory(id, name)
}
}
fun deleteCategory(id: Long) { fun deleteCategory(id: Long) {
launchJob { launchJob {
repository.removeCategory(id) repository.removeCategory(id)
} }
} }
fun setCategoryOrder(id: Long, order: SortOrder) {
launchJob {
repository.setCategoryOrder(id, order)
}
}
fun setAllCategoriesVisible(isVisible: Boolean) { fun setAllCategoriesVisible(isVisible: Boolean) {
settings.isAllFavouritesVisible = isVisible settings.isAllFavouritesVisible = isVisible
} }
@ -89,9 +71,7 @@ class FavouritesCategoriesViewModel(
return result return result
} }
private fun observeAllCategoriesVisible() = settings.observe() private fun observeAllCategoriesVisible() = settings.observeAsFlow(AppSettings.KEY_ALL_FAVOURITES_VISIBLE) {
.filter { it == AppSettings.KEY_ALL_FAVOURITES_VISIBLE } isAllFavouritesVisible
.map { settings.isAllFavouritesVisible } }
.onStart { emit(settings.isAllFavouritesVisible) }
.distinctUntilChanged()
} }

@ -16,7 +16,7 @@ fun categoryAD(
clickListener.onItemClick(item.category, it) clickListener.onItemClick(item.category, it)
} }
@Suppress("ClickableViewAccessibility") @Suppress("ClickableViewAccessibility")
binding.imageViewHandle.setOnTouchListener { v, event -> binding.imageViewHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) { if (event.actionMasked == MotionEvent.ACTION_DOWN) {
clickListener.onItemLongClick(item.category, itemView) clickListener.onItemLongClick(item.category, itemView)
} else { } else {

@ -45,6 +45,7 @@ sealed interface CategoryListModel : ListModel {
if (category.id != other.category.id) return false if (category.id != other.category.id) return false
if (category.title != other.category.title) return false if (category.title != other.category.title) return false
if (category.order != other.category.order) return false if (category.order != other.category.order) return false
if (category.isTrackingEnabled != other.category.isTrackingEnabled) return false
return true return true
} }
@ -53,6 +54,7 @@ sealed interface CategoryListModel : ListModel {
var result = category.id.hashCode() var result = category.id.hashCode()
result = 31 * result + category.title.hashCode() result = 31 * result + category.title.hashCode()
result = 31 * result + category.order.hashCode() result = 31 * result + category.order.hashCode()
result = 31 * result + category.isTrackingEnabled.hashCode()
return result return result
} }
} }

@ -0,0 +1,147 @@
package org.koitharu.kotatsu.favourites.ui.categories.edit
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>(), AdapterView.OnItemClickListener {
private val viewModel by viewModel<FavouritesCategoryEditViewModel> {
parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID))
}
private var selectedSortOrder: SortOrder? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityCategoryEditBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
}
initSortSpinner()
viewModel.onSaved.observe(this) { finishAfterTransition() }
viewModel.category.observe(this, ::onCategoryChanged)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.onError.observe(this, ::onError)
viewModel.isTrackerEnabled.observe(this) {
binding.switchTracker.isVisible = it
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putSerializable(KEY_SORT_ORDER, selectedSortOrder)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
val order = savedInstanceState.getSerializable(KEY_SORT_ORDER)
if (order != null && order is SortOrder) {
selectedSortOrder = order
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_config, menu)
menu.findItem(R.id.action_done)?.setTitle(R.string.save)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_done -> {
viewModel.save(
title = binding.editName.text?.toString().orEmpty(),
sortOrder = getSelectedSortOrder(),
isTrackerEnabled = binding.switchTracker.isChecked,
)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.scrollView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
binding.toolbar.updatePadding(
top = insets.top,
)
}
override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedSortOrder = CategoriesActivity.SORT_ORDERS.getOrNull(position)
}
private fun onCategoryChanged(category: FavouriteCategory?) {
setTitle(if (category == null) R.string.create_category else R.string.edit_category)
if (selectedSortOrder != null) {
return
}
binding.editName.setText(category?.title)
selectedSortOrder = category?.order
val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes)
binding.editSort.setText(sortText, false)
binding.switchTracker.isChecked = category?.isTrackingEnabled ?: true
}
private fun onError(e: Throwable) {
binding.textViewError.text = e.getDisplayMessage(resources)
binding.textViewError.isVisible = true
}
private fun onLoadingStateChanged(isLoading: Boolean) {
binding.editSort.isEnabled = !isLoading
binding.editName.isEnabled = !isLoading
binding.switchTracker.isEnabled = !isLoading
if (isLoading) {
binding.textViewError.isVisible = false
}
}
private fun initSortSpinner() {
val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, entries)
binding.editSort.setAdapter(adapter)
binding.editSort.onItemClickListener = this
}
private fun getSelectedSortOrder(): SortOrder {
selectedSortOrder?.let { return it }
val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
val index = entries.indexOf(binding.editSort.text.toString())
return CategoriesActivity.SORT_ORDERS.getOrNull(index) ?: SortOrder.NEWEST
}
companion object {
private const val EXTRA_ID = "id"
private const val KEY_SORT_ORDER = "sort"
private const val NO_ID = -1L
fun newIntent(context: Context, id: Long = NO_ID): Intent {
return Intent(context, FavouritesCategoryEditActivity::class.java)
.putExtra(EXTRA_ID, id)
}
}
}

@ -0,0 +1,53 @@
package org.koitharu.kotatsu.favourites.ui.categories.edit
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.SingleLiveEvent
private const val NO_ID = -1L
class FavouritesCategoryEditViewModel(
private val categoryId: Long,
private val repository: FavouritesRepository,
private val settings: AppSettings,
) : BaseViewModel() {
val onSaved = SingleLiveEvent<Unit>()
val category = MutableLiveData<FavouriteCategory?>()
val isTrackerEnabled = liveData(viewModelScope.coroutineContext + Dispatchers.Default) {
emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources)
}
init {
launchLoadingJob {
category.value = if (categoryId != NO_ID) {
repository.getCategory(categoryId)
} else {
null
}
}
}
fun save(
title: String,
sortOrder: SortOrder,
isTrackerEnabled: Boolean,
) {
launchLoadingJob {
if (categoryId == NO_ID) {
repository.createCategory(title, sortOrder, isTrackerEnabled)
} else {
repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled)
}
onSaved.call(Unit)
}
}
}

@ -17,26 +17,24 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
class FavouriteCategoriesDialog : class FavouriteCategoriesBottomSheet :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(), BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>, OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback, CategoriesEditDelegate.CategoriesEditCallback,
Toolbar.OnMenuItemClickListener { Toolbar.OnMenuItemClickListener, View.OnClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> { private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga }) parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
} }
private var adapter: MangaCategoriesAdapter? = null private var adapter: MangaCategoriesAdapter? = null
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
CategoriesEditDelegate(requireContext(), this@FavouriteCategoriesDialog)
}
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -48,6 +46,7 @@ class FavouriteCategoriesDialog :
adapter = MangaCategoriesAdapter(this) adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter binding.recyclerViewCategories.adapter = adapter
binding.toolbar.setOnMenuItemClickListener(this) binding.toolbar.setOnMenuItemClickListener(this)
binding.itemCreate.setOnClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
@ -60,26 +59,26 @@ class FavouriteCategoriesDialog :
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_create -> { R.id.action_done -> {
editDelegate.createCategory() dismiss()
true true
} }
else -> false else -> false
} }
} }
override fun onClick(v: View) {
when (v.id) {
R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
}
}
override fun onItemClick(item: MangaCategoryItem, view: View) { override fun onItemClick(item: MangaCategoryItem, view: View) {
viewModel.setChecked(item.id, !item.isChecked) viewModel.setChecked(item.id, !item.isChecked)
} }
override fun onDeleteCategory(category: FavouriteCategory) = Unit override fun onDeleteCategory(category: FavouriteCategory) = Unit
override fun onRenameCategory(category: FavouriteCategory, newName: String) = Unit
override fun onCreateCategory(name: String) {
viewModel.createCategory(name)
}
private fun onContentChanged(categories: List<MangaCategoryItem>) { private fun onContentChanged(categories: List<MangaCategoryItem>) {
adapter?.items = categories adapter?.items = categories
} }
@ -95,7 +94,7 @@ class FavouriteCategoriesDialog :
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga)) fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) { fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesBottomSheet().withArgs(1) {
putParcelableArrayList( putParcelableArrayList(
KEY_MANGA_LIST, KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) } manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) }

@ -38,12 +38,6 @@ class MangaCategoriesViewModel(
} }
} }
fun createCategory(name: String) {
launchJob(Dispatchers.Default) {
favouritesRepository.addCategory(name)
}
}
private fun observeCategoriesIds() = if (manga.size == 1) { private fun observeCategoriesIds() = if (manga.size == 1) {
// Fast path // Fast path
favouritesRepository.observeCategoriesIds(manga[0].id) favouritesRepository.observeCategoriesIds(manga[0].id)

@ -1,11 +1,17 @@
package org.koitharu.kotatsu.favourites.ui.list package org.koitharu.kotatsu.favourites.ui.list
import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.view.iterator
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
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
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@ -17,12 +23,54 @@ class FavouritesListFragment : MangaListFragment() {
} }
private val categoryId: Long private val categoryId: Long
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: 0L get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID
override val isSwipeRefreshEnabled = false override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() }
}
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
if (categoryId != NO_ID) {
inflater.inflate(R.menu.opt_favourites_list, menu)
menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
menuItem.isCheckable = true
}
submenu.setGroupCheckable(R.id.group_order, true, true)
}
}
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
val selectedOrder = viewModel.sortOrder.value
for (item in submenu) {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order)
item.isChecked = order == selectedOrder
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when {
item.itemId == R.id.action_order -> false
item.groupId == R.id.group_order -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order) ?: return false
viewModel.setSortOrder(order)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_favourites, menu) mode.menuInflater.inflate(R.menu.mode_favourites, menu)
return super.onCreateActionMode(mode, menu) return super.onCreateActionMode(mode, menu)
@ -48,6 +96,7 @@ class FavouritesListFragment : MangaListFragment() {
companion object { companion object {
const val NO_ID = 0L
private const val ARG_CATEGORY_ID = "category_id" private const val ARG_CATEGORY_ID = "category_id"
fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) { fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) {

@ -1,12 +1,16 @@
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.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
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 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.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.list.domain.CountersProvider import org.koitharu.kotatsu.list.domain.CountersProvider
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
@ -24,8 +28,16 @@ class FavouritesListViewModel(
settings: AppSettings, settings: AppSettings,
) : MangaListViewModel(settings), CountersProvider { ) : MangaListViewModel(settings), CountersProvider {
var sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) {
MutableLiveData(null)
} else {
repository.observeCategory(categoryId)
.map { it?.order }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
}
override val content = combine( override val content = combine(
if (categoryId == 0L) { if (categoryId == NO_ID) {
repository.observeAll(SortOrder.NEWEST) repository.observeAll(SortOrder.NEWEST)
} else { } else {
repository.observeAll(categoryId) repository.observeAll(categoryId)
@ -37,7 +49,7 @@ class FavouritesListViewModel(
EmptyState( EmptyState(
icon = R.drawable.ic_heart_outline, icon = R.drawable.ic_heart_outline,
textPrimary = R.string.text_empty_holder_primary, textPrimary = R.string.text_empty_holder_primary,
textSecondary = if (categoryId == 0L) { textSecondary = if (categoryId == NO_ID) {
R.string.you_have_not_favourites_yet R.string.you_have_not_favourites_yet
} else { } else {
R.string.favourites_category_empty R.string.favourites_category_empty
@ -60,7 +72,7 @@ class FavouritesListViewModel(
return return
} }
launchJob { launchJob {
if (categoryId == 0L) { if (categoryId == NO_ID) {
repository.removeFromFavourites(ids) repository.removeFromFavourites(ids)
} else { } else {
repository.removeFromCategory(categoryId, ids) repository.removeFromCategory(categoryId, ids)
@ -68,6 +80,15 @@ class FavouritesListViewModel(
} }
} }
fun setSortOrder(order: SortOrder) {
if (categoryId == NO_ID) {
return
}
launchJob {
repository.setCategoryOrder(categoryId, order)
}
}
override suspend fun getCounter(mangaId: Long): Int { override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId) return trackingRepository.getNewChaptersCount(mangaId)
} }

@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule val historyModule
get() = module { get() = module {
single { HistoryRepository(get(), get(), get()) } factory { HistoryRepository(get(), get(), get()) }
viewModel { HistoryListViewModel(get(), get(), get(), get()) } viewModel { HistoryListViewModel(get(), get(), get(), get()) }
} }

@ -12,6 +12,10 @@ abstract class HistoryDao {
@Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga> abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
@Transaction
@Query("SELECT * FROM history WHERE manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity?>
@Transaction @Transaction
@Query("SELECT * FROM history ORDER BY updated_at DESC") @Query("SELECT * FROM history ORDER BY updated_at DESC")
abstract fun observeAll(): Flow<List<HistoryWithManga>> abstract fun observeAll(): Flow<List<HistoryWithManga>>
@ -66,4 +70,13 @@ abstract class HistoryDao {
true true
} else false } else false
} }
@Transaction
open suspend fun upsert(entities: Iterable<HistoryEntity>) {
for (e in entities) {
if (update(e) == 0) {
insert(e)
}
}
}
} }

@ -4,7 +4,7 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity( @Entity(

@ -4,6 +4,7 @@ import androidx.room.withTransaction
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 org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
@ -100,6 +101,19 @@ class HistoryRepository(
} }
} }
suspend fun deleteReversible(ids: Collection<Long>): ReversibleHandle {
val entities = db.withTransaction {
val entities = db.historyDao.findAll(ids.toList()).filterNotNull()
for (id in ids) {
db.historyDao.delete(id)
}
entities
}
return ReversibleHandle {
db.historyDao.upsert(entities)
}
}
/** /**
* Try to replace one manga with another one * Try to replace one manga with another one
* Useful for replacing saved manga on deleting it with remove source * Useful for replacing saved manga on deleting it with remove source

@ -7,8 +7,11 @@ import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.reverseAsync
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
@ -22,6 +25,7 @@ class HistoryListFragment : MangaListFragment() {
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) { viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
viewModel.onItemsRemoved.observe(viewLifecycleOwner, ::onItemsRemoved)
} }
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
@ -80,6 +84,12 @@ class HistoryListFragment : MangaListFragment() {
} }
} }
private fun onItemsRemoved(reversibleHandle: ReversibleHandle) {
Snackbar.make(binding.recyclerView, R.string.removed_from_history, Snackbar.LENGTH_LONG)
.setAction(R.string.undo) { reversibleHandle.reverseAsync() }
.show()
}
companion object { companion object {
fun newInstance() = HistoryListFragment() fun newInstance() = HistoryListFragment()

@ -5,17 +5,24 @@ import androidx.lifecycle.viewModelScope
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.plus
import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.os.ShortcutsRepository
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.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
@ -28,12 +35,9 @@ class HistoryListViewModel(
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val isGroupingEnabled = MutableLiveData<Boolean>() val isGroupingEnabled = MutableLiveData<Boolean>()
val onItemsRemoved = SingleLiveEvent<ReversibleHandle>()
private val historyGrouping = settings.observe() private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { historyGrouping }
.filter { it == AppSettings.KEY_HISTORY_GROUPING }
.map { settings.historyGrouping }
.onStart { emit(settings.historyGrouping) }
.distinctUntilChanged()
.onEach { isGroupingEnabled.postValue(it) } .onEach { isGroupingEnabled.postValue(it) }
override val content = combine( override val content = combine(
@ -52,8 +56,10 @@ class HistoryListViewModel(
) )
else -> mapList(list, grouped, mode) else -> mapList(list, grouped, mode)
} }
}.onStart {
loadingCounter.increment()
}.onFirst { }.onFirst {
isLoading.postValue(false) loadingCounter.decrement()
}.catch { }.catch {
it.toErrorState(canRetry = false) it.toErrorState(canRetry = false)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
@ -73,10 +79,13 @@ class HistoryListViewModel(
if (ids.isEmpty()) { if (ids.isEmpty()) {
return return
} }
launchJob { launchJob(Dispatchers.Default) {
repository.delete(ids) val handle = repository.deleteReversible(ids) + ReversibleHandle {
shortcutsRepository.updateShortcuts() shortcutsRepository.updateShortcuts()
} }
shortcutsRepository.updateShortcuts()
onItemsRemoved.postCall(handle)
}
} }
fun setGrouping(isGroupingEnabled: Boolean) { fun setGrouping(isGroupingEnabled: Boolean) {

@ -6,7 +6,6 @@ import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@ -14,7 +13,7 @@ import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import coil.request.CachePolicy import coil.request.CachePolicy
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.target.PoolableViewTarget import coil.target.ViewTarget
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@ -61,16 +60,12 @@ class ImageActivity : BaseActivity<ActivityImageBinding>() {
private class SsivTarget( private class SsivTarget(
override val view: SubsamplingScaleImageView, override val view: SubsamplingScaleImageView,
) : PoolableViewTarget<SubsamplingScaleImageView> { ) : ViewTarget<SubsamplingScaleImageView> {
override fun onStart(placeholder: Drawable?) = setDrawable(placeholder)
override fun onError(error: Drawable?) = setDrawable(error) override fun onError(error: Drawable?) = setDrawable(error)
override fun onSuccess(result: Drawable) = setDrawable(result) override fun onSuccess(result: Drawable) = setDrawable(result)
override fun onClear() = setDrawable(null)
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
return (this === other) || (other is SsivTarget && view == other.view) return (this === other) || (other is SsivTarget && view == other.view)
} }

@ -28,7 +28,7 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
@ -297,7 +297,7 @@ abstract class MangaListFragment :
true true
} }
R.id.action_favourite -> { R.id.action_favourite -> {
FavouriteCategoriesDialog.show(childFragmentManager, selectedItems) FavouriteCategoriesBottomSheet.show(childFragmentManager, selectedItems)
mode.finish() mode.finish()
true true
} }

@ -4,16 +4,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
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.observeAsLiveData
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.MangaListDetailedModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
abstract class MangaListViewModel( abstract class MangaListViewModel(
private val settings: AppSettings, private val settings: AppSettings,
@ -21,20 +19,15 @@ abstract class MangaListViewModel(
abstract val content: LiveData<List<ListModel>> abstract val content: LiveData<List<ListModel>>
val listMode = MutableLiveData<ListMode>() val listMode = MutableLiveData<ListMode>()
val gridScale = settings.observe() val gridScale = settings.observeAsLiveData(
.filter { it == AppSettings.KEY_GRID_SIZE } context = viewModelScope.coroutineContext + Dispatchers.Default,
.map { settings.gridSize / 100f } key = AppSettings.KEY_GRID_SIZE,
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.IO) { valueProducer = { gridSize / 100f },
settings.gridSize / 100f )
}
open fun onRemoveFilterTag(tag: MangaTag) = Unit open fun onRemoveFilterTag(tag: MangaTag) = Unit
protected fun createListModeFlow() = settings.observe() protected fun createListModeFlow() = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.filter { it == AppSettings.KEY_LIST_MODE }
.map { settings.listMode }
.onStart { emit(settings.listMode) }
.distinctUntilChanged()
.onEach { .onEach {
if (listMode.value != it) { if (listMode.value != it) {
listMode.postValue(it) listMode.postValue(it)

@ -13,7 +13,7 @@ fun currentFilterAD(
val chipGroup = itemView as ChipsView val chipGroup = itemView as ChipsView
chipGroup.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { chip, data -> chipGroup.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { _, data ->
listener.onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener) listener.onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener)
} }

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@ -43,6 +44,7 @@ fun mangaGridItemAD(
.fallback(R.drawable.ic_placeholder) .fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder)
.allowRgb565(true) .allowRgb565(true)
.scale(Scale.FILL)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.enqueueWith(coil) .enqueueWith(coil)
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
@ -53,7 +55,7 @@ fun mangaGridItemAD(
badge = null badge = null
imageRequest?.dispose() imageRequest?.dispose()
imageRequest = null imageRequest = null
CoilUtils.clear(binding.imageViewCover) CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null) binding.imageViewCover.setImageDrawable(null)
} }
} }

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@ -44,6 +45,7 @@ fun mangaListDetailedItemAD(
.placeholder(R.drawable.ic_placeholder) .placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) .fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.allowRgb565(true) .allowRgb565(true)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.enqueueWith(coil) .enqueueWith(coil)
@ -57,7 +59,7 @@ fun mangaListDetailedItemAD(
badge = null badge = null
imageRequest?.dispose() imageRequest?.dispose()
imageRequest = null imageRequest = null
CoilUtils.clear(binding.imageViewCover) CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null) binding.imageViewCover.setImageDrawable(null)
} }
} }

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@ -44,6 +45,7 @@ fun mangaListItemAD(
.placeholder(R.drawable.ic_placeholder) .placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) .fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.allowRgb565(true) .allowRgb565(true)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.enqueueWith(coil) .enqueueWith(coil)
@ -55,7 +57,7 @@ fun mangaListItemAD(
badge = null badge = null
imageRequest?.dispose() imageRequest?.dispose()
imageRequest = null imageRequest = null
CoilUtils.clear(binding.imageViewCover) CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null) binding.imageViewCover.setImageDrawable(null)
} }
} }

@ -6,7 +6,6 @@ import android.os.Bundle
import android.view.* import android.view.*
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
@ -14,11 +13,14 @@ import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.BottomSheetToolbarController
class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActionExpandListener, class FilterBottomSheet :
SearchView.OnQueryTextListener, DialogInterface.OnKeyListener { BaseBottomSheet<SheetFilterBinding>(),
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener {
private val viewModel by sharedViewModel<RemoteListViewModel>( private val viewModel by sharedViewModel<RemoteListViewModel>(
owner = { from(requireParentFragment(), requireParentFragment()) } owner = { requireParentFragment() }
) )
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

@ -1,18 +1,18 @@
package org.koitharu.kotatsu.list.ui.filter package org.koitharu.kotatsu.list.ui.filter
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import java.util.*
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.* import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import java.util.* import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class FilterCoordinator( class FilterCoordinator(
private val repository: RemoteMangaRepository, private val repository: RemoteMangaRepository,
@ -153,9 +153,7 @@ class FilterCoordinator(
runCatching { runCatching {
repository.getTags() repository.getTags()
}.onFailure { error -> }.onFailure { error ->
if (BuildConfig.DEBUG) { error.printStackTraceDebug()
error.printStackTrace()
}
}.getOrNull() }.getOrNull()
} }

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

Loading…
Cancel
Save