Merge branch 'devel' into feature/kitsu

# Conflicts:
#	app/src/main/res/values/strings.xml
feature/kitsu
Zakhar Timoshenko 3 years ago
commit cb64740349

@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
versionCode 546
versionName '5.1.2'
versionCode 547
versionName '5.1.3'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -79,7 +79,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:ebcc6391d6') {
implementation('com.github.KotatsuApp:kotatsu-parsers:44e28b40d3') {
exclude group: 'org.json', module: 'json'
}
@ -88,7 +88,7 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.1'
implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
@ -97,7 +97,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0'
@ -106,7 +106,7 @@ dependencies {
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:31.1-android') {
implementation('com.google.guava:guava:32.0.0-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
@ -116,8 +116,8 @@ dependencies {
implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
@ -128,8 +128,8 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.3.0'
implementation 'io.coil-kt:coil-svg:2.3.0'
implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
@ -137,7 +137,7 @@ dependencies {
implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227'

@ -17,7 +17,7 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("", null)
get() = ConfigKey.Domain()
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)

@ -1,6 +1,10 @@
package org.koitharu.kotatsu.bookmarks.data
import androidx.room.*
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@ -18,7 +22,7 @@ abstract class BookmarksDao {
@Transaction
@Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at"
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at",
)
abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
@ -29,5 +33,8 @@ abstract class BookmarksDao {
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)
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
}

@ -1,7 +1,7 @@
package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
import java.util.Date
class Bookmark(
val manga: Manga,
@ -27,9 +27,7 @@ class Bookmark(
if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false
if (percent != other.percent) return false
return true
return percent == other.percent
}
override fun hashCode(): Int {

@ -52,8 +52,14 @@ class BookmarksRepository @Inject constructor(
}
}
suspend fun removeBookmark(mangaId: Long, pageId: Long) {
db.bookmarksDao.delete(mangaId, pageId)
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) {
"Bookmark not found"
}
}
suspend fun removeBookmark(bookmark: Bookmark) {
removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page)
}
suspend fun removeBookmarks(ids: Map<Manga, Set<Long>>): ReversibleHandle {

@ -19,7 +19,9 @@ class BookmarksAdapter(
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.manga.id == newItem.manga.id && oldItem.pageId == newItem.pageId
return oldItem.manga.id == newItem.manga.id &&
oldItem.chapterId == newItem.chapterId &&
oldItem.page == newItem.page
}
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {

@ -40,10 +40,11 @@ import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListExtraProviderImpl
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext
@ -64,6 +65,9 @@ interface AppModule {
@Binds
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
@Binds
fun bindListExtraProvider(impl: ListExtraProviderImpl): ListExtraProvider
companion object {
@Provides
@ -86,7 +90,8 @@ interface AppModule {
@ApplicationContext context: Context,
@MangaHttpClient okHttpClient: OkHttpClient,
mangaRepositoryFactory: MangaRepository.Factory,
pagesCache: PagesCache,
imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
): ImageLoader {
val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir
@ -108,7 +113,8 @@ interface AppModule {
.add(SvgDecoder.Factory())
.add(CbzFetcher.Factory())
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.add(MangaPageFetcher.Factory(context, okHttpClient, pagesCache, mangaRepositoryFactory))
.add(pageFetcherFactory)
.add(imageProxyInterceptor)
.build(),
).build()
}

@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration11To12
import org.koitharu.kotatsu.core.db.migrations.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@ -48,7 +49,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 15
const val DATABASE_VERSION = 16
@Database(
entities = [
@ -100,6 +101,7 @@ val databaseMigrations: Array<Migration>
Migration12To13(),
Migration13To14(),
Migration14To15(),
Migration15To16(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

@ -23,4 +23,5 @@ data class MangaPrefsEntity(
@ColumnInfo(name = "mode") val mode: Int,
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
)

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

@ -13,6 +13,10 @@ class AppProxySelector(
private val settings: AppSettings,
) : ProxySelector() {
init {
setDefault(this)
}
private var cachedProxy: Proxy? = null
override fun select(uri: URI?): List<Proxy> {

@ -14,6 +14,7 @@ object CommonHeaders {
const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization"
const val CACHE_CONTROL = "Cache-Control"
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build()

@ -0,0 +1,103 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import androidx.collection.ArraySet
import coil.intercept.Interceptor
import coil.request.ErrorResult
import coil.request.ImageResult
import coil.request.SuccessResult
import coil.size.Dimension
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ImageProxyInterceptor @Inject constructor(
private val settings: AppSettings,
) : Interceptor {
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val request = chain.request
if (!settings.isImagesProxyEnabled) {
return chain.proceed(request)
}
val url: HttpUrl? = when (val data = request.data) {
is HttpUrl -> data
is String -> data.toHttpUrlOrNull()
else -> null
}
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
return chain.proceed(request)
}
val newUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", url.toString())
.addQueryParameter("fit", "outside")
.addQueryParameter("we", null)
val size = request.sizeResolver.size()
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
val newRequest = request.newBuilder()
.data(newUrl.build())
.build()
val result = chain.proceed(newRequest)
return if (result is SuccessResult) {
result
} else {
logDebug((result as? ErrorResult)?.throwable)
chain.proceed(request).also {
if (it is SuccessResult) {
blacklist.add(url.host)
}
}
}
}
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
if (!settings.isImagesProxyEnabled) {
return okHttp.newCall(request).await()
}
val sourceUrl = request.url
val targetUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", sourceUrl.toString())
.addQueryParameter("we", null)
val newRequest = request.newBuilder()
.url(targetUrl.build())
.build()
return runCatchingCancellable {
okHttp.doCall(newRequest)
}.recover {
logDebug(it)
okHttp.doCall(request).also {
blacklist.add(sourceUrl.host)
}
}.getOrThrow()
}
private suspend fun OkHttpClient.doCall(request: Request): Response {
return newCall(request).await().ensureSuccess()
}
private fun logDebug(e: Throwable?) {
if (BuildConfig.DEBUG) {
Log.w("ImageProxy", e.toString())
}
}
}

@ -59,6 +59,7 @@ interface NetworkModule {
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
proxySelector(AppProxySelector(settings))
proxyAuthenticator(ProxyAuthenticator(settings))
dns(DoHManager(cache, settings))
if (settings.isSSLBypassEnabled) {
bypassSSLErrors()

@ -0,0 +1,45 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Authenticator
import okhttp3.Credentials
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.core.prefs.AppSettings
import java.net.PasswordAuthentication
import java.net.Proxy
class ProxyAuthenticator(
private val settings: AppSettings,
) : Authenticator, java.net.Authenticator() {
init {
setDefault(this)
}
override fun authenticate(route: Route?, response: Response): Request? {
if (!isProxyEnabled()) {
return null
}
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
val credential = Credentials.basic(login, password)
return response.request.newBuilder()
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
.build()
}
override fun getPasswordAuthentication(): PasswordAuthentication? {
if (!isProxyEnabled()) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
return PasswordAuthentication(login, password.toCharArray())
}
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
}

@ -5,14 +5,12 @@ import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@ -22,7 +20,6 @@ import javax.inject.Inject
@Reusable
class MangaDataRepository @Inject constructor(
@MangaHttpClient private val okHttpClient: OkHttpClient,
private val db: MangaDatabase,
) {
@ -42,6 +39,7 @@ class MangaDataRepository @Inject constructor(
entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f,
cfInvert = colorFilter?.isInverted ?: false,
),
)
}
@ -84,8 +82,8 @@ class MangaDataRepository @Inject constructor(
}
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f) {
ReaderColorFilter(cfBrightness, cfContrast)
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert)
} else {
null
}
@ -96,5 +94,6 @@ class MangaDataRepository @Inject constructor(
mode = -1,
cfBrightness = 0f,
cfContrast = 0f,
cfInvert = false,
)
}

@ -22,6 +22,8 @@ interface MangaRepository {
val sortOrders: Set<SortOrder>
var defaultSortOrder: SortOrder
suspend fun getList(offset: Int, query: String): List<Manga>
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga>

@ -39,8 +39,8 @@ class RemoteMangaRepository(
override val sortOrders: Set<SortOrder>
get() = parser.sortOrders
var defaultSortOrder: SortOrder?
get() = getConfig().defaultSortOrder ?: sortOrders.firstOrNull()
override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first()
set(value) {
getConfig().defaultSortOrder = value
}
@ -101,7 +101,7 @@ class RemoteMangaRepository(
}
fun getAvailableMirrors(): List<String> {
return parser.configKeyDomain.presetValues?.toList().orEmpty()
return parser.configKeyDomain.presetValues.toList()
}
private fun getConfig() = parser.config as SourceSettings

@ -86,7 +86,7 @@ class FaviconFetcher(
if (!options.diskCachePolicy.readEnabled) {
return null
}
val snapshot = diskCache.value?.get(diskCacheKey) ?: return null
val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null
return SourceResult(
source = snapshot.toImageSource(),
mimeType = null,
@ -98,12 +98,12 @@ class FaviconFetcher(
if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) {
return null
}
val editor = diskCache.value?.edit(diskCacheKey) ?: return null
val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null
try {
fileSystem.write(editor.data) {
body.source().readAll(this)
}
return editor.commitAndGet()
return editor.commitAndOpenSnapshot()
} catch (e: Throwable) {
try {
editor.abort()

@ -277,6 +277,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderSliderEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_SLIDER, true)
val isImagesProxyEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
@ -295,6 +298,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val proxyPort: Int
get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0
val proxyLogin: String?
get() = prefs.getString(KEY_PROXY_LOGIN, null)?.takeUnless { it.isEmpty() }
val proxyPassword: String?
get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.takeUnless { it.isEmpty() }
var localListOrder: SortOrder
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
@ -449,6 +458,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_TYPE = "proxy_type"
const val KEY_PROXY_ADDRESS = "proxy_address"
const val KEY_PROXY_PORT = "proxy_port"
const val KEY_PROXY_AUTH = "proxy_auth"
const val KEY_PROXY_LOGIN = "proxy_login"
const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy"
// About
const val KEY_APP_UPDATE = "app_update"

@ -18,6 +18,10 @@ import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.getDisplaySize
import com.google.android.material.R as materialR
@Deprecated(
"Use BaseAdaptiveSheet",
replaceWith = ReplaceWith("BaseAdaptiveSheet<B>", "org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet"),
)
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
var viewBinding: B? = null

@ -3,57 +3,36 @@ package org.koitharu.kotatsu.core.ui
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.viewbinding.ViewBinding
@Suppress("DEPRECATION")
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_FULLSCREEN
@Suppress("DEPRECATION")
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_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>(),
View.OnSystemUiVisibilityChangeListener {
BaseActivity<B>() {
private lateinit var insetsControllerCompat: WindowInsetsControllerCompat
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
with(window) {
insetsControllerCompat = WindowInsetsControllerCompat(this, decorView)
statusBarColor = Color.TRANSPARENT
navigationBarColor = Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
decorView.setOnSystemUiVisibilityChangeListener(this@BaseFullscreenActivity)
}
insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
showSystemUI()
}
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
final override fun onSystemUiVisibilityChange(visibility: Int) {
onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0)
}
// TODO WindowInsetsControllerCompat works incorrect
@Suppress("DEPRECATION")
protected fun hideSystemUI() {
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN
insetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
}
@Suppress("DEPRECATION")
protected fun showSystemUI() {
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN
insetsControllerCompat.show(WindowInsetsCompat.Type.systemBars())
}
protected open fun onSystemUiVisibilityChanged(isVisible: Boolean) = Unit
}

@ -3,8 +3,10 @@ package org.koitharu.kotatsu.core.ui.list
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) :
RecyclerView.OnScrollListener() {
abstract class BoundsScrollListener(
@JvmField protected val offsetTop: Int,
@JvmField protected val offsetBottom: Int
) : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
@ -24,9 +26,16 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView)
}
onPostScrolled(recyclerView, firstVisibleItemPosition, visibleItemCount)
}
abstract fun onScrolledToStart(recyclerView: RecyclerView)
abstract fun onScrolledToEnd(recyclerView: RecyclerView)
protected open fun onPostScrolled(
recyclerView: RecyclerView,
firstVisibleItemPosition: Int,
visibleItemCount: Int
) = Unit
}

@ -1,30 +0,0 @@
package org.koitharu.kotatsu.core.ui.list
import androidx.recyclerview.widget.RecyclerView
class ScrollListenerInvalidationObserver(
private val recyclerView: RecyclerView,
private val scrollListener: RecyclerView.OnScrollListener,
) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
invalidateScroll()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
invalidateScroll()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
super.onItemRangeRemoved(positionStart, itemCount)
invalidateScroll()
}
private fun invalidateScroll() {
recyclerView.post {
scrollListener.onScrolled(recyclerView, 0, 0)
}
}
}

@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import androidx.annotation.AttrRes
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
@ -15,6 +16,12 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val fastScroller = FastScroller(context, attrs)
var isFastScrollerEnabled: Boolean = true
set(value) {
field = value
fastScroller.isVisible = value && isVisible
}
init {
fastScroller.id = R.id.fast_scroller
fastScroller.layoutParams = ViewGroup.LayoutParams(
@ -30,7 +37,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
override fun setVisibility(visibility: Int) {
super.setVisibility(visibility)
fastScroller.visibility = visibility
fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
}
override fun onAttachedToWindow() {

@ -56,6 +56,7 @@ class FastScroller @JvmOverloads constructor(
private var bubbleHeight = 0
private var handleHeight = 0
private var viewHeight = 0
private var offset = 0
private var hideScrollbar = true
private var showBubble = true
private var showBubbleAlways = false
@ -137,6 +138,7 @@ class FastScroller @JvmOverloads constructor(
bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL)
val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize)
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
offset = getDimensionPixelOffset(R.styleable.FastScroller_scrollerOffset, offset)
}
setTrackColor(trackColor)
@ -248,7 +250,7 @@ class FastScroller @JvmOverloads constructor(
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
height = 0
setMargins(0, marginTop, 0, marginBottom)
setMargins(offset, marginTop, offset, marginBottom)
}
}
@ -256,13 +258,13 @@ class FastScroller @JvmOverloads constructor(
height = LayoutParams.MATCH_PARENT
anchorGravity = GravityCompat.END
anchorId = recyclerViewId
setMargins(0, marginTop, 0, marginBottom)
setMargins(offset, marginTop, offset, marginBottom)
}
is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
height = LayoutParams.MATCH_PARENT
gravity = GravityCompat.END
setMargins(0, marginTop, 0, marginBottom)
setMargins(offset, marginTop, offset, marginBottom)
}
is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply {
@ -270,7 +272,7 @@ class FastScroller @JvmOverloads constructor(
addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
addRule(RelativeLayout.ALIGN_END, recyclerViewId)
setMargins(0, marginTop, 0, marginBottom)
setMargins(offset, marginTop, offset, marginBottom)
}
else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")

@ -0,0 +1,123 @@
package org.koitharu.kotatsu.core.ui.sheet
import android.app.Dialog
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.sidesheet.SideSheetBehavior
import com.google.android.material.sidesheet.SideSheetCallback
import com.google.android.material.sidesheet.SideSheetDialog
import java.util.LinkedList
sealed class AdaptiveSheetBehavior {
@JvmField
protected val callbacks = LinkedList<AdaptiveSheetCallback>()
abstract var state: Int
abstract var isDraggable: Boolean
open val isHideable: Boolean = true
fun addCallback(callback: AdaptiveSheetCallback) {
callbacks.add(callback)
}
fun removeCallback(callback: AdaptiveSheetCallback) {
callbacks.remove(callback)
}
class Bottom(
private val delegate: BottomSheetBehavior<*>,
) : AdaptiveSheetBehavior() {
override var state: Int
get() = delegate.state
set(value) {
delegate.state = value
}
override var isDraggable: Boolean
get() = delegate.isDraggable
set(value) {
delegate.isDraggable = value
}
override val isHideable: Boolean
get() = delegate.isHideable
var isFitToContents: Boolean
get() = delegate.isFitToContents
set(value) {
delegate.isFitToContents = value
}
init {
delegate.addBottomSheetCallback(
object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
callbacks.forEach { it.onStateChanged(bottomSheet, newState) }
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
callbacks.forEach { it.onSlide(bottomSheet, slideOffset) }
}
},
)
}
}
class Side(
private val delegate: SideSheetBehavior<*>,
) : AdaptiveSheetBehavior() {
override var state: Int
get() = delegate.state
set(value) {
delegate.state = value
}
override var isDraggable: Boolean
get() = delegate.isDraggable
set(value) {
delegate.isDraggable = value
}
init {
delegate.addCallback(
object : SideSheetCallback() {
override fun onStateChanged(sheet: View, newState: Int) {
callbacks.forEach { it.onStateChanged(sheet, newState) }
}
override fun onSlide(sheet: View, slideOffset: Float) {
callbacks.forEach { it.onSlide(sheet, slideOffset) }
}
},
)
}
}
companion object {
const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED
const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING
const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING
const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN
fun from(dialog: Dialog?): AdaptiveSheetBehavior? = when (dialog) {
is BottomSheetDialog -> Bottom(dialog.behavior)
is SideSheetDialog -> Side(dialog.behavior)
else -> null
}
fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? = when (val behavior = lp.behavior) {
is BottomSheetBehavior<*> -> Bottom(behavior)
is SideSheetBehavior<*> -> Side(behavior)
else -> null
}
}
}

@ -0,0 +1,22 @@
package org.koitharu.kotatsu.core.ui.sheet
import android.view.View
interface AdaptiveSheetCallback {
/**
* Called when the sheet changes its state.
*
* @param sheet The sheet view.
* @param newState The new state.
*/
fun onStateChanged(sheet: View, newState: Int)
/**
* Called when the sheet is being dragged.
*
* @param sheet The sheet view.
* @param slideOffset The new offset of this sheet.
*/
fun onSlide(sheet: View, slideOffset: Float) = Unit
}

@ -0,0 +1,94 @@
package org.koitharu.kotatsu.core.ui.sheet
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.withStyledAttributes
import androidx.core.view.isGone
import androidx.core.view.isVisible
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding
class AdaptiveSheetHeaderBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback {
private val binding = LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this)
private var sheetBehavior: AdaptiveSheetBehavior? = null
var title: CharSequence?
get() = binding.shTextViewTitle.text
set(value) {
binding.shTextViewTitle.text = value
}
val isTitleVisible: Boolean
get() = binding.shLayoutSidesheet.isVisible
init {
orientation = VERTICAL
binding.shButtonClose.setOnClickListener { dismissSheet() }
context.withStyledAttributes(
attrs,
R.styleable.AdaptiveSheetHeaderBar, defStyleAttr,
) {
title = getText(R.styleable.AdaptiveSheetHeaderBar_title)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (isInEditMode) {
val isTabled = resources.getBoolean(R.bool.is_tablet)
binding.shDragHandle.isGone = isTabled
binding.shLayoutSidesheet.isVisible = isTabled
} else {
setBottomSheetBehavior(findParentSheetBehavior())
}
}
override fun onDetachedFromWindow() {
setBottomSheetBehavior(null)
super.onDetachedFromWindow()
}
override fun onStateChanged(sheet: View, newState: Int) {
}
fun setTitle(@StringRes resId: Int) {
binding.shTextViewTitle.setText(resId)
}
private fun setBottomSheetBehavior(behavior: AdaptiveSheetBehavior?) {
binding.shDragHandle.isVisible = behavior is AdaptiveSheetBehavior.Bottom
binding.shLayoutSidesheet.isVisible = behavior is AdaptiveSheetBehavior.Side
sheetBehavior?.removeCallback(this)
sheetBehavior = behavior
behavior?.addCallback(this)
}
private fun dismissSheet() {
sheetBehavior?.state = AdaptiveSheetBehavior.STATE_HIDDEN
}
private fun findParentSheetBehavior(): AdaptiveSheetBehavior? {
for (p in parents) {
val layoutParams = (p as? View)?.layoutParams
if (layoutParams is CoordinatorLayout.LayoutParams) {
AdaptiveSheetBehavior.from(layoutParams)?.let {
return it
}
}
}
return null
}
}

@ -0,0 +1,175 @@
package org.koitharu.kotatsu.core.ui.sheet
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.activity.ComponentDialog
import androidx.activity.OnBackPressedDispatcher
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.sidesheet.SideSheetDialog
import org.koitharu.kotatsu.R
import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false
var viewBinding: B? = null
private set
@Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
get() = requireViewBinding()
protected val behavior: AdaptiveSheetBehavior?
get() = AdaptiveSheetBehavior.from(dialog)
val isExpanded: Boolean
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher
get() = (requireDialog() as ComponentDialog).onBackPressedDispatcher
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = onCreateViewBinding(inflater, container)
viewBinding = binding
return binding.root
}
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = requireViewBinding()
onViewBindingCreated(binding, savedInstanceState)
}
override fun onDestroyView() {
viewBinding = null
super.onDestroyView()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
return if (context.resources.getBoolean(R.bool.is_tablet)) {
SideSheetDialog(context, theme)
} else {
BottomSheetDialog(context, theme)
}
}
fun addSheetCallback(callback: AdaptiveSheetCallback) {
val b = behavior ?: return
b.addCallback(callback)
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
?: dialog?.findViewById(materialR.id.coordinator)
if (rootView != null) {
callback.onStateChanged(rootView, b.state)
}
}
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
val b = behavior ?: return
if (isExpanded) {
b.state = BottomSheetBehavior.STATE_EXPANDED
}
if (b is AdaptiveSheetBehavior.Bottom) {
b.isFitToContents = !isFitToContentsDisabled && !isExpanded
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
rootView?.updateLayoutParams {
height = if (isFitToContentsDisabled || isExpanded) {
LayoutParams.MATCH_PARENT
} else {
LayoutParams.WRAP_CONTENT
}
}
}
b.isDraggable = !isLocked
}
protected fun disableFitToContents() {
isFitToContentsDisabled = true
val b = behavior as? AdaptiveSheetBehavior.Bottom ?: return
b.isFitToContents = false
dialog?.findViewById<View>(materialR.id.design_bottom_sheet)?.updateLayoutParams {
height = LayoutParams.MATCH_PARENT
}
}
fun requireViewBinding(): B = checkNotNull(viewBinding) {
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
}
override fun dismiss() {
if (!tryDismissWithAnimation(false)) {
super.dismiss()
}
}
override fun dismissAllowingStateLoss() {
if (!tryDismissWithAnimation(true)) {
super.dismissAllowingStateLoss()
}
}
/**
* Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible,
* false otherwise.
*/
private fun tryDismissWithAnimation(allowingStateLoss: Boolean): Boolean {
val shouldDismissWithAnimation = when (val dialog = dialog) {
is BottomSheetDialog -> dialog.dismissWithAnimation
is SideSheetDialog -> dialog.isDismissWithSheetAnimationEnabled
else -> false
}
val behavior = behavior ?: return false
return if (shouldDismissWithAnimation && behavior.isHideable) {
dismissWithAnimation(behavior, allowingStateLoss)
true
} else {
false
}
}
private fun dismissWithAnimation(behavior: AdaptiveSheetBehavior, allowingStateLoss: Boolean) {
waitingForDismissAllowingStateLoss = allowingStateLoss
if (behavior.state == AdaptiveSheetBehavior.STATE_HIDDEN) {
dismissAfterAnimation()
} else {
behavior.addCallback(SheetDismissCallback())
behavior.state = AdaptiveSheetBehavior.STATE_HIDDEN
}
}
private fun dismissAfterAnimation() {
if (waitingForDismissAllowingStateLoss) {
super.dismissAllowingStateLoss()
} else {
super.dismiss()
}
}
private inner class SheetDismissCallback : AdaptiveSheetCallback {
override fun onStateChanged(sheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
dismissAfterAnimation()
}
}
override fun onSlide(sheet: View, slideOffset: Float) {}
}
}

