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

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

@ -1,6 +1,10 @@
package org.koitharu.kotatsu.bookmarks.data 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 kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaWithTags import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@ -18,7 +22,7 @@ abstract class BookmarksDao {
@Transaction @Transaction
@Query( @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>>> abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
@ -29,5 +33,8 @@ abstract class BookmarksDao {
abstract suspend fun delete(entity: BookmarkEntity) abstract suspend fun delete(entity: BookmarkEntity)
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") @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 package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.* import java.util.Date
class Bookmark( class Bookmark(
val manga: Manga, val manga: Manga,
@ -27,9 +27,7 @@ class Bookmark(
if (scroll != other.scroll) return false if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false if (createdAt != other.createdAt) return false
if (percent != other.percent) return false return percent == other.percent
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@ -43,4 +41,4 @@ class Bookmark(
result = 31 * result + percent.hashCode() result = 31 * result + percent.hashCode()
return result return result
} }
} }

@ -52,8 +52,14 @@ class BookmarksRepository @Inject constructor(
} }
} }
suspend fun removeBookmark(mangaId: Long, pageId: Long) { suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
db.bookmarksDao.delete(mangaId, pageId) 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 { suspend fun removeBookmarks(ids: Map<Manga, Set<Long>>): ReversibleHandle {

@ -19,7 +19,9 @@ class BookmarksAdapter(
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() { private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean { 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 { 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.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListExtraProviderImpl
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges 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.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
@ -64,6 +65,9 @@ interface AppModule {
@Binds @Binds
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
@Binds
fun bindListExtraProvider(impl: ListExtraProviderImpl): ListExtraProvider
companion object { companion object {
@Provides @Provides
@ -86,7 +90,8 @@ interface AppModule {
@ApplicationContext context: Context, @ApplicationContext context: Context,
@MangaHttpClient okHttpClient: OkHttpClient, @MangaHttpClient okHttpClient: OkHttpClient,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
pagesCache: PagesCache, imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
): ImageLoader { ): ImageLoader {
val diskCacheFactory = { val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir val rootDir = context.externalCacheDir ?: context.cacheDir
@ -108,7 +113,8 @@ interface AppModule {
.add(SvgDecoder.Factory()) .add(SvgDecoder.Factory())
.add(CbzFetcher.Factory()) .add(CbzFetcher.Factory())
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.add(MangaPageFetcher.Factory(context, okHttpClient, pagesCache, mangaRepositoryFactory)) .add(pageFetcherFactory)
.add(imageProxyInterceptor)
.build(), .build(),
).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.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14 import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15 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.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 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.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 15 const val DATABASE_VERSION = 16
@Database( @Database(
entities = [ entities = [
@ -100,6 +101,7 @@ val databaseMigrations: Array<Migration>
Migration12To13(), Migration12To13(),
Migration13To14(), Migration13To14(),
Migration14To15(), Migration14To15(),
Migration15To16(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

@ -23,4 +23,5 @@ data class MangaPrefsEntity(
@ColumnInfo(name = "mode") val mode: Int, @ColumnInfo(name = "mode") val mode: Int,
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float, @ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
@ColumnInfo(name = "cf_contrast") val cfContrast: 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, private val settings: AppSettings,
) : ProxySelector() { ) : ProxySelector() {
init {
setDefault(this)
}
private var cachedProxy: Proxy? = null private var cachedProxy: Proxy? = null
override fun select(uri: URI?): List<Proxy> { override fun select(uri: URI?): List<Proxy> {

@ -14,6 +14,7 @@ object CommonHeaders {
const val ACCEPT_ENCODING = "Accept-Encoding" const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization" const val AUTHORIZATION = "Authorization"
const val CACHE_CONTROL = "Cache-Control" const val CACHE_CONTROL = "Cache-Control"
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
val CACHE_CONTROL_NO_STORE: CacheControl val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build() 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) writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar) cookieJar(cookieJar)
proxySelector(AppProxySelector(settings)) proxySelector(AppProxySelector(settings))
proxyAuthenticator(ProxyAuthenticator(settings))
dns(DoHManager(cache, settings)) dns(DoHManager(cache, settings))
if (settings.isSSLBypassEnabled) { if (settings.isSSLBypassEnabled) {
bypassSSLErrors() 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.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -22,7 +20,6 @@ import javax.inject.Inject
@Reusable @Reusable
class MangaDataRepository @Inject constructor( class MangaDataRepository @Inject constructor(
@MangaHttpClient private val okHttpClient: OkHttpClient,
private val db: MangaDatabase, private val db: MangaDatabase,
) { ) {
@ -42,6 +39,7 @@ class MangaDataRepository @Inject constructor(
entity.copy( entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f, cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f, cfContrast = colorFilter?.contrast ?: 0f,
cfInvert = colorFilter?.isInverted ?: false,
), ),
) )
} }
@ -84,8 +82,8 @@ class MangaDataRepository @Inject constructor(
} }
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f) { return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) {
ReaderColorFilter(cfBrightness, cfContrast) ReaderColorFilter(cfBrightness, cfContrast, cfInvert)
} else { } else {
null null
} }
@ -96,5 +94,6 @@ class MangaDataRepository @Inject constructor(
mode = -1, mode = -1,
cfBrightness = 0f, cfBrightness = 0f,
cfContrast = 0f, cfContrast = 0f,
cfInvert = false,
) )
} }

@ -22,6 +22,8 @@ interface MangaRepository {
val sortOrders: Set<SortOrder> val sortOrders: Set<SortOrder>
var defaultSortOrder: SortOrder
suspend fun getList(offset: Int, query: String): List<Manga> suspend fun getList(offset: Int, query: String): List<Manga>
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): 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> override val sortOrders: Set<SortOrder>
get() = parser.sortOrders get() = parser.sortOrders
var defaultSortOrder: SortOrder? override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.firstOrNull() get() = getConfig().defaultSortOrder ?: sortOrders.first()
set(value) { set(value) {
getConfig().defaultSortOrder = value getConfig().defaultSortOrder = value
} }
@ -101,7 +101,7 @@ class RemoteMangaRepository(
} }
fun getAvailableMirrors(): List<String> { fun getAvailableMirrors(): List<String> {
return parser.configKeyDomain.presetValues?.toList().orEmpty() return parser.configKeyDomain.presetValues.toList()
} }
private fun getConfig() = parser.config as SourceSettings private fun getConfig() = parser.config as SourceSettings

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

@ -277,6 +277,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderSliderEnabled: Boolean val isReaderSliderEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_SLIDER, true) get() = prefs.getBoolean(KEY_READER_SLIDER, true)
val isImagesProxyEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
val dnsOverHttps: DoHProvider val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
@ -295,6 +298,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val proxyPort: Int val proxyPort: Int
get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0 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 var localListOrder: SortOrder
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } 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_TYPE = "proxy_type"
const val KEY_PROXY_ADDRESS = "proxy_address" const val KEY_PROXY_ADDRESS = "proxy_address"
const val KEY_PROXY_PORT = "proxy_port" 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 // About
const val KEY_APP_UPDATE = "app_update" 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 org.koitharu.kotatsu.core.util.ext.getDisplaySize
import com.google.android.material.R as materialR 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() { abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
var viewBinding: B? = null var viewBinding: B? = null

@ -3,57 +3,36 @@ package org.koitharu.kotatsu.core.ui
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.view.WindowManager import android.view.WindowManager
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.viewbinding.ViewBinding 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> : abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>(), BaseActivity<B>() {
View.OnSystemUiVisibilityChangeListener {
private lateinit var insetsControllerCompat: WindowInsetsControllerCompat
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
with(window) { with(window) {
insetsControllerCompat = WindowInsetsControllerCompat(this, decorView)
statusBarColor = Color.TRANSPARENT statusBarColor = Color.TRANSPARENT
navigationBarColor = Color.TRANSPARENT navigationBarColor = Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
attributes.layoutInDisplayCutoutMode = attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} }
decorView.setOnSystemUiVisibilityChangeListener(this@BaseFullscreenActivity)
} }
insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
showSystemUI() 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() { protected fun hideSystemUI() {
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN insetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
} }
@Suppress("DEPRECATION")
protected fun showSystemUI() { 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.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) : abstract class BoundsScrollListener(
RecyclerView.OnScrollListener() { @JvmField protected val offsetTop: Int,
@JvmField protected val offsetBottom: Int
) : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
@ -24,9 +26,16 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
if (firstVisibleItemPosition <= offsetTop) { if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView) onScrolledToStart(recyclerView)
} }
onPostScrolled(recyclerView, firstVisibleItemPosition, visibleItemCount)
} }
abstract fun onScrolledToStart(recyclerView: RecyclerView) abstract fun onScrolledToStart(recyclerView: RecyclerView)
abstract fun onScrolledToEnd(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.util.AttributeSet
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -15,6 +16,12 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val fastScroller = FastScroller(context, attrs) val fastScroller = FastScroller(context, attrs)
var isFastScrollerEnabled: Boolean = true
set(value) {
field = value
fastScroller.isVisible = value && isVisible
}
init { init {
fastScroller.id = R.id.fast_scroller fastScroller.id = R.id.fast_scroller
fastScroller.layoutParams = ViewGroup.LayoutParams( fastScroller.layoutParams = ViewGroup.LayoutParams(
@ -30,7 +37,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
override fun setVisibility(visibility: Int) { override fun setVisibility(visibility: Int) {
super.setVisibility(visibility) super.setVisibility(visibility)
fastScroller.visibility = visibility fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
} }
override fun onAttachedToWindow() { override fun onAttachedToWindow() {

@ -56,6 +56,7 @@ class FastScroller @JvmOverloads constructor(
private var bubbleHeight = 0 private var bubbleHeight = 0
private var handleHeight = 0 private var handleHeight = 0
private var viewHeight = 0 private var viewHeight = 0
private var offset = 0
private var hideScrollbar = true private var hideScrollbar = true
private var showBubble = true private var showBubble = true
private var showBubbleAlways = false private var showBubbleAlways = false
@ -137,6 +138,7 @@ class FastScroller @JvmOverloads constructor(
bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL) bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL)
val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize) val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize)
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
offset = getDimensionPixelOffset(R.styleable.FastScroller_scrollerOffset, offset)
} }
setTrackColor(trackColor) setTrackColor(trackColor)
@ -248,7 +250,7 @@ class FastScroller @JvmOverloads constructor(
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply { layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
height = 0 height = 0
setMargins(0, marginTop, 0, marginBottom) setMargins(offset, marginTop, offset, marginBottom)
} }
} }
@ -256,13 +258,13 @@ class FastScroller @JvmOverloads constructor(
height = LayoutParams.MATCH_PARENT height = LayoutParams.MATCH_PARENT
anchorGravity = GravityCompat.END anchorGravity = GravityCompat.END
anchorId = recyclerViewId anchorId = recyclerViewId
setMargins(0, marginTop, 0, marginBottom) setMargins(offset, marginTop, offset, marginBottom)
} }
is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply { is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
height = LayoutParams.MATCH_PARENT height = LayoutParams.MATCH_PARENT
gravity = GravityCompat.END gravity = GravityCompat.END
setMargins(0, marginTop, 0, marginBottom) setMargins(offset, marginTop, offset, marginBottom)
} }
is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply { is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply {
@ -270,7 +272,7 @@ class FastScroller @JvmOverloads constructor(
addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
addRule(RelativeLayout.ALIGN_END, 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") 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 private const val THROTTLE_DELAY = 200L
@Deprecated("")
class BottomSheetHeaderBar @JvmOverloads constructor( class BottomSheetHeaderBar @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,

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

@ -8,7 +8,7 @@ import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class RetainedLifecycleCoroutineScope( class RetainedLifecycleCoroutineScope(
private val lifecycle: RetainedLifecycle, val lifecycle: RetainedLifecycle,
) : CoroutineScope, RetainedLifecycle.OnClearedListener { ) : CoroutineScope, RetainedLifecycle.OnClearedListener {
override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate 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.Activity
import android.app.ActivityManager import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions import android.app.ActivityOptions
import android.content.Context import android.content.Context
import android.content.Context.ACTIVITY_SERVICE import android.content.Context.ACTIVITY_SERVICE
@ -140,6 +141,13 @@ fun Context.isLowRamDevice(): Boolean {
return activityManager?.isLowRamDevice ?: false 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( fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation(
view, view,
0, 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.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.lifecycle.RetainedLifecycle
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@ -18,6 +20,9 @@ import kotlin.coroutines.resumeWithException
val processLifecycleScope: LifecycleCoroutineScope val processLifecycleScope: LifecycleCoroutineScope
inline get() = ProcessLifecycleOwner.get().lifecycleScope inline get() = ProcessLifecycleOwner.get().lifecycleScope
val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope
inline get() = RetainedLifecycleCoroutineScope(this)
suspend fun Lifecycle.awaitStateAtLeast(state: Lifecycle.State) { suspend fun Lifecycle.awaitStateAtLeast(state: Lifecycle.State) {
if (currentState.isAtLeast(state)) { if (currentState.isAtLeast(state)) {
return return

@ -9,13 +9,9 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.Event import org.koitharu.kotatsu.core.util.Event
fun <T> Flow<T>.observe(owner: LifecycleOwner, collector: FlowCollector<T>) { 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 val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT
owner.lifecycleScope.launch(start = start) { owner.lifecycleScope.launch(start = start) {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
@ -23,3 +24,17 @@ fun Response.parseJsonOrNull(): JSONObject? {
closeQuietly() 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 package org.koitharu.kotatsu.core.util.ext
import org.koitharu.kotatsu.core.util.CompositeRunnable
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T> Class<T>.castOrNull(obj: Any?): T? { fun <T> Class<T>.castOrNull(obj: Any?): T? {
if (obj == null || !isInstance(obj)) { if (obj == null || !isInstance(obj)) {
@ -7,3 +9,15 @@ fun <T> Class<T>.castOrNull(obj: Any?): T? {
} }
return obj as 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.AttrRes
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.annotation.Px
import androidx.core.content.res.use import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
@ -22,6 +23,22 @@ fun Context.getThemeColor(
it.getColor(0, fallback) 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 @ColorInt
fun Context.getThemeColor( fun Context.getThemeColor(
@AttrRes resId: Int, @AttrRes resId: Int,

@ -1,10 +1,12 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.createViewModelLazy import androidx.fragment.app.createViewModelLazy
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
@MainThread @MainThread
@ -17,3 +19,7 @@ inline fun <reified VM : ViewModel> Fragment.parentFragmentViewModels(
extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras }, extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras },
factoryProducer = factoryProducer ?: { requireParentFragment().defaultViewModelProviderFactory }, 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 androidx.appcompat.view.ActionMode
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koitharu.kotatsu.core.ui.util.ActionModeListener 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( class ChaptersBottomSheetMediator(
bottomSheet: View, private val behavior: BottomSheetBehavior<*>,
) : OnBackPressedCallback(false), ) : OnBackPressedCallback(false),
ActionModeListener, ActionModeListener,
BottomSheetHeaderBar.OnExpansionChangeListener,
OnLayoutChangeListener { OnLayoutChangeListener {
private val behavior = BottomSheetBehavior.from(bottomSheet)
private var lockCounter = 0 private var lockCounter = 0
init {
behavior.doOnExpansionsChanged { isExpanded ->
isEnabled = isExpanded
if (!isExpanded) {
unlock()
}
}
}
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED behavior.state = BottomSheetBehavior.STATE_COLLAPSED
} }
@ -30,13 +37,6 @@ class ChaptersBottomSheetMediator(
unlock() unlock()
} }
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
isEnabled = isExpanded
if (!isExpanded) {
unlock()
}
}
override fun onLayoutChange( override fun onLayoutChange(
v: View?, v: View?,
left: Int, left: Int,
@ -61,6 +61,9 @@ class ChaptersBottomSheetMediator(
fun unlock() { fun unlock() {
lockCounter-- lockCounter--
if (lockCounter < 0) {
lockCounter = 0
}
behavior.isDraggable = lockCounter <= 0 behavior.isDraggable = lockCounter <= 0
} }
} }

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
@ -56,6 +57,9 @@ class ChaptersFragment :
checkNotNull(selectionController).attachToRecyclerView(this) checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true) setHasFixedSize(true)
adapter = chaptersAdapter 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.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged) viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)

@ -1,9 +1,11 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
fun mapChapters( fun mapChapters(
remoteManga: Manga?, remoteManga: Manga?,
@ -11,12 +13,14 @@ fun mapChapters(
history: MangaHistory?, history: MangaHistory?,
newCount: Int, newCount: Int,
branch: String?, branch: String?,
bookmarks: List<Bookmark>,
): List<ChapterListItem> { ): List<ChapterListItem> {
val remoteChapters = remoteManga?.getChapters(branch).orEmpty() val remoteChapters = remoteManga?.getChapters(branch).orEmpty()
val localChapters = localManga?.getChapters(branch).orEmpty() val localChapters = localManga?.getChapters(branch).orEmpty()
if (remoteChapters.isEmpty() && localChapters.isEmpty()) { if (remoteChapters.isEmpty() && localChapters.isEmpty()) {
return emptyList() return emptyList()
} }
val bookmarked = bookmarks.mapToSet { it.chapterId }
val currentId = history?.chapterId ?: 0L val currentId = history?.chapterId ?: 0L
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
val chaptersSize = maxOf(remoteChapters.size, localChapters.size) val chaptersSize = maxOf(remoteChapters.size, localChapters.size)
@ -41,6 +45,7 @@ fun mapChapters(
isUnread = isUnread, isUnread = isUnread,
isNew = isUnread && result.size >= newFrom, isNew = isUnread && result.size >= newFrom,
isDownloaded = local != null, isDownloaded = local != null,
isBookmarked = chapter.id in bookmarked,
) )
} }
if (!localMap.isNullOrEmpty()) { if (!localMap.isNullOrEmpty()) {
@ -53,6 +58,7 @@ fun mapChapters(
isUnread = isUnread, isUnread = isUnread,
isNew = false, isNew = false,
isDownloaded = remoteManga != null, 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.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import java.lang.ref.WeakReference
class ChaptersMenuProvider( class ChaptersMenuProvider(
private val viewModel: DetailsViewModel, private val viewModel: DetailsViewModel,
private val bottomSheetMediator: ChaptersBottomSheetMediator?, 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) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_chapters, menu) menuInflater.inflate(R.menu.opt_chapters, menu)
@ -20,6 +24,7 @@ class ChaptersMenuProvider(
searchView.setOnQueryTextListener(this) searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false) searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title searchView.queryHint = searchMenuItem.title
searchItemRef = WeakReference(searchMenuItem)
} }
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
@ -32,15 +37,22 @@ class ChaptersMenuProvider(
viewModel.setChaptersReversed(!menuItem.isChecked) viewModel.setChaptersReversed(!menuItem.isChecked)
true true
} }
else -> false else -> false
} }
override fun handleOnBackPressed() {
searchItemRef?.get()?.collapseActionView()
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
bottomSheetMediator?.lock() bottomSheetMediator?.lock()
isEnabled = true
return true return true
} }
override fun onMenuItemActionCollapse(item: MenuItem): Boolean { override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
isEnabled = false
(item.actionView as? SearchView)?.setQuery("", false) (item.actionView as? SearchView)?.setQuery("", false)
viewModel.performChapterSearch(null) viewModel.performChapterSearch(null)
bottomSheetMediator?.unlock() bottomSheetMediator?.unlock()

@ -4,12 +4,14 @@ import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.transition.AutoTransition
import android.transition.Slide import android.transition.Slide
import android.transition.TransitionManager import android.transition.TransitionManager
import android.view.Gravity import android.view.Gravity
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
@ -17,7 +19,9 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding 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.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -31,8 +35,11 @@ import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.util.ViewBadge 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.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat 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.ReaderActivity
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class DetailsActivity : class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(), BaseActivity<ActivityDetailsBinding>(),
View.OnClickListener, View.OnClickListener,
BottomSheetHeaderBar.OnExpansionChangeListener,
NoModalBottomSheetOwner, NoModalBottomSheetOwner,
View.OnLongClickListener, View.OnLongClickListener,
PopupMenu.OnMenuItemClickListener { PopupMenu.OnMenuItemClickListener {
override val bsHeader: BottomSheetHeaderBar?
get() = viewBinding.headerChapters
@Inject @Inject
lateinit var shortcutsUpdater: ShortcutsUpdater lateinit var shortcutsUpdater: ShortcutsUpdater
@ -82,16 +86,22 @@ class DetailsActivity :
viewBinding.buttonDropdown.setOnClickListener(this) viewBinding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(viewBinding.buttonRead, this) viewBadge = ViewBadge(viewBinding.buttonRead, this)
chaptersMenuProvider = if (viewBinding.layoutBottom != null) { if (viewBinding.layoutBottom != null) {
val bsMediator = ChaptersBottomSheetMediator(checkNotNull(viewBinding.layoutBottom)) val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
val bsMediator = ChaptersBottomSheetMediator(behavior)
actionModeDelegate.addListener(bsMediator) actionModeDelegate.addListener(bsMediator)
checkNotNull(viewBinding.headerChapters).addOnExpansionChangeListener(bsMediator) checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator)
checkNotNull(viewBinding.headerChapters).addOnLayoutChangeListener(bsMediator)
onBackPressedDispatcher.addCallback(bsMediator) onBackPressedDispatcher.addCallback(bsMediator)
ChaptersMenuProvider(viewModel, bsMediator) chaptersMenuProvider = ChaptersMenuProvider(viewModel, bsMediator)
behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged)
viewBinding.toolbarChapters?.setNavigationOnClickListener {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
} else { } else {
ChaptersMenuProvider(viewModel, null) chaptersMenuProvider = ChaptersMenuProvider(viewModel, null)
addMenuProvider(chaptersMenuProvider)
} }
onBackPressedDispatcher.addCallback(chaptersMenuProvider)
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
@ -114,11 +124,11 @@ class DetailsActivity :
} }
viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) { viewModel.selectedBranch.observe(this) {
viewBinding.headerChapters?.subtitle = it viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it viewBinding.textViewSubtitle?.textAndVisible = it
} }
viewModel.isChaptersReversed.observe(this) { viewModel.isChaptersReversed.observe(this) {
viewBinding.headerChapters?.invalidateMenu() ?: invalidateOptionsMenu() viewBinding.toolbarChapters?.invalidateMenu() ?: invalidateOptionsMenu()
} }
viewModel.favouriteCategories.observe(this) { viewModel.favouriteCategories.observe(this) {
invalidateOptionsMenu() invalidateOptionsMenu()
@ -137,7 +147,10 @@ class DetailsActivity :
shortcutsUpdater = shortcutsUpdater, shortcutsUpdater = shortcutsUpdater,
), ),
) )
viewBinding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider) }
override fun getBottomSheetCollapsedHeight(): Int {
return viewBinding.layoutBsHeader?.measureHeight() ?: 0
} }
override fun onClick(v: View) { 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) { if (isExpanded) {
headerBar.addMenuProvider(chaptersMenuProvider) toolbar.addMenuProvider(chaptersMenuProvider)
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
} else { } else {
headerBar.removeMenuProvider(chaptersMenuProvider) toolbar.removeMenuProvider(chaptersMenuProvider)
toolbar.navigationIcon = null
} }
viewBinding.buttonRead.isGone = isExpanded viewBinding.buttonRead.isGone = isExpanded
} }
@ -219,6 +240,12 @@ class DetailsActivity :
if (insets.bottom > 0) { if (insets.bottom > 0) {
window.setNavigationBarTransparentCompat(this, viewBinding.layoutBottom?.elevation ?: 0f, 0.9f) 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) { private fun onHistoryChanged(info: HistoryInfo) {
@ -237,7 +264,7 @@ class DetailsActivity :
info.totalChapters == 0 -> getString(R.string.no_chapters) info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters) else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters)
} }
viewBinding.headerChapters?.title = text viewBinding.toolbarChapters?.title = text
viewBinding.textViewTitle?.text = text viewBinding.textViewTitle?.text = text
} }
@ -282,8 +309,6 @@ class DetailsActivity :
} }
} }
private fun isTabletLayout() = viewBinding.layoutBottom == null
private fun showBottomSheet(isVisible: Boolean) { private fun showBottomSheet(isVisible: Boolean) {
val view = viewBinding.layoutBottom ?: return val view = viewBinding.layoutBottom ?: return
if (view.isVisible == isVisible) return if (view.isVisible == isVisible) return
@ -297,7 +322,7 @@ class DetailsActivity :
private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar { private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar {
val sb = Snackbar.make(viewBinding.containerDetails, text, duration) val sb = Snackbar.make(viewBinding.containerDetails, text, duration)
if (viewBinding.layoutBottom?.isVisible == true) { if (viewBinding.layoutBottom?.isVisible == true) {
sb.anchorView = viewBinding.headerChapters sb.anchorView = viewBinding.toolbarChapters
} }
return sb 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.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
@ -84,6 +83,7 @@ class DetailsFragment :
binding.infoLayout.textViewSource.setOnClickListener(this) binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
TitleScrollCoordinator(binding.textViewTitle).attach(binding.scrollView)
viewModel.manga.filterNotNull().observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.filterNotNull().observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged)
@ -271,7 +271,7 @@ class DetailsFragment :
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().root.updatePadding( requireViewBinding().root.updatePadding(
bottom = ( bottom = (
(activity as? NoModalBottomSheetOwner)?.bsHeader?.measureHeight() (activity as? NoModalBottomSheetOwner)?.getBottomSheetCollapsedHeight()
?.plus(insets.bottom)?.plus(resources.resolveDp(16)) ?.plus(insets.bottom)?.plus(resources.resolveDp(16))
) )
?: insets.bottom, ?: insets.bottom,
@ -284,6 +284,7 @@ class DetailsFragment :
ChipsView.ChipModel( ChipsView.ChipModel(
title = tag.title, title = tag.title,
tint = tagHighlighter.getTint(tag), tint = tagHighlighter.getTint(tag),
icon = 0,
data = tag, data = tag,
isCheckable = false, isCheckable = false,
isChecked = 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.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.details.ui.model.MangaBranch 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet 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 import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
class DetailsMenuProvider( class DetailsMenuProvider(
@ -63,7 +63,7 @@ class DetailsMenuProvider(
R.id.action_favourite -> { R.id.action_favourite -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
FavouriteCategoriesBottomSheet.show(activity.supportFragmentManager, it) FavouriteCategoriesSheet.show(activity.supportFragmentManager, it)
} }
} }
@ -105,7 +105,7 @@ class DetailsMenuProvider(
R.id.action_scrobbling -> { R.id.action_scrobbling -> {
viewModel.manga.value?.let { 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, history,
selectedBranch, selectedBranch,
newChaptersCount, newChaptersCount,
) { manga, history, branch, news -> bookmarks,
mapChapters(manga?.remote, manga?.local, history, news, branch) ) { manga, history, branch, news, bookmarks ->
mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks)
}, },
isChaptersReversed, isChaptersReversed,
chaptersQuery, chaptersQuery,
@ -209,8 +210,8 @@ class DetailsViewModel @Inject constructor(
} }
fun removeBookmark(bookmark: Bookmark) { fun removeBookmark(bookmark: Bookmark) {
launchJob { launchJob(Dispatchers.Default) {
bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId) bookmarksRepository.removeBookmark(bookmark)
onShowToast.call(R.string.bookmark_removed) 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 package org.koitharu.kotatsu.details.ui.adapter
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener 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.getThemeColor
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.databinding.ItemChapterBinding
@ -43,7 +45,13 @@ fun chapterListItemAD(
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary)) binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary))
} }
} }
binding.imageViewBookmarked.isVisible = item.isBookmarked
binding.imageViewDownloaded.isVisible = item.isDownloaded 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 val isDownloaded: Boolean
get() = hasFlag(FLAG_DOWNLOADED) get() = hasFlag(FLAG_DOWNLOADED)
val isBookmarked: Boolean
get() = hasFlag(FLAG_BOOKMARKED)
val isNew: Boolean val isNew: Boolean
get() = hasFlag(FLAG_NEW) get() = hasFlag(FLAG_NEW)
@ -70,6 +73,7 @@ class ChapterListItem(
const val FLAG_UNREAD = 2 const val FLAG_UNREAD = 2
const val FLAG_CURRENT = 4 const val FLAG_CURRENT = 4
const val FLAG_NEW = 8 const val FLAG_NEW = 8
const val FLAG_BOOKMARKED = 16
const val FLAG_DOWNLOADED = 32 const val FLAG_DOWNLOADED = 32
} }
} }

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

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

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

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

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

@ -6,6 +6,7 @@ import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@Dao @Dao
@ -71,12 +72,12 @@ abstract class FavouritesDao {
) )
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity> 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) val orderBy = getOrderBy(order)
@Language("RoomSql") @Language("RoomSql")
val query = SimpleSQLiteQuery( 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", "WHERE f.category_id = ? AND deleted_at = 0 ORDER BY $orderBy",
arrayOf<Any>(categoryId), arrayOf<Any>(categoryId),
) )
@ -145,7 +146,7 @@ abstract class FavouritesDao {
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<FavouriteManga>> protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<FavouriteManga>>
@RawQuery @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") @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId")
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long) 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.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toManga import org.koitharu.kotatsu.favourites.data.toManga
import org.koitharu.kotatsu.favourites.data.toMangaList 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.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
@ -66,11 +67,11 @@ class FavouritesRepository @Inject constructor(
}.distinctUntilChanged() }.distinctUntilChanged()
} }
fun observeCategoriesWithCovers(): Flow<Map<FavouriteCategory, List<String>>> { fun observeCategoriesWithCovers(): Flow<Map<FavouriteCategory, List<Cover>>> {
return db.favouriteCategoriesDao.observeAll() return db.favouriteCategoriesDao.observeAll()
.map { .map {
db.withTransaction { db.withTransaction {
val res = LinkedHashMap<FavouriteCategory, List<String>>() val res = LinkedHashMap<FavouriteCategory, List<Cover>>()
for (entity in it) { for (entity in it) {
val cat = entity.toFavouriteCategory() val cat = entity.toFavouriteCategory()
res[cat] = db.favouritesDao.findCovers( 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 coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.animatorDurationScale
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemCategoryBinding import org.koitharu.kotatsu.databinding.ItemCategoryBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@ -53,10 +54,7 @@ fun categoryAD(
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)) ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153))
val fallback = ColorDrawable(Color.TRANSPARENT) val fallback = ColorDrawable(Color.TRANSPARENT)
val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3) val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3)
val crossFadeDuration = ( val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt()
context.resources.getInteger(R.integer.config_defaultAnimTime) *
context.animatorDurationScale
).toInt()
itemView.setOnClickListener(eventListener) itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener) itemView.setOnLongClickListener(eventListener)
itemView.setOnTouchListener(eventListener) itemView.setOnTouchListener(eventListener)
@ -77,9 +75,11 @@ fun categoryAD(
) )
} }
repeat(coverViews.size) { i -> 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) placeholder(R.drawable.ic_placeholder)
fallback(fallback) fallback(fallback)
source(cover?.mangaSource)
crossfade(crossFadeDuration * (i + 1)) crossfade(crossFadeDuration * (i + 1))
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)

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

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

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus 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.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet.Companion.KEY_MANGA_LIST import org.koitharu.kotatsu.favourites.ui.categories.select.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.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -23,17 +26,21 @@ class MangaCategoriesViewModel @Inject constructor(
) : BaseViewModel() { ) : BaseViewModel() {
private val manga = requireNotNull(savedStateHandle.get<List<ParcelableManga>>(KEY_MANGA_LIST)).map { it.manga } 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(), favouritesRepository.observeCategories(),
observeCategoriesIds(), observeCategoriesIds(),
) { all, checked -> ) { all, checked ->
all.map { buildList(all.size + 1) {
MangaCategoryItem( add(header)
id = it.id, all.mapTo(this) {
name = it.title, MangaCategoryItem(
isChecked = it.id in checked, id = it.id,
) name = it.title,
isChecked = it.id in checked,
)
}
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) }.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 androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener 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.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
class MangaCategoriesAdapter( class MangaCategoriesAdapter(
clickListener: OnListItemClickListener<MangaCategoryItem> clickListener: OnListItemClickListener<MangaCategoryItem>,
) : AsyncListDifferDelegationAdapter<MangaCategoryItem>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(mangaCategoryAD(clickListener)) delegatesManager.addDelegate(mangaCategoryAD(clickListener))
.addDelegate(categoriesHeaderAD())
} }
private class DiffCallback : DiffUtil.ItemCallback<MangaCategoryItem>() { private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame( override fun areItemsTheSame(
oldItem: MangaCategoryItem, oldItem: ListModel,
newItem: MangaCategoryItem newItem: ListModel,
): Boolean = oldItem.id == newItem.id ): 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( override fun areContentsTheSame(
oldItem: MangaCategoryItem, oldItem: ListModel,
newItem: MangaCategoryItem newItem: ListModel,
): Boolean = oldItem == newItem ): Boolean = oldItem == newItem
override fun getChangePayload( override fun getChangePayload(
oldItem: MangaCategoryItem, oldItem: ListModel,
newItem: MangaCategoryItem newItem: ListModel,
): Any? { ): Any? {
if (oldItem.isChecked != newItem.isChecked) { if (oldItem is MangaCategoryItem && newItem is MangaCategoryItem && oldItem.isChecked != newItem.isChecked) {
return newItem.isChecked return newItem.isChecked
} }
return super.getChangePayload(oldItem, newItem) 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.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
fun mangaCategoryAD( fun mangaCategoryAD(
clickListener: OnListItemClickListener<MangaCategoryItem> clickListener: OnListItemClickListener<MangaCategoryItem>,
) = adapterDelegateViewBinding<MangaCategoryItem, MangaCategoryItem, ItemCheckableNewBinding>( ) = adapterDelegateViewBinding<MangaCategoryItem, ListModel, ItemCheckableNewBinding>(
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) }, { 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 package org.koitharu.kotatsu.favourites.ui.categories.select.model
import org.koitharu.kotatsu.list.ui.model.ListModel
data class MangaCategoryItem( data class MangaCategoryItem(
val id: Long, val id: Long,
val name: String, 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 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.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -17,21 +19,40 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.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.model.MangaTag
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.text.Collator import java.text.Collator
import java.util.LinkedList
import java.util.Locale import java.util.Locale
import java.util.TreeSet import java.util.TreeSet
import javax.inject.Inject
class FilterCoordinator( @ViewModelScoped
private val repository: RemoteMangaRepository, class FilterCoordinator @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
dataRepository: MangaDataRepository, dataRepository: MangaDataRepository,
private val coroutineScope: CoroutineScope, private val searchRepository: MangaSearchRepository,
) : OnFilterChangedListener { 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 val currentState = MutableStateFlow(FilterState(repository.defaultSortOrder, emptySet()))
private var searchQuery = MutableStateFlow("") private var searchQuery = MutableStateFlow("")
private val localTags = SuspendLazy { private val localTags = SuspendLazy {
@ -39,13 +60,23 @@ class FilterCoordinator(
} }
private var availableTagsDeferred = loadTagsAsync() private var availableTagsDeferred = loadTagsAsync()
val items: StateFlow<List<FilterItem>> = getItemsFlow() override val filterItems: StateFlow<List<ListModel>> = getItemsFlow()
.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(FilterItem.Loading)) .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 { init {
observeState() observeState()
} }
override fun applyFilter(tags: Set<MangaTag>) {
setTags(tags)
}
override fun onSortItemClick(item: FilterItem.Sort) { override fun onSortItemClick(item: FilterItem.Sort) {
currentState.update { oldValue -> currentState.update { oldValue ->
FilterState(item.order, oldValue.tags) FilterState(item.order, oldValue.tags)
@ -91,6 +122,14 @@ class FilterCoordinator(
searchQuery.value = query 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( private fun getItemsFlow() = combine(
getTagsAsFlow(), getTagsAsFlow(),
currentState, 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 @WorkerThread
private fun buildFilterList( private fun buildFilterList(
allTags: TagsWrapper, allTags: TagsWrapper,
state: FilterState, state: FilterState,
query: String, query: String,
): List<FilterItem> { ): List<ListModel> {
val sortOrders = repository.sortOrders.sortedBy { it.ordinal } val sortOrders = repository.sortOrders.sortedBy { it.ordinal }
val tags = mergeTags(state.tags, allTags.tags).toList() 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 (query.isEmpty()) {
if (sortOrders.isNotEmpty()) { if (sortOrders.isNotEmpty()) {
list.add(FilterItem.Header(R.string.sort_order, 0)) list.add(ListHeader(R.string.sort_order, 0, null))
sortOrders.mapTo(list) { sortOrders.mapTo(list) {
FilterItem.Sort(it, isSelected = it == state.sortOrder) FilterItem.Sort(it, isSelected = it == state.sortOrder)
} }
} }
if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) { 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) { tags.mapTo(list) {
FilterItem.Tag(it, isChecked = it in state.tags) FilterItem.Tag(it, isChecked = it in state.tags)
} }
@ -135,7 +216,7 @@ class FilterCoordinator(
if (allTags.isError) { if (allTags.isError) {
list.add(FilterItem.Error(R.string.filter_load_error)) list.add(FilterItem.Error(R.string.filter_load_error))
} else if (allTags.isLoading) { } else if (allTags.isLoading) {
list.add(FilterItem.Loading) list.add(LoadingFooter())
} }
} else { } else {
tags.mapNotNullTo(list) { 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,8 +1,10 @@
package org.koitharu.kotatsu.list.ui.filter package org.koitharu.kotatsu.filter.ui
import org.koitharu.kotatsu.filter.ui.model.FilterItem
interface OnFilterChangedListener { interface OnFilterChangedListener {
fun onSortItemClick(item: FilterItem.Sort) fun onSortItemClick(item: FilterItem.Sort)
fun onTagItemClick(item: FilterItem.Tag) fun onTagItemClick(item: FilterItem.Tag)
} }

@ -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.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
class ListHeader2( class FilterHeaderModel(
val chips: Collection<ChipsView.ChipModel>, val chips: Collection<ChipsView.ChipModel>,
val sortOrder: SortOrder?, val sortOrder: SortOrder?,
val hasSelectedTags: Boolean, val hasSelectedTags: Boolean,
) : ListModel { ) : ListModel {
val textSummary: String
get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString()
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
other as ListHeader2 other as FilterHeaderModel
if (chips != other.chips) return false if (chips != other.chips) return false
return sortOrder == other.sortOrder 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.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@ -15,9 +15,7 @@ class FilterState(
other as FilterState other as FilterState
if (sortOrder != other.sortOrder) return false if (sortOrder != other.sortOrder) return false
if (tags != other.tags) return false return tags == other.tags
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@ -25,4 +23,4 @@ class FilterState(
result = 31 * result + tags.hashCode() result = 31 * result + tags.hashCode()
return result return result
} }
} }

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

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

@ -1,24 +1,20 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding 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.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.databinding.ItemHeader2Binding import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@Deprecated("")
fun listHeader2AD( fun listHeader2AD(
listener: MangaListListener, listener: MangaListListener,
) = adapterDelegateViewBinding<ListHeader2, ListModel, ItemHeader2Binding>( ) = adapterDelegateViewBinding<FilterHeaderModel, ListModel, FragmentFilterHeaderBinding>(
{ layoutInflater, parent -> ItemHeader2Binding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> FragmentFilterHeaderBinding.inflate(layoutInflater, parent, false) },
) { ) {
var ignoreChecking = false var ignoreChecking = false
binding.textViewFilter.setOnClickListener {
listener.onFilterClick(it)
}
binding.chipsTags.setOnCheckedStateChangeListener { _, _ -> binding.chipsTags.setOnCheckedStateChangeListener { _, _ ->
if (!ignoreChecking) { if (!ignoreChecking) {
listener.onUpdateFilter(binding.chipsTags.getCheckedData(MangaTag::class.java)) listener.onUpdateFilter(binding.chipsTags.getCheckedData(MangaTag::class.java))
@ -36,6 +32,5 @@ fun listHeader2AD(
ignoreChecking = true ignoreChecking = true
binding.chipsTags.setChips(item.chips) // TODO use recyclerview binding.chipsTags.setChips(item.chips) // TODO use recyclerview
ignoreChecking = false 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 com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding 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.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@ -22,3 +23,12 @@ fun listHeaderAD(
binding.buttonMore.setTextAndVisible(item.buttonTextRes) 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 coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo 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.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.MangaGridModel 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) 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( ChipsView.ChipModel(
tint = tagHighlighter?.getTint(it) ?: 0, tint = tagHighlighter?.getTint(it) ?: 0,
title = it.title, title = it.title,
icon = 0,
isCheckable = false, isCheckable = false,
isChecked = false, isChecked = false,
data = it, data = it,

@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.CompositeMutex import org.koitharu.kotatsu.core.util.CompositeMutex
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.local.data.input.LocalMangaInput 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.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.File import java.io.File
import java.util.EnumSet
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -37,11 +39,20 @@ private const val MAX_PARALLELISM = 4
class LocalMangaRepository @Inject constructor( class LocalMangaRepository @Inject constructor(
private val storageManager: LocalStorageManager, private val storageManager: LocalStorageManager,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val settings: AppSettings,
) : MangaRepository { ) : MangaRepository {
override val source = MangaSource.LOCAL override val source = MangaSource.LOCAL
private val locks = CompositeMutex<Long>() 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> { override suspend fun getList(offset: Int, query: String): List<Manga> {
if (offset > 0) { if (offset > 0) {
return emptyList() return emptyList()
@ -137,8 +148,6 @@ class LocalMangaRepository @Inject constructor(
}.firstOrNull()?.getManga() }.firstOrNull()?.getManga()
} }
override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING)
override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getPageUrl(page: MangaPage) = page.url
override suspend fun getTags() = emptySet<MangaTag>() 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) { suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.get().parentFile, url.longHashCode().toString()) val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
try { try {
file.sink(append = false).buffer().use { val bytes = file.sink(append = false).buffer().use {
it.writeAllCancellable(source) it.writeAllCancellable(source)
} }
check(bytes != 0L) { "No data has been written" }
lruCache.get().put(url, file) lruCache.get().put(url, file)
} finally { } finally {
file.delete() file.delete()

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

@ -5,7 +5,6 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.fragment.app.viewModels 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.ShareHelper
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { class LocalListFragment : MangaListFragment() {
override val viewModel by viewModels<LocalListViewModel>() override val viewModel by viewModels<LocalListViewModel>()
@ -35,11 +37,7 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener
} }
override fun onFilterClick(view: View?) { override fun onFilterClick(view: View?) {
super.onFilterClick(view) FilterSheetFragment.show(childFragmentManager)
val menu = PopupMenu(requireContext(), view ?: requireViewBinding().recyclerView)
menu.inflate(R.menu.popup_order)
menu.setOnMenuItemClickListener(this)
menu.show()
} }
override fun onScrolledToEnd() = Unit 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) { private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
MaterialAlertDialogBuilder(context ?: return) MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.delete_manga) .setTitle(R.string.delete_manga)
@ -96,6 +83,8 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener
companion object { 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 package org.koitharu.kotatsu.local.ui
import androidx.lifecycle.viewModelScope import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers 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.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.R
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState 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.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
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 javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LocalListViewModel @Inject constructor( class LocalListViewModel @Inject constructor(
private val repository: LocalMangaRepository, savedStateHandle: SavedStateHandle,
private val historyRepository: HistoryRepository, mangaRepositoryFactory: MangaRepository.Factory,
private val trackingRepository: TrackingRepository, filter: FilterCoordinator,
private val settings: AppSettings, tagHighlighter: MangaTagHighlighter,
private val tagHighlighter: MangaTagHighlighter, settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
downloadScheduler: DownloadWorker.Scheduler, 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 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 { init {
onRefresh()
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
localStorageChanges localStorageChanges
.collectLatest { .collect {
if (refreshJob?.isActive != true) { loadList(filter.snapshot(), append = false).join()
doRefresh()
}
} }
} }
} }
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>) { fun delete(ids: Set<Long>) {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
deleteLocalMangaUseCase(ids) deleteLocalMangaUseCase(ids)
@ -125,60 +59,12 @@ class LocalListViewModel @Inject constructor(
} }
} }
private suspend fun doRefresh() { override fun createEmptyState(canResetFilter: Boolean): EmptyState {
try { return EmptyState(
listError.value = null icon = R.drawable.ic_empty_local,
mangaList.value = repository.getList(0, selectedTags.value, sortOrder.value) textPrimary = R.string.text_local_holder_primary,
} catch (e: CancellationException) { textSecondary = R.string.text_local_holder_secondary,
throw e actionStringRes = R.string._import,
} 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 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 = navigationDelegate =
MainNavigationDelegate(checkNotNull(bottomNav ?: viewBinding.navRail), supportFragmentManager) MainNavigationDelegate(checkNotNull(bottomNav ?: viewBinding.navRail), supportFragmentManager)
navigationDelegate.addOnFragmentChangedListener(this) navigationDelegate.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(savedInstanceState) navigationDelegate.onCreate()
onBackPressedDispatcher.addCallback(ExitCallback(this, viewBinding.container)) onBackPressedDispatcher.addCallback(ExitCallback(this, viewBinding.container))
onBackPressedDispatcher.addCallback(navigationDelegate) onBackPressedDispatcher.addCallback(navigationDelegate)

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

Loading…
Cancel
Save