@ -30,6 +30,7 @@ import com.google.android.material.R as materialR
private const val THROTTLE_DELAY = 200L
@Deprecated("")
class BottomSheetHeaderBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,

@ -6,6 +6,7 @@ import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.View.OnClickListener
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.getColorStateListOrThrow
import androidx.core.view.children
@ -101,6 +102,13 @@ class ChipsView @JvmOverloads constructor(
chip.setTextColor(tint ?: defaultChipTextColor)
chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable
if (model.icon == 0) {
chip.chipIcon = null
chip.isChipIconVisible = false
} else {
chip.setChipIconResource(model.icon)
chip.isChipIconVisible = true
}
chip.isChecked = model.isChecked
chip.tag = model.data
}
@ -134,6 +142,7 @@ class ChipsView @JvmOverloads constructor(
class ChipModel(
@ColorRes val tint: Int,
val title: CharSequence,
@DrawableRes val icon: Int,
val isCheckable: Boolean,
val isChecked: Boolean,
val data: Any? = null,
@ -147,6 +156,7 @@ class ChipsView @JvmOverloads constructor(
if (tint != other.tint) return false
if (title != other.title) return false
if (icon != other.icon) return false
if (isCheckable != other.isCheckable) return false
if (isChecked != other.isChecked) return false
return data == other.data
@ -155,6 +165,7 @@ class ChipsView @JvmOverloads constructor(
override fun hashCode(): Int {
var result = tint.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + icon.hashCode()
result = 31 * result + isCheckable.hashCode()
result = 31 * result + isChecked.hashCode()
result = 31 * result + (data?.hashCode() ?: 0)

@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.util
class CompositeRunnable(
private val children: List<Runnable>,
) : Runnable, Collection<Runnable> by children {
override fun run() {
for (child in children) {
child.run()
}
}
}

@ -8,7 +8,7 @@ class Event<T>(
private var isConsumed = false
suspend fun consume(collector: FlowCollector<T>) {
if (isConsumed) {
if (!isConsumed) {
collector.emit(data)
isConsumed = true
}

@ -8,7 +8,7 @@ import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext
class RetainedLifecycleCoroutineScope(
private val lifecycle: RetainedLifecycle,
val lifecycle: RetainedLifecycle,
) : CoroutineScope, RetainedLifecycle.OnClearedListener {
override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.app.Activity
import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
@ -140,6 +141,13 @@ fun Context.isLowRamDevice(): Boolean {
return activityManager?.isLowRamDevice ?: false
}
val Context.ramAvailable: Long
get() {
val result = MemoryInfo()
activityManager?.getMemoryInfo(result)
return result.availMem
}
fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation(
view,
0,

@ -0,0 +1,23 @@
package org.koitharu.kotatsu.core.util.ext
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
fun BottomSheetBehavior<*>.doOnExpansionsChanged(callback: (isExpanded: Boolean) -> Unit) {
var isExpended = state == BottomSheetBehavior.STATE_EXPANDED
callback(isExpended)
addBottomSheetCallback(
object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
val expanded = newState == BottomSheetBehavior.STATE_EXPANDED
if (expanded != isExpended) {
isExpended = expanded
callback(expanded)
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
},
)
}

@ -8,9 +8,11 @@ import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.lifecycle.RetainedLifecycle
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@ -18,6 +20,9 @@ import kotlin.coroutines.resumeWithException
val processLifecycleScope: LifecycleCoroutineScope
inline get() = ProcessLifecycleOwner.get().lifecycleScope
val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope
inline get() = RetainedLifecycleCoroutineScope(this)
suspend fun Lifecycle.awaitStateAtLeast(state: Lifecycle.State) {
if (currentState.isAtLeast(state)) {
return

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

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
@ -23,3 +24,17 @@ fun Response.parseJsonOrNull(): JSONObject? {
closeQuietly()
}
}
val HttpUrl.isHttpOrHttps: Boolean
get() {
val s = scheme.lowercase()
return s == "https" || s == "http"
}
fun Response.ensureSuccess() = apply {
if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) {
val message = "Invalid response: $code $message at ${request.url}"
closeQuietly()
throw IllegalStateException(message)
}
}

@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.util.ext
import org.koitharu.kotatsu.core.util.CompositeRunnable
@Suppress("UNCHECKED_CAST")
fun <T> Class<T>.castOrNull(obj: Any?): T? {
if (obj == null || !isInstance(obj)) {
@ -7,3 +9,15 @@ fun <T> Class<T>.castOrNull(obj: Any?): T? {
}
return obj as T
}
/* CompositeRunnable */
operator fun Runnable.plus(other: Runnable): Runnable {
val list = ArrayList<Runnable>(this.size + other.size)
if (this is CompositeRunnable) list.addAll(this) else list.add(this)
if (other is CompositeRunnable) list.addAll(other) else list.add(other)
return CompositeRunnable(list)
}
private val Runnable.size: Int
get() = if (this is CompositeRunnable) size else 1

@ -5,6 +5,7 @@ import android.graphics.Color
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
import androidx.annotation.Px
import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils
@ -22,6 +23,22 @@ fun Context.getThemeColor(
it.getColor(0, fallback)
}
@Px
fun Context.getThemeDimensionPixelSize(
@AttrRes resId: Int,
@ColorInt fallback: Int = 0,
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimensionPixelSize(0, fallback)
}
@Px
fun Context.getThemeDimensionPixelOffset(
@AttrRes resId: Int,
@ColorInt fallback: Int = 0,
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimensionPixelOffset(0, fallback)
}
@ColorInt
fun Context.getThemeColor(
@AttrRes resId: Int,

@ -1,10 +1,12 @@
package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.fragment.app.createViewModelLazy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewmodel.CreationExtras
@MainThread
@ -17,3 +19,7 @@ inline fun <reified VM : ViewModel> Fragment.parentFragmentViewModels(
extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras },
factoryProducer = factoryProducer ?: { requireParentFragment().defaultViewModelProviderFactory },
)
val ViewModelStore.values: Collection<ViewModel>
@SuppressLint("RestrictedApi")
get() = this.keys().mapNotNull { get(it) }

@ -6,18 +6,25 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
class ChaptersBottomSheetMediator(
bottomSheet: View,
private val behavior: BottomSheetBehavior<*>,
) : OnBackPressedCallback(false),
ActionModeListener,
BottomSheetHeaderBar.OnExpansionChangeListener,
OnLayoutChangeListener {
private val behavior = BottomSheetBehavior.from(bottomSheet)
private var lockCounter = 0
init {
behavior.doOnExpansionsChanged { isExpanded ->
isEnabled = isExpanded
if (!isExpanded) {
unlock()
}
}
}
override fun handleOnBackPressed() {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
@ -30,13 +37,6 @@ class ChaptersBottomSheetMediator(
unlock()
}
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
isEnabled = isExpanded
if (!isExpanded) {
unlock()
}
}
override fun onLayoutChange(
v: View?,
left: Int,
@ -61,6 +61,9 @@ class ChaptersBottomSheetMediator(
fun unlock() {
lockCounter--
if (lockCounter < 0) {
lockCounter = 0
}
behavior.isDraggable = lockCounter <= 0
}
}

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.ui
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@ -56,6 +57,9 @@ class ChaptersFragment :
checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true)
adapter = chaptersAdapter
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
scrollIndicators = if (resources.getBoolean(R.bool.is_tablet)) 0 else View.SCROLL_INDICATOR_TOP
}
}
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)

@ -1,9 +1,11 @@
package org.koitharu.kotatsu.details.ui
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
fun mapChapters(
remoteManga: Manga?,
@ -11,12 +13,14 @@ fun mapChapters(
history: MangaHistory?,
newCount: Int,
branch: String?,
bookmarks: List<Bookmark>,
): List<ChapterListItem> {
val remoteChapters = remoteManga?.getChapters(branch).orEmpty()
val localChapters = localManga?.getChapters(branch).orEmpty()
if (remoteChapters.isEmpty() && localChapters.isEmpty()) {
return emptyList()
}
val bookmarked = bookmarks.mapToSet { it.chapterId }
val currentId = history?.chapterId ?: 0L
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
val chaptersSize = maxOf(remoteChapters.size, localChapters.size)
@ -41,6 +45,7 @@ fun mapChapters(
isUnread = isUnread,
isNew = isUnread && result.size >= newFrom,
isDownloaded = local != null,
isBookmarked = chapter.id in bookmarked,
)
}
if (!localMap.isNullOrEmpty()) {
@ -53,6 +58,7 @@ fun mapChapters(
isUnread = isUnread,
isNew = false,
isDownloaded = remoteManga != null,
isBookmarked = chapter.id in bookmarked,
)
}
}

@ -3,14 +3,18 @@ package org.koitharu.kotatsu.details.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import java.lang.ref.WeakReference
class ChaptersMenuProvider(
private val viewModel: DetailsViewModel,
private val bottomSheetMediator: ChaptersBottomSheetMediator?,
) : MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener {
) : OnBackPressedCallback(false), MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener {
private var searchItemRef: WeakReference<MenuItem>? = null
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_chapters, menu)
@ -20,6 +24,7 @@ class ChaptersMenuProvider(
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
searchItemRef = WeakReference(searchMenuItem)
}
override fun onPrepareMenu(menu: Menu) {
@ -32,15 +37,22 @@ class ChaptersMenuProvider(
viewModel.setChaptersReversed(!menuItem.isChecked)
true
}
else -> false
}
override fun handleOnBackPressed() {
searchItemRef?.get()?.collapseActionView()
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
bottomSheetMediator?.lock()
isEnabled = true
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
isEnabled = false
(item.actionView as? SearchView)?.setQuery("", false)
viewModel.performChapterSearch(null)
bottomSheetMediator?.unlock()

@ -4,12 +4,14 @@ import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.transition.AutoTransition
import android.transition.Slide
import android.transition.TransitionManager
import android.view.Gravity
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast
import androidx.activity.viewModels
@ -17,7 +19,9 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
@ -31,8 +35,11 @@ import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
@ -49,19 +56,16 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
View.OnClickListener,
BottomSheetHeaderBar.OnExpansionChangeListener,
NoModalBottomSheetOwner,
View.OnLongClickListener,
PopupMenu.OnMenuItemClickListener {
override val bsHeader: BottomSheetHeaderBar?
get() = viewBinding.headerChapters
@Inject
lateinit var shortcutsUpdater: ShortcutsUpdater
@ -82,16 +86,22 @@ class DetailsActivity :
viewBinding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(viewBinding.buttonRead, this)
chaptersMenuProvider = if (viewBinding.layoutBottom != null) {
val bsMediator = ChaptersBottomSheetMediator(checkNotNull(viewBinding.layoutBottom))
if (viewBinding.layoutBottom != null) {
val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
val bsMediator = ChaptersBottomSheetMediator(behavior)
actionModeDelegate.addListener(bsMediator)
checkNotNull(viewBinding.headerChapters).addOnExpansionChangeListener(bsMediator)
checkNotNull(viewBinding.headerChapters).addOnLayoutChangeListener(bsMediator)
checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator)
onBackPressedDispatcher.addCallback(bsMediator)
ChaptersMenuProvider(viewModel, bsMediator)
chaptersMenuProvider = ChaptersMenuProvider(viewModel, bsMediator)
behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged)
viewBinding.toolbarChapters?.setNavigationOnClickListener {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
} else {
ChaptersMenuProvider(viewModel, null)
chaptersMenuProvider = ChaptersMenuProvider(viewModel, null)
addMenuProvider(chaptersMenuProvider)
}
onBackPressedDispatcher.addCallback(chaptersMenuProvider)
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
@ -114,11 +124,11 @@ class DetailsActivity :
}
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) {
viewBinding.headerChapters?.subtitle = it
viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it
}
viewModel.isChaptersReversed.observe(this) {
viewBinding.headerChapters?.invalidateMenu() ?: invalidateOptionsMenu()
viewBinding.toolbarChapters?.invalidateMenu() ?: invalidateOptionsMenu()
}
viewModel.favouriteCategories.observe(this) {
invalidateOptionsMenu()
@ -137,7 +147,10 @@ class DetailsActivity :
shortcutsUpdater = shortcutsUpdater,
),
)
viewBinding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider)
}
override fun getBottomSheetCollapsedHeight(): Int {
return viewBinding.layoutBsHeader?.measureHeight() ?: 0
}
override fun onClick(v: View) {
@ -184,11 +197,19 @@ class DetailsActivity :
}
}
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
private fun onChaptersSheetStateChanged(isExpanded: Boolean) {
val toolbar = viewBinding.toolbarChapters ?: return
if (isAnimationsEnabled) {
val transition = AutoTransition()
transition.duration = getAnimationDuration(R.integer.config_shorterAnimTime)
TransitionManager.beginDelayedTransition(toolbar, transition)
}
if (isExpanded) {
headerBar.addMenuProvider(chaptersMenuProvider)
toolbar.addMenuProvider(chaptersMenuProvider)
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
} else {
headerBar.removeMenuProvider(chaptersMenuProvider)
toolbar.removeMenuProvider(chaptersMenuProvider)
toolbar.navigationIcon = null
}
viewBinding.buttonRead.isGone = isExpanded
}
@ -219,6 +240,12 @@ class DetailsActivity :
if (insets.bottom > 0) {
window.setNavigationBarTransparentCompat(this, viewBinding.layoutBottom?.elevation ?: 0f, 0.9f)
}
viewBinding.cardChapters?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.bottom + marginEnd
}
viewBinding.dragHandle?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.bottom
}
}
private fun onHistoryChanged(info: HistoryInfo) {
@ -237,7 +264,7 @@ class DetailsActivity :
info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters)
}
viewBinding.headerChapters?.title = text
viewBinding.toolbarChapters?.title = text
viewBinding.textViewTitle?.text = text
}
@ -282,8 +309,6 @@ class DetailsActivity :
}
}
private fun isTabletLayout() = viewBinding.layoutBottom == null
private fun showBottomSheet(isVisible: Boolean) {
val view = viewBinding.layoutBottom ?: return
if (view.isVisible == isVisible) return
@ -297,7 +322,7 @@ class DetailsActivity :
private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar {
val sb = Snackbar.make(viewBinding.containerDetails, text, duration)
if (viewBinding.layoutBottom?.isVisible == true) {
sb.anchorView = viewBinding.headerChapters
sb.anchorView = viewBinding.toolbarChapters
}
return sb
}

@ -34,7 +34,6 @@ import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
@ -84,6 +83,7 @@ class DetailsFragment :
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
TitleScrollCoordinator(binding.textViewTitle).attach(binding.scrollView)
viewModel.manga.filterNotNull().observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged)
@ -271,7 +271,7 @@ class DetailsFragment :
override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().root.updatePadding(
bottom = (
(activity as? NoModalBottomSheetOwner)?.bsHeader?.measureHeight()
(activity as? NoModalBottomSheetOwner)?.getBottomSheetCollapsedHeight()
?.plus(insets.bottom)?.plus(resources.resolveDp(16))
)
?: insets.bottom,
@ -284,6 +284,7 @@ class DetailsFragment :
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTint(tag),
icon = 0,
data = tag,
isCheckable = false,
isChecked = false,

@ -18,11 +18,11 @@ import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
class DetailsMenuProvider(
@ -63,7 +63,7 @@ class DetailsMenuProvider(
R.id.action_favourite -> {
viewModel.manga.value?.let {
FavouriteCategoriesBottomSheet.show(activity.supportFragmentManager, it)
FavouriteCategoriesSheet.show(activity.supportFragmentManager, it)
}
}
@ -105,7 +105,7 @@ class DetailsMenuProvider(
R.id.action_scrobbling -> {
viewModel.manga.value?.let {
ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it, null)
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)
}
}

@ -171,8 +171,9 @@ class DetailsViewModel @Inject constructor(
history,
selectedBranch,
newChaptersCount,
) { manga, history, branch, news ->
mapChapters(manga?.remote, manga?.local, history, news, branch)
bookmarks,
) { manga, history, branch, news, bookmarks ->
mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks)
},
isChaptersReversed,
chaptersQuery,
@ -209,8 +210,8 @@ class DetailsViewModel @Inject constructor(
}
fun removeBookmark(bookmark: Bookmark) {
launchJob {
bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId)
launchJob(Dispatchers.Default) {
bookmarksRepository.removeBookmark(bookmark)
onShowToast.call(R.string.bookmark_removed)
}
}

@ -0,0 +1,45 @@
package org.koitharu.kotatsu.details.ui
import android.content.Context
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.doOnLayout
import androidx.core.widget.NestedScrollView
import org.koitharu.kotatsu.core.util.ext.findActivity
import java.lang.ref.WeakReference
class TitleScrollCoordinator(
private val titleView: TextView,
) : NestedScrollView.OnScrollChangeListener {
private val location = IntArray(2)
private var activityRef: WeakReference<AppCompatActivity>? = null
override fun onScrollChange(v: NestedScrollView, scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int) {
val actionBar = getActivity(v.context)?.supportActionBar ?: return
titleView.getLocationOnScreen(location)
var top = location[1] + titleView.height
v.getLocationOnScreen(location)
top -= location[1]
actionBar.setDisplayShowTitleEnabled(top < 0)
}
fun attach(scrollView: NestedScrollView) {
scrollView.setOnScrollChangeListener(this)
scrollView.doOnLayout {
onScrollChange(scrollView, 0, 0, 0, 0)
}
}
private fun getActivity(context: Context): AppCompatActivity? {
activityRef?.get()?.let {
if (!it.isDestroyed) return it
}
val activity = context.findActivity() as? AppCompatActivity
if (activity == null || activity.isDestroyed) {
return null
}
activityRef = WeakReference(activity)
return activity
}
}

@ -1,10 +1,12 @@
package org.koitharu.kotatsu.details.ui.adapter
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemChapterBinding
@ -43,7 +45,13 @@ fun chapterListItemAD(
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary))
}
}
binding.imageViewBookmarked.isVisible = item.isBookmarked
binding.imageViewDownloaded.isVisible = item.isDownloaded
binding.imageViewNew.isVisible = item.isNew
// binding.imageViewNew.isVisible = item.isNew
binding.textViewTitle.drawableStart = if (item.isNew) {
ContextCompat.getDrawable(context, R.drawable.ic_new)
} else {
null
}
}
}

@ -31,6 +31,9 @@ class ChapterListItem(
val isDownloaded: Boolean
get() = hasFlag(FLAG_DOWNLOADED)
val isBookmarked: Boolean
get() = hasFlag(FLAG_BOOKMARKED)
val isNew: Boolean
get() = hasFlag(FLAG_NEW)
@ -70,6 +73,7 @@ class ChapterListItem(
const val FLAG_UNREAD = 2
const val FLAG_CURRENT = 4
const val FLAG_NEW = 8
const val FLAG_BOOKMARKED = 16
const val FLAG_DOWNLOADED = 32
}
}

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_BOOKMARKED
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
@ -11,11 +12,13 @@ fun MangaChapter.toListItem(
isUnread: Boolean,
isNew: Boolean,
isDownloaded: Boolean,
isBookmarked: Boolean,
): ChapterListItem {
var flags = 0
if (isCurrent) flags = flags or FLAG_CURRENT
if (isUnread) flags = flags or FLAG_UNREAD
if (isNew) flags = flags or FLAG_NEW
if (isBookmarked) flags = flags or FLAG_BOOKMARKED
if (isDownloaded) flags = flags or FLAG_DOWNLOADED
return ChapterListItem(
chapter = this,

@ -19,7 +19,7 @@ fun scrobblingInfoAD(
{ layoutInflater, parent -> ItemScrobblingInfoBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener {
ScrobblingInfoBottomSheet.show(fragmentManager, bindingAdapterPosition)
ScrobblingInfoSheet.show(fragmentManager, bindingAdapterPosition)
}
bind {

@ -17,7 +17,7 @@ import androidx.fragment.app.activityViewModels
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.newImageRequest
@ -30,12 +30,12 @@ import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import javax.inject.Inject
@AndroidEntryPoint
class ScrobblingInfoBottomSheet :
BaseBottomSheet<SheetScrobblingBinding>(),
class ScrobblingInfoSheet :
BaseAdaptiveSheet<SheetScrobblingBinding>(),
AdapterView.OnItemSelectedListener,
RatingBar.OnRatingBarChangeListener,
View.OnClickListener,
@ -74,7 +74,7 @@ class ScrobblingInfoBottomSheet :
menu = PopupMenu(binding.root.context, binding.buttonMenu).apply {
inflate(R.menu.opt_scrobbling)
setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet)
setOnMenuItemClickListener(this@ScrobblingInfoSheet)
}
}
@ -152,7 +152,7 @@ class ScrobblingInfoBottomSheet :
R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false
val scrobblerService = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.scrobbler
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga, scrobblerService)
ScrobblingSelectorSheet.show(parentFragmentManager, manga, scrobblerService)
dismiss()
}
}
@ -164,7 +164,7 @@ class ScrobblingInfoBottomSheet :
private const val TAG = "ScrobblingInfoBottomSheet"
private const val ARG_INDEX = "index"
fun show(fm: FragmentManager, index: Int) = ScrobblingInfoBottomSheet().withArgs(1) {
fun show(fm: FragmentManager, index: Int) = ScrobblingInfoSheet().withArgs(1) {
putInt(ARG_INDEX, index)
}.show(fm, TAG)
}

@ -80,7 +80,7 @@ class ExploreFragment :
viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
viewModel.onShowSuggestionsTip.observe(viewLifecycleOwner) {
viewModel.onShowSuggestionsTip.observeEvent(viewLifecycleOwner) {
showSuggestionsTip()
}
}

@ -18,9 +18,7 @@ sealed interface ExploreItem : ListModel {
other as Buttons
if (isSuggestionsEnabled != other.isSuggestionsEnabled) return false
return true
return isSuggestionsEnabled == other.isSuggestionsEnabled
}
override fun hashCode(): Int {
@ -40,9 +38,7 @@ sealed interface ExploreItem : ListModel {
other as Header
if (titleResId != other.titleResId) return false
if (isButtonVisible != other.isButtonVisible) return false
return true
return isButtonVisible == other.isButtonVisible
}
override fun hashCode(): Int {
@ -64,9 +60,7 @@ sealed interface ExploreItem : ListModel {
other as Source
if (source != other.source) return false
if (isGrid != other.isGrid) return false
return true
return isGrid == other.isGrid
}
override fun hashCode(): Int {
@ -76,7 +70,6 @@ sealed interface ExploreItem : ListModel {
}
}
@Deprecated("")
class EmptyHint(
@DrawableRes icon: Int,
@StringRes textPrimary: Int,

@ -6,6 +6,7 @@ import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.parsers.model.SortOrder
@Dao
@ -71,12 +72,12 @@ abstract class FavouritesDao {
)
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
suspend fun findCovers(categoryId: Long, order: SortOrder): List<String> {
suspend fun findCovers(categoryId: Long, order: SortOrder): List<Cover> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery(
"SELECT m.cover_url FROM favourites AS f LEFT JOIN manga AS m ON f.manga_id = m.manga_id " +
"SELECT m.cover_url AS url, m.source AS source FROM favourites AS f LEFT JOIN manga AS m ON f.manga_id = m.manga_id " +
"WHERE f.category_id = ? AND deleted_at = 0 ORDER BY $orderBy",
arrayOf<Any>(categoryId),
)
@ -145,7 +146,7 @@ abstract class FavouritesDao {
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<FavouriteManga>>
@RawQuery
protected abstract suspend fun findCoversImpl(query: SupportSQLiteQuery): List<String>
protected abstract suspend fun findCoversImpl(query: SupportSQLiteQuery): List<Cover>
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId")
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long)

@ -19,6 +19,7 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toManga
import org.koitharu.kotatsu.favourites.data.toMangaList
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
@ -66,11 +67,11 @@ class FavouritesRepository @Inject constructor(
}.distinctUntilChanged()
}
fun observeCategoriesWithCovers(): Flow<Map<FavouriteCategory, List<String>>> {
fun observeCategoriesWithCovers(): Flow<Map<FavouriteCategory, List<Cover>>> {
return db.favouriteCategoriesDao.observeAll()
.map {
db.withTransaction {
val res = LinkedHashMap<FavouriteCategory, List<String>>()
val res = LinkedHashMap<FavouriteCategory, List<Cover>>()
for (entity in it) {
val cat = entity.toFavouriteCategory()
res[cat] = db.favouritesDao.findCovers(

@ -0,0 +1,32 @@
package org.koitharu.kotatsu.favourites.domain.model
import org.koitharu.kotatsu.parsers.model.MangaSource
class Cover(
val url: String,
val source: String,
) {
val mangaSource: MangaSource?
get() = if (source.isEmpty()) null else MangaSource.values().find { it.name == source }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Cover
if (url != other.url) return false
return source == other.source
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + source.hashCode()
return result
}
override fun toString(): String {
return "Cover(url='$url', source=$source)"
}
}

@ -15,11 +15,12 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.animatorDurationScale
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemCategoryBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.model.ListModel
@ -53,10 +54,7 @@ fun categoryAD(
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153))
val fallback = ColorDrawable(Color.TRANSPARENT)
val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3)
val crossFadeDuration = (
context.resources.getInteger(R.integer.config_defaultAnimTime) *
context.animatorDurationScale
).toInt()
val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt()
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
itemView.setOnTouchListener(eventListener)
@ -77,9 +75,11 @@ fun categoryAD(
)
}
repeat(coverViews.size) { i ->
coverViews[i].newImageRequest(lifecycleOwner, item.covers.getOrNull(i))?.run {
val cover = item.covers.getOrNull(i)
coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(fallback)
source(cover?.mangaSource)
crossfade(crossFadeDuration * (i + 1))
error(R.drawable.ic_error_placeholder)
allowRgb565(true)

@ -1,11 +1,12 @@
package org.koitharu.kotatsu.favourites.ui.categories.adapter
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.ui.model.ListModel
class CategoryListModel(
val mangaCount: Int,
val covers: List<String>,
val covers: List<Cover>,
val category: FavouriteCategory,
val isReorderMode: Boolean,
) : ListModel {
@ -21,9 +22,7 @@ class CategoryListModel(
if (covers != other.covers) return false
if (category.id != other.category.id) return false
if (category.title != other.category.title) return false
if (category.order != other.category.order) return false
return true
return category.order == other.category.order
}
override fun hashCode(): Int {

@ -2,34 +2,29 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding
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.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
@AndroidEntryPoint
class FavouriteCategoriesBottomSheet :
BaseBottomSheet<SheetFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>,
View.OnClickListener,
Toolbar.OnMenuItemClickListener {
class FavouriteCategoriesSheet :
BaseAdaptiveSheet<SheetFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem> {
private val viewModel: MangaCategoriesViewModel by viewModels()
@ -40,13 +35,13 @@ class FavouriteCategoriesBottomSheet :
container: ViewGroup?,
) = SheetFavoriteCategoriesBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: SheetFavoriteCategoriesBinding, savedInstanceState: Bundle?) {
override fun onViewBindingCreated(
binding: SheetFavoriteCategoriesBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter
binding.buttonDone.setOnClickListener(this)
binding.headerBar.toolbar.setOnMenuItemClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, ::onError)
}
@ -56,25 +51,11 @@ class FavouriteCategoriesBottomSheet :
super.onDestroyView()
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_done -> dismiss()
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
else -> return false
}
return true
}
override fun onItemClick(item: MangaCategoryItem, view: View) {
viewModel.setChecked(item.id, !item.isChecked)
}
private fun onContentChanged(categories: List<MangaCategoryItem>) {
private fun onContentChanged(categories: List<ListModel>) {
adapter?.items = categories
}
@ -89,11 +70,17 @@ class FavouriteCategoriesBottomSheet :
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesBottomSheet().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) },
)
}.show(fm, TAG)
fun show(fm: FragmentManager, manga: Collection<Manga>) =
FavouriteCategoriesSheet().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) {
ParcelableManga(
it,
withChapters = false,
)
},
)
}.show(fm, TAG)
}
}

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
@ -12,8 +13,10 @@ import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
import javax.inject.Inject
@HiltViewModel
@ -23,17 +26,21 @@ class MangaCategoriesViewModel @Inject constructor(
) : BaseViewModel() {
private val manga = requireNotNull(savedStateHandle.get<List<ParcelableManga>>(KEY_MANGA_LIST)).map { it.manga }
private val header = CategoriesHeaderItem()
val content = combine(
val content: StateFlow<List<ListModel>> = combine(
favouritesRepository.observeCategories(),
observeCategoriesIds(),
) { all, checked ->
all.map {
MangaCategoryItem(
id = it.id,
name = it.title,
isChecked = it.id in checked,
)
buildList(all.size + 1) {
add(header)
all.mapTo(this) {
MangaCategoryItem(
id = it.id,
name = it.title,
isChecked = it.id in checked,
)
}
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())

@ -0,0 +1,27 @@
package org.koitharu.kotatsu.favourites.ui.categories.select.adapter
import android.view.View
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemCategoriesHeaderBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
import org.koitharu.kotatsu.list.ui.model.ListModel
fun categoriesHeaderAD() = adapterDelegateViewBinding<CategoriesHeaderItem, ListModel, ItemCategoriesHeaderBinding>(
{ inflater, parent -> ItemCategoriesHeaderBinding.inflate(inflater, parent, false) },
) {
val onClickListener = View.OnClickListener { v ->
val intent = when (v.id) {
R.id.button_create -> FavouritesCategoryEditActivity.newIntent(v.context)
R.id.button_manage -> FavouriteCategoriesActivity.newIntent(v.context)
else -> return@OnClickListener
}
v.context.startActivity(intent)
}
binding.buttonCreate.setOnClickListener(onClickListener)
binding.buttonManage.setOnClickListener(onClickListener)
}

@ -3,32 +3,39 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.adapter
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
class MangaCategoriesAdapter(
clickListener: OnListItemClickListener<MangaCategoryItem>
) : AsyncListDifferDelegationAdapter<MangaCategoryItem>(DiffCallback()) {
clickListener: OnListItemClickListener<MangaCategoryItem>,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(mangaCategoryAD(clickListener))
.addDelegate(categoriesHeaderAD())
}
private class DiffCallback : DiffUtil.ItemCallback<MangaCategoryItem>() {
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(
oldItem: MangaCategoryItem,
newItem: MangaCategoryItem
): Boolean = oldItem.id == newItem.id
oldItem: ListModel,
newItem: ListModel,
): Boolean = when {
oldItem is MangaCategoryItem && newItem is MangaCategoryItem -> oldItem.id == newItem.id
oldItem is CategoriesHeaderItem && newItem is CategoriesHeaderItem -> oldItem == newItem
else -> false
}
override fun areContentsTheSame(
oldItem: MangaCategoryItem,
newItem: MangaCategoryItem
oldItem: ListModel,
newItem: ListModel,
): Boolean = oldItem == newItem
override fun getChangePayload(
oldItem: MangaCategoryItem,
newItem: MangaCategoryItem
oldItem: ListModel,
newItem: ListModel,
): Any? {
if (oldItem.isChecked != newItem.isChecked) {
if (oldItem is MangaCategoryItem && newItem is MangaCategoryItem && oldItem.isChecked != newItem.isChecked) {
return newItem.isChecked
}
return super.getChangePayload(oldItem, newItem)

@ -4,10 +4,11 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
fun mangaCategoryAD(
clickListener: OnListItemClickListener<MangaCategoryItem>
) = adapterDelegateViewBinding<MangaCategoryItem, MangaCategoryItem, ItemCheckableNewBinding>(
clickListener: OnListItemClickListener<MangaCategoryItem>,
) = adapterDelegateViewBinding<MangaCategoryItem, ListModel, ItemCheckableNewBinding>(
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
) {

@ -0,0 +1,8 @@
package org.koitharu.kotatsu.favourites.ui.categories.select.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class CategoriesHeaderItem : ListModel {
override fun equals(other: Any?): Boolean = other?.javaClass == CategoriesHeaderItem::class.java
}

@ -1,7 +1,9 @@
package org.koitharu.kotatsu.favourites.ui.categories.select.model
import org.koitharu.kotatsu.list.ui.model.ListModel
data class MangaCategoryItem(
val id: Long,
val name: String,
val isChecked: Boolean
)
val isChecked: Boolean,
) : ListModel

@ -0,0 +1,46 @@
package org.koitharu.kotatsu.filter.ui
import android.content.Context
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.adapter.listSimpleHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class FilterAdapter(
listener: OnFilterChangedListener,
listListener: ListListener<ListModel>,
) : AsyncListDifferDelegationAdapter<ListModel>(FilterDiffCallback()), FastScroller.SectionIndexer {
init {
delegatesManager
.addDelegate(filterSortDelegate(listener))
.addDelegate(filterTagDelegate(listener))
.addDelegate(listSimpleHeaderAD())
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
.addDelegate(filterErrorDelegate())
differ.addListListener(listListener)
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is FilterItem.Tag) {
return item.tag.title.firstOrNull()?.toString()
}
}
return null
}
companion object {
const val ITEM_TYPE_HEADER = 0
const val ITEM_TYPE_SORT = 1
const val ITEM_TYPE_TAG = 2
}
}

@ -0,0 +1,51 @@
package org.koitharu.kotatsu.filter.ui
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.model.ListModel
fun filterSortDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Sort, ListModel, ItemCheckableSingleBinding>(
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onSortItemClick(item)
}
bind { payloads ->
binding.root.setText(item.order.titleRes)
binding.root.setChecked(item.isSelected, payloads.isNotEmpty())
}
}
fun filterTagDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item)
}
bind { payloads ->
binding.root.text = item.tag.title
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterErrorDelegate() = adapterDelegate<FilterItem.Error, ListModel>(R.layout.item_sources_empty) {
bind {
(itemView as TextView).setText(item.textResId)
}
}

@ -1,7 +1,9 @@
package org.koitharu.kotatsu.list.ui.filter
package org.koitharu.kotatsu.filter.ui
import androidx.annotation.WorkerThread
import kotlinx.coroutines.CoroutineScope
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -17,21 +19,40 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.filter.ui.model.FilterState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.text.Collator
import java.util.LinkedList
import java.util.Locale
import java.util.TreeSet
import javax.inject.Inject
class FilterCoordinator(
private val repository: RemoteMangaRepository,
@ViewModelScoped
class FilterCoordinator @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
dataRepository: MangaDataRepository,
private val coroutineScope: CoroutineScope,
) : OnFilterChangedListener {
private val searchRepository: MangaSearchRepository,
lifecycle: ViewModelLifecycle,
) : FilterOwner {
private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
private val currentState = MutableStateFlow(FilterState(repository.defaultSortOrder, emptySet()))
private var searchQuery = MutableStateFlow("")
private val localTags = SuspendLazy {
@ -39,13 +60,23 @@ class FilterCoordinator(
}
private var availableTagsDeferred = loadTagsAsync()
val items: StateFlow<List<FilterItem>> = getItemsFlow()
.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(FilterItem.Loading))
override val filterItems: StateFlow<List<ListModel>> = getItemsFlow()
.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn(
scope = coroutineScope + Dispatchers.Default,
started = SharingStarted.Lazily,
initialValue = FilterHeaderModel(emptyList(), repository.defaultSortOrder, false),
)
init {
observeState()
}
override fun applyFilter(tags: Set<MangaTag>) {
setTags(tags)
}
override fun onSortItemClick(item: FilterItem.Sort) {
currentState.update { oldValue ->
FilterState(item.order, oldValue.tags)
@ -91,6 +122,14 @@ class FilterCoordinator(
searchQuery.value = query
}
private fun getHeaderFlow() = combine(
observeState(),
observeAvailableTags(),
) { state, available ->
val chips = createChipsList(state, available.orEmpty())
FilterHeaderModel(chips, state.sortOrder, state.tags.isNotEmpty())
}
private fun getItemsFlow() = combine(
getTagsAsFlow(),
currentState,
@ -110,24 +149,66 @@ class FilterCoordinator(
}
}
private suspend fun createChipsList(
filterState: FilterState,
availableTags: Set<MangaTag>,
): List<ChipsView.ChipModel> {
val selectedTags = filterState.tags.toMutableSet()
var tags = searchRepository.getTagsSuggestion("", 6, repository.source)
if (tags.isEmpty()) {
tags = availableTags.take(6)
}
if (tags.isEmpty() && selectedTags.isEmpty()) {
return emptyList()
}
val result = LinkedList<ChipsView.ChipModel>()
for (tag in tags) {
val model = ChipsView.ChipModel(
tint = 0,
title = tag.title,
icon = 0,
isCheckable = true,
isChecked = selectedTags.remove(tag),
data = tag,
)
if (model.isChecked) {
result.addFirst(model)
} else {
result.addLast(model)
}
}
for (tag in selectedTags) {
val model = ChipsView.ChipModel(
tint = 0,
title = tag.title,
icon = 0,
isCheckable = true,
isChecked = true,
data = tag,
)
result.addFirst(model)
}
return result
}
@WorkerThread
private fun buildFilterList(
allTags: TagsWrapper,
state: FilterState,
query: String,
): List<FilterItem> {
): List<ListModel> {
val sortOrders = repository.sortOrders.sortedBy { it.ordinal }
val tags = mergeTags(state.tags, allTags.tags).toList()
val list = ArrayList<FilterItem>(tags.size + sortOrders.size + 3)
val list = ArrayList<ListModel>(tags.size + sortOrders.size + 3)
if (query.isEmpty()) {
if (sortOrders.isNotEmpty()) {
list.add(FilterItem.Header(R.string.sort_order, 0))
list.add(ListHeader(R.string.sort_order, 0, null))
sortOrders.mapTo(list) {
FilterItem.Sort(it, isSelected = it == state.sortOrder)
}
}
if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
list.add(FilterItem.Header(R.string.genres, state.tags.size))
list.add(ListHeader(R.string.genres, 0, null))
tags.mapTo(list) {
FilterItem.Tag(it, isChecked = it in state.tags)
}
@ -135,7 +216,7 @@ class FilterCoordinator(
if (allTags.isError) {
list.add(FilterItem.Error(R.string.filter_load_error))
} else if (allTags.isLoading) {
list.add(FilterItem.Loading)
list.add(LoadingFooter())
}
} else {
tags.mapNotNullTo(list) {

@ -0,0 +1,52 @@
package org.koitharu.kotatsu.filter.ui
import androidx.recyclerview.widget.DiffUtil
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
class FilterDiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when {
oldItem === newItem -> true
oldItem.javaClass != newItem.javaClass -> false
oldItem is ListHeader && newItem is ListHeader -> {
oldItem == newItem
}
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.tag == newItem.tag
}
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.order == newItem.order
}
oldItem is FilterItem.Error && newItem is FilterItem.Error -> {
oldItem.textResId == newItem.textResId
}
else -> false
}
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
val hasPayload = when {
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.isChecked != newItem.isChecked
}
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.isSelected != newItem.isSelected
}
else -> false
}
return if (hasPayload) Unit else super.getChangePayload(oldItem, newItem)
}
}

@ -0,0 +1,71 @@
package org.koitharu.kotatsu.filter.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import com.google.android.material.chip.Chip
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.parsers.model.MangaTag
import com.google.android.material.R as materialR
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
private val owner by lazy(LazyThreadSafetyMode.NONE) {
FilterOwner.from(requireActivity())
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.chipsTags.onChipClickListener = this
owner.header.observe(viewLifecycleOwner, ::onDataChanged)
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag
if (tag == null) {
FilterSheetFragment.show(parentFragmentManager)
} else {
owner.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
}
}
private fun onDataChanged(header: FilterHeaderModel) {
val binding = viewBinding ?: return
val chips = header.chips
if (chips.isEmpty()) {
binding.chipsTags.setChips(emptyList())
binding.root.isVisible = false
return
}
if (binding.root.context.isAnimationsEnabled) {
binding.scrollView.smoothScrollTo(0, 0)
} else {
binding.scrollView.scrollTo(0, 0)
}
binding.chipsTags.setChips(header.chips + moreTagsChip())
binding.root.isVisible = true
}
private fun moreTagsChip() = ChipsView.ChipModel(
tint = 0,
title = getString(R.string.more),
icon = materialR.drawable.abc_ic_menu_overflow_material,
isCheckable = false,
isChecked = false,
)
}

@ -0,0 +1,32 @@
package org.koitharu.kotatsu.filter.ui
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.core.util.ext.values
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
interface FilterOwner : OnFilterChangedListener {
val filterItems: StateFlow<List<ListModel>>
val header: StateFlow<FilterHeaderModel>
fun applyFilter(tags: Set<MangaTag>)
companion object {
fun from(activity: FragmentActivity): FilterOwner {
for (f in activity.supportFragmentManager.fragments) {
return find(f) ?: continue
}
error("Cannot find FilterOwner")
}
fun find(fragment: Fragment): FilterOwner? {
return fragment.viewModelStore.values.firstNotNullOfOrNull { it as? FilterOwner }
}
}
}

@ -0,0 +1,59 @@
package org.koitharu.kotatsu.filter.ui
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.LinearLayoutManager
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
class FilterSheetFragment :
BaseAdaptiveSheet<SheetFilterBinding>(),
AdaptiveSheetCallback,
AsyncListDiffer.ListListener<ListModel> {
private val owner by lazy(LazyThreadSafetyMode.NONE) {
FilterOwner.from(requireActivity())
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
addSheetCallback(this)
val adapter = FilterAdapter(owner, this)
binding.recyclerView.adapter = adapter
owner.filterItems.observe(viewLifecycleOwner, adapter::setItems)
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.recyclerView.scrollIndicators = 0
}
}
override fun onCurrentListChanged(previousList: MutableList<ListModel>, currentList: MutableList<ListModel>) {
if (currentList.size > previousList.size && view != null) {
(requireViewBinding().recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
}
}
override fun onStateChanged(sheet: View, newState: Int) {
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
}
companion object {
private const val TAG = "FilterBottomSheet"
fun show(fm: FragmentManager) = FilterSheetFragment().show(fm, TAG)
}
}

@ -1,4 +1,6 @@
package org.koitharu.kotatsu.list.ui.filter
package org.koitharu.kotatsu.filter.ui
import org.koitharu.kotatsu.filter.ui.model.FilterItem
interface OnFilterChangedListener {

@ -1,19 +1,23 @@
package org.koitharu.kotatsu.list.ui.model
package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
class ListHeader2(
class FilterHeaderModel(
val chips: Collection<ChipsView.ChipModel>,
val sortOrder: SortOrder?,
val hasSelectedTags: Boolean,
) : ListModel {
val textSummary: String
get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ListHeader2
other as FilterHeaderModel
if (chips != other.chips) return false
return sortOrder == other.sortOrder

@ -0,0 +1,71 @@
package org.koitharu.kotatsu.filter.ui.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
sealed interface FilterItem : ListModel {
class Sort(
val order: SortOrder,
val isSelected: Boolean,
) : FilterItem {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Sort
if (order != other.order) return false
return isSelected == other.isSelected
}
override fun hashCode(): Int {
var result = order.hashCode()
result = 31 * result + isSelected.hashCode()
return result
}
}
class Tag(
val tag: MangaTag,
val isChecked: Boolean,
) : FilterItem {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Tag
if (tag != other.tag) return false
return isChecked == other.isChecked
}
override fun hashCode(): Int {
var result = tag.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
}
class Error(
@StringRes val textResId: Int,
) : FilterItem {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Error
return textResId == other.textResId
}
override fun hashCode(): Int {
return textResId
}
}
}

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.list.ui.filter
package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
@ -15,9 +15,7 @@ class FilterState(
other as FilterState
if (sortOrder != other.sortOrder) return false
if (tags != other.tags) return false
return true
return tags == other.tags
}
override fun hashCode(): Int {

@ -0,0 +1,32 @@
package org.koitharu.kotatsu.list.domain
import dagger.Reusable
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@Reusable
class ListExtraProviderImpl @Inject constructor(
private val settings: AppSettings,
private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository,
) : ListExtraProvider {
override suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
override suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
}

@ -11,7 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.databinding.DialogListModeBinding
@ -19,7 +19,7 @@ import javax.inject.Inject
@AndroidEntryPoint
class ListModeBottomSheet :
BaseBottomSheet<DialogListModeBinding>(),
BaseAdaptiveSheet<DialogListModeBinding>(),
Slider.OnChangeListener,
MaterialButtonToggleGroup.OnButtonCheckedListener {

@ -45,7 +45,7 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
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.MangaListListener
@ -296,7 +296,7 @@ abstract class MangaListFragment :
}
R.id.action_favourite -> {
FavouriteCategoriesBottomSheet.show(childFragmentManager, selectedItems)
FavouriteCategoriesSheet.show(childFragmentManager, selectedItems)
mode.finish()
true
}

@ -1,24 +1,20 @@
package org.koitharu.kotatsu.list.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.ItemHeader2Binding
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
@Deprecated("")
fun listHeader2AD(
listener: MangaListListener,
) = adapterDelegateViewBinding<ListHeader2, ListModel, ItemHeader2Binding>(
{ layoutInflater, parent -> ItemHeader2Binding.inflate(layoutInflater, parent, false) },
) = adapterDelegateViewBinding<FilterHeaderModel, ListModel, FragmentFilterHeaderBinding>(
{ layoutInflater, parent -> FragmentFilterHeaderBinding.inflate(layoutInflater, parent, false) },
) {
var ignoreChecking = false
binding.textViewFilter.setOnClickListener {
listener.onFilterClick(it)
}
binding.chipsTags.setOnCheckedStateChangeListener { _, _ ->
if (!ignoreChecking) {
listener.onUpdateFilter(binding.chipsTags.getCheckedData(MangaTag::class.java))
@ -36,6 +32,5 @@ fun listHeader2AD(
ignoreChecking = true
binding.chipsTags.setChips(item.chips) // TODO use recyclerview
ignoreChecking = false
binding.textViewFilter.setTextAndVisible(item.sortOrder?.titleRes ?: 0)
}
}

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.databinding.ItemHeaderSingleBinding
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
@ -22,3 +23,12 @@ fun listHeaderAD(
binding.buttonMore.setTextAndVisible(item.buttonTextRes)
}
}
fun listSimpleHeaderAD() = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderSingleBinding>(
{ inflater, parent -> ItemHeaderSingleBinding.inflate(inflater, parent, false) },
) {
bind {
binding.textViewTitle.text = item.getText(context)
}
}

@ -5,8 +5,8 @@ import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
@ -82,7 +82,7 @@ open class MangaListAdapter(
}
}
is ListHeader2 -> Unit
is FilterHeaderModel -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
}

@ -1,21 +0,0 @@
package org.koitharu.kotatsu.list.ui.filter
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
class FilterAdapter(
listener: OnFilterChangedListener,
listListener: ListListener<FilterItem>,
) : AsyncListDifferDelegationAdapter<FilterItem>(
FilterDiffCallback(),
filterSortDelegate(listener),
filterTagDelegate(listener),
filterHeaderDelegate(),
filterLoadingDelegate(),
filterErrorDelegate(),
) {
init {
differ.addListListener(listListener)
}
}

@ -1,66 +0,0 @@
package org.koitharu.kotatsu.list.ui.filter
import android.widget.TextView
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
fun filterSortDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Sort, FilterItem, ItemCheckableNewBinding>(
{ layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onSortItemClick(item)
}
bind {
binding.root.setText(item.order.titleRes)
binding.root.isChecked = item.isSelected
}
}
fun filterTagDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, FilterItem, ItemCheckableNewBinding>(
{ layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item)
}
bind {
binding.root.text = item.tag.title
binding.root.isChecked = item.isChecked
}
}
fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, FilterItem, ItemFilterHeaderBinding>(
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) },
) {
bind {
binding.textViewTitle.setText(item.titleResId)
binding.badge.isVisible = if (item.counter == 0) {
false
} else {
binding.badge.text = item.counter.toString()
true
}
}
}
fun filterLoadingDelegate() = adapterDelegate<FilterItem.Loading, FilterItem>(R.layout.item_loading_footer) {}
fun filterErrorDelegate() = adapterDelegate<FilterItem.Error, FilterItem>(R.layout.item_sources_empty) {
bind {
(itemView as TextView).setText(item.textResId)
}
}

@ -1,91 +0,0 @@
package org.koitharu.kotatsu.list.ui.filter
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.LinearLayoutManager
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentFragmentViewModels
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
class FilterBottomSheet :
BaseBottomSheet<SheetFilterBinding>(),
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
AsyncListDiffer.ListListener<FilterItem> {
private val viewModel by parentFragmentViewModels<RemoteListViewModel>()
private var collapsibleActionViewCallback: CollapseActionViewCallback? = null
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = FilterAdapter(viewModel, this)
binding.recyclerView.adapter = adapter
viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems)
initOptionsMenu()
}
override fun onDestroyView() {
super.onDestroyView()
collapsibleActionViewCallback = null
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true)
collapsibleActionViewCallback?.onMenuItemActionExpand(item)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
val searchView = (item.actionView as? SearchView) ?: return false
searchView.setQuery("", false)
searchView.post { setExpanded(isExpanded = false, isLocked = false) }
collapsibleActionViewCallback?.onMenuItemActionCollapse(item)
return true
}
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.filterSearch(newText?.trim().orEmpty())
return true
}
override fun onCurrentListChanged(previousList: MutableList<FilterItem>, currentList: MutableList<FilterItem>) {
if (currentList.size > previousList.size && view != null) {
(requireViewBinding().recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
}
}
private fun initOptionsMenu() {
requireViewBinding().headerBar.inflateMenu(R.menu.opt_filter)
val searchMenuItem = requireViewBinding().headerBar.menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
collapsibleActionViewCallback = CollapseActionViewCallback(searchMenuItem).also {
onBackPressedDispatcher.addCallback(it)
}
}
companion object {
private const val TAG = "FilterBottomSheet"
fun show(fm: FragmentManager) = FilterBottomSheet().show(fm, TAG)
}
}

@ -1,59 +0,0 @@
package org.koitharu.kotatsu.list.ui.filter
import androidx.recyclerview.widget.DiffUtil
class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
return when {
oldItem === newItem -> true
oldItem.javaClass != newItem.javaClass -> false
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
oldItem.titleResId == newItem.titleResId
}
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.tag == newItem.tag
}
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.order == newItem.order
}
oldItem is FilterItem.Error && newItem is FilterItem.Error -> {
oldItem.textResId == newItem.textResId
}
else -> false
}
}
override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
return when {
oldItem == FilterItem.Loading && newItem == FilterItem.Loading -> true
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
oldItem.counter == newItem.counter
}
oldItem is FilterItem.Error && newItem is FilterItem.Error -> true
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.isChecked == newItem.isChecked
}
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.isSelected == newItem.isSelected
}
else -> false
}
}
override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? {
val hasPayload = when {
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.isChecked != newItem.isChecked
}
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.isSelected != newItem.isSelected
}
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
oldItem.counter != newItem.counter
}
else -> false
}
return if (hasPayload) Unit else super.getChangePayload(oldItem, newItem)
}
}

@ -1,29 +0,0 @@
package org.koitharu.kotatsu.list.ui.filter
import androidx.annotation.StringRes
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
sealed interface FilterItem {
class Header(
@StringRes val titleResId: Int,
val counter: Int,
) : FilterItem
class Sort(
val order: SortOrder,
val isSelected: Boolean,
) : FilterItem
class Tag(
val tag: MangaTag,
val isChecked: Boolean,
) : FilterItem
object Loading : FilterItem
class Error(
@StringRes val textResId: Int,
) : FilterItem
}

@ -43,6 +43,7 @@ fun Manga.toListDetailedModel(
ChipsView.ChipModel(
tint = tagHighlighter?.getTint(it) ?: 0,
title = it.title,
icon = 0,
isCheckable = false,
isChecked = false,
data = it,

@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.CompositeMutex
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
@ -28,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.File
import java.util.EnumSet
import javax.inject.Inject
import javax.inject.Singleton
@ -37,11 +39,20 @@ private const val MAX_PARALLELISM = 4
class LocalMangaRepository @Inject constructor(
private val storageManager: LocalStorageManager,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val settings: AppSettings,
) : MangaRepository {
override val source = MangaSource.LOCAL
private val locks = CompositeMutex<Long>()
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
override var defaultSortOrder: SortOrder
get() = settings.localListOrder
set(value) {
settings.localListOrder = value
}
override suspend fun getList(offset: Int, query: String): List<Manga> {
if (offset > 0) {
return emptyList()
@ -137,8 +148,6 @@ class LocalMangaRepository @Inject constructor(
}.firstOrNull()?.getManga()
}
override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING)
override suspend fun getPageUrl(page: MangaPage) = page.url
override suspend fun getTags() = emptySet<MangaTag>()

@ -54,9 +54,10 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
try {
file.sink(append = false).buffer().use {
val bytes = file.sink(append = false).buffer().use {
it.writeAllCancellable(source)
}
check(bytes != 0L) { "No data has been written" }
lruCache.get().put(url, file)
} finally {
file.delete()

@ -130,7 +130,6 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
}
val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null
return ZipFile(cbz).use { zip ->
val filter = ImageFileFilter()
zip.entries().asSequence()
.firstOrNull { x -> !x.isDirectory && filter.accept(x) }
?.let { entry -> zipUri(cbz, entry.name) }

@ -5,7 +5,6 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
@ -16,11 +15,14 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
class LocalListFragment : MangaListFragment() {
override val viewModel by viewModels<LocalListViewModel>()
@ -35,11 +37,7 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener
}
override fun onFilterClick(view: View?) {
super.onFilterClick(view)
val menu = PopupMenu(requireContext(), view ?: requireViewBinding().recyclerView)
menu.inflate(R.menu.popup_order)
menu.setOnMenuItemClickListener(this)
menu.show()
FilterSheetFragment.show(childFragmentManager)
}
override fun onScrolledToEnd() = Unit
@ -67,17 +65,6 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
val order = when (item.itemId) {
R.id.action_order_new -> SortOrder.NEWEST
R.id.action_order_abs -> SortOrder.ALPHABETICAL
R.id.action_order_rating -> SortOrder.RATING
else -> return false
}
viewModel.setSortOrder(order)
return true
}
private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.delete_manga)
@ -96,6 +83,8 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener
companion object {
fun newInstance() = LocalListFragment()
fun newInstance() = LocalListFragment().withArgs(1) {
putSerializable(RemoteListFragment.ARG_SOURCE, MangaSource.LOCAL) // required by FilterCoordinator
}
}
}

@ -1,123 +1,57 @@
package org.koitharu.kotatsu.local.ui
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.LinkedList
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import javax.inject.Inject
@HiltViewModel
class LocalListViewModel @Inject constructor(
private val repository: LocalMangaRepository,
private val historyRepository: HistoryRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
private val tagHighlighter: MangaTagHighlighter,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
filter: FilterCoordinator,
tagHighlighter: MangaTagHighlighter,
settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider {
listExtraProvider: ListExtraProvider,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) : RemoteListViewModel(
savedStateHandle,
mangaRepositoryFactory,
filter,
tagHighlighter,
settings,
listExtraProvider,
downloadScheduler,
) {
val onMangaRemoved = MutableEventFlow<Unit>()
val sortOrder = MutableStateFlow(settings.localListOrder)
private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val selectedTags = MutableStateFlow<Set<MangaTag>>(emptySet())
private var refreshJob: Job? = null
override val content = combine(
mangaList,
listMode,
sortOrder,
selectedTags,
listError,
) { list, mode, order, tags, error ->
when {
error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_empty_local,
textPrimary = R.string.text_local_holder_primary,
textSecondary = R.string.text_local_holder_secondary,
actionStringRes = R.string._import,
),
)
else -> buildList(list.size + 1) {
add(createHeader(list, tags, order))
list.toUi(this, mode, this@LocalListViewModel, tagHighlighter)
}
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init {
onRefresh()
launchJob(Dispatchers.Default) {
localStorageChanges
.collectLatest {
if (refreshJob?.isActive != true) {
doRefresh()
}
.collect {
loadList(filter.snapshot(), append = false).join()
}
}
}
override fun onUpdateFilter(tags: Set<MangaTag>) {
selectedTags.value = tags
onRefresh()
}
override fun onRefresh() {
val prevJob = refreshJob
refreshJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
doRefresh()
}
}
override fun onRetry() = onRefresh()
fun setSortOrder(value: SortOrder) {
sortOrder.value = value
settings.localListOrder = value
onRefresh()
}
fun delete(ids: Set<Long>) {
launchLoadingJob(Dispatchers.Default) {
deleteLocalMangaUseCase(ids)
@ -125,60 +59,12 @@ class LocalListViewModel @Inject constructor(
}
}
private suspend fun doRefresh() {
try {
listError.value = null
mangaList.value = repository.getList(0, selectedTags.value, sortOrder.value)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
listError.value = e
}
}
private fun createHeader(mangaList: List<Manga>, selectedTags: Set<MangaTag>, order: SortOrder): ListHeader2 {
val tags = HashMap<MangaTag, Int>()
for (item in mangaList) {
for (tag in item.tags) {
tags[tag] = tags[tag]?.plus(1) ?: 1
}
}
val topTags = tags.entries.sortedByDescending { it.value }.take(6)
val chips = LinkedList<ChipsView.ChipModel>()
for ((tag, _) in topTags) {
val model = ChipsView.ChipModel(
tint = 0,
title = tag.title,
isCheckable = true,
isChecked = tag in selectedTags,
data = tag,
)
if (model.isChecked) {
chips.addFirst(model)
} else {
chips.addLast(model)
}
}
return ListHeader2(
chips = chips,
sortOrder = order,
hasSelectedTags = selectedTags.isNotEmpty(),
override fun createEmptyState(canResetFilter: Boolean): EmptyState {
return EmptyState(
icon = R.drawable.ic_empty_local,
textPrimary = R.string.text_local_holder_primary,
textSecondary = R.string.text_local_holder_secondary,
actionStringRes = R.string._import,
)
}
override suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
override suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
}

@ -129,7 +129,7 @@ class MainActivity :
navigationDelegate =
MainNavigationDelegate(checkNotNull(bottomNav ?: viewBinding.navRail), supportFragmentManager)
navigationDelegate.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(savedInstanceState)
navigationDelegate.onCreate()
onBackPressedDispatcher.addCallback(ExitCallback(this, viewBinding.container))
onBackPressedDispatcher.addCallback(navigationDelegate)

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

Loading…
Cancel
Save