diff --git a/app/build.gradle b/app/build.gradle index 64aab0ef4..094ef4ff5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 33 - versionCode 546 - versionName '5.1.2' + versionCode 547 + versionName '5.1.3' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -79,7 +79,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:ebcc6391d6') { + implementation('com.github.KotatsuApp:kotatsu-parsers:44e28b40d3') { exclude group: 'org.json', module: 'json' } @@ -88,7 +88,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.core:core-ktx:1.10.1' - implementation 'androidx.activity:activity-ktx:1.7.1' + implementation 'androidx.activity:activity-ktx:1.7.2' implementation 'androidx.fragment:fragment-ktx:1.5.7' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' @@ -97,7 +97,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.3.0' - implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' + implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'com.google.android.material:material:1.9.0' @@ -106,7 +106,7 @@ dependencies { implementation 'androidx.work:work-runtime-ktx:2.8.1' //noinspection GradleDependency - implementation('com.google.guava:guava:31.1-android') { + implementation('com.google.guava:guava:32.0.0-android') { exclude group: 'com.google.guava', module: 'failureaccess' exclude group: 'org.checkerframework', module: 'checker-qual' exclude group: 'com.google.j2objc', module: 'j2objc-annotations' @@ -116,8 +116,8 @@ dependencies { implementation 'androidx.room:room-ktx:2.5.1' kapt 'androidx.room:room-compiler:2.5.1' - implementation 'com.squareup.okhttp3:okhttp:4.10.0' - implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3' + implementation 'com.squareup.okhttp3:okhttp:4.11.0' + implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0' implementation 'com.squareup.okio:okio:3.3.0' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' @@ -128,8 +128,8 @@ dependencies { implementation 'androidx.hilt:hilt-work:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0' - implementation 'io.coil-kt:coil-base:2.3.0' - implementation 'io.coil-kt:coil-svg:2.3.0' + implementation 'io.coil-kt:coil-base:2.4.0' + implementation 'io.coil-kt:coil-svg:2.4.0' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f' implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'io.noties.markwon:core:4.6.2' @@ -137,7 +137,7 @@ dependencies { implementation 'ch.acra:acra-http:5.9.7' implementation 'ch.acra:acra-dialog:5.9.7' - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' + debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11' testImplementation 'junit:junit:4.13.2' testImplementation 'org.json:json:20230227' diff --git a/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt index a60655a2a..fb96f6e64 100644 --- a/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt +++ b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt @@ -17,7 +17,7 @@ import java.util.EnumSet class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { override val configKeyDomain: ConfigKey.Domain - get() = ConfigKey.Domain("", null) + get() = ConfigKey.Domain() override val sortOrders: Set get() = EnumSet.allOf(SortOrder::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt index 076b19a3c..0f7533857 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt @@ -1,6 +1,10 @@ package org.koitharu.kotatsu.bookmarks.data -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.entity.MangaWithTags @@ -18,7 +22,7 @@ abstract class BookmarksDao { @Transaction @Query( - "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at" + "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at", ) abstract fun observe(): Flow>> @@ -29,5 +33,8 @@ abstract class BookmarksDao { abstract suspend fun delete(entity: BookmarkEntity) @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") - abstract suspend fun delete(mangaId: Long, pageId: Long) -} \ No newline at end of file + 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 +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt index 5b6ff3bf0..421105155 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.bookmarks.domain import org.koitharu.kotatsu.parsers.model.Manga -import java.util.* +import java.util.Date class Bookmark( val manga: Manga, @@ -27,9 +27,7 @@ class Bookmark( if (scroll != other.scroll) return false if (imageUrl != other.imageUrl) return false if (createdAt != other.createdAt) return false - if (percent != other.percent) return false - - return true + return percent == other.percent } override fun hashCode(): Int { @@ -43,4 +41,4 @@ class Bookmark( result = 31 * result + percent.hashCode() return result } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt index 0c439a6bc..598113eb8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt @@ -52,8 +52,14 @@ class BookmarksRepository @Inject constructor( } } - suspend fun removeBookmark(mangaId: Long, pageId: Long) { - db.bookmarksDao.delete(mangaId, pageId) + suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) { + check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) { + "Bookmark not found" + } + } + + suspend fun removeBookmark(bookmark: Bookmark) { + removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page) } suspend fun removeBookmarks(ids: Map>): ReversibleHandle { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt index d47bbb785..8d15f00ab 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt @@ -19,7 +19,9 @@ class BookmarksAdapter( private class DiffCallback : DiffUtil.ItemCallback() { 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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt index 39b9b701b..1aad04a76 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt @@ -40,10 +40,11 @@ import org.koitharu.kotatsu.core.util.IncognitoModeIndicator import org.koitharu.kotatsu.core.util.ext.activityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.isLowRamDevice +import org.koitharu.kotatsu.list.domain.ListExtraProvider +import org.koitharu.kotatsu.list.domain.ListExtraProviderImpl import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.parsers.MangaLoaderContext @@ -64,6 +65,9 @@ interface AppModule { @Binds fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter + @Binds + fun bindListExtraProvider(impl: ListExtraProviderImpl): ListExtraProvider + companion object { @Provides @@ -86,7 +90,8 @@ interface AppModule { @ApplicationContext context: Context, @MangaHttpClient okHttpClient: OkHttpClient, mangaRepositoryFactory: MangaRepository.Factory, - pagesCache: PagesCache, + imageProxyInterceptor: ImageProxyInterceptor, + pageFetcherFactory: MangaPageFetcher.Factory, ): ImageLoader { val diskCacheFactory = { val rootDir = context.externalCacheDir ?: context.cacheDir @@ -108,7 +113,8 @@ interface AppModule { .add(SvgDecoder.Factory()) .add(CbzFetcher.Factory()) .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) - .add(MangaPageFetcher.Factory(context, okHttpClient, pagesCache, mangaRepositoryFactory)) + .add(pageFetcherFactory) + .add(imageProxyInterceptor) .build(), ).build() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 5378e420e..d70dcb983 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration11To12 import org.koitharu.kotatsu.core.db.migrations.Migration12To13 import org.koitharu.kotatsu.core.db.migrations.Migration13To14 import org.koitharu.kotatsu.core.db.migrations.Migration14To15 +import org.koitharu.kotatsu.core.db.migrations.Migration15To16 import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration3To4 @@ -48,7 +49,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TracksDao -const val DATABASE_VERSION = 15 +const val DATABASE_VERSION = 16 @Database( entities = [ @@ -100,6 +101,7 @@ val databaseMigrations: Array Migration12To13(), Migration13To14(), Migration14To15(), + Migration15To16(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt index a5ccdb765..98512e8b8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt @@ -23,4 +23,5 @@ data class MangaPrefsEntity( @ColumnInfo(name = "mode") val mode: Int, @ColumnInfo(name = "cf_brightness") val cfBrightness: Float, @ColumnInfo(name = "cf_contrast") val cfContrast: Float, + @ColumnInfo(name = "cf_invert") val cfInvert: Boolean, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration15To16.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration15To16.kt new file mode 100644 index 000000000..aba52e885 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration15To16.kt @@ -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") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt index 5beb6e42a..7d67a40fd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt @@ -13,6 +13,10 @@ class AppProxySelector( private val settings: AppSettings, ) : ProxySelector() { + init { + setDefault(this) + } + private var cachedProxy: Proxy? = null override fun select(uri: URI?): List { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt index f8976acd6..1454542ea 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -14,6 +14,7 @@ object CommonHeaders { const val ACCEPT_ENCODING = "Accept-Encoding" const val AUTHORIZATION = "Authorization" const val CACHE_CONTROL = "Cache-Control" + const val PROXY_AUTHORIZATION = "Proxy-Authorization" val CACHE_CONTROL_NO_STORE: CacheControl get() = CacheControl.Builder().noStore().build() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ImageProxyInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ImageProxyInterceptor.kt new file mode 100644 index 000000000..54423ba67 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ImageProxyInterceptor.kt @@ -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()) + + 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()) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt index a60c5db64..1c91966a0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -59,6 +59,7 @@ interface NetworkModule { writeTimeout(20, TimeUnit.SECONDS) cookieJar(cookieJar) proxySelector(AppProxySelector(settings)) + proxyAuthenticator(ProxyAuthenticator(settings)) dns(DoHManager(cache, settings)) if (settings.isSSLBypassEnabled) { bypassSSLErrors() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ProxyAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ProxyAuthenticator.kt new file mode 100644 index 000000000..fb4ffad7e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ProxyAuthenticator.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt index 45c2e5df9..81b13ada2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt @@ -5,14 +5,12 @@ import dagger.Reusable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import okhttp3.OkHttpClient import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags -import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -22,7 +20,6 @@ import javax.inject.Inject @Reusable class MangaDataRepository @Inject constructor( - @MangaHttpClient private val okHttpClient: OkHttpClient, private val db: MangaDatabase, ) { @@ -42,6 +39,7 @@ class MangaDataRepository @Inject constructor( entity.copy( cfBrightness = colorFilter?.brightness ?: 0f, cfContrast = colorFilter?.contrast ?: 0f, + cfInvert = colorFilter?.isInverted ?: false, ), ) } @@ -84,8 +82,8 @@ class MangaDataRepository @Inject constructor( } private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { - return if (cfBrightness != 0f || cfContrast != 0f) { - ReaderColorFilter(cfBrightness, cfContrast) + return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) { + ReaderColorFilter(cfBrightness, cfContrast, cfInvert) } else { null } @@ -96,5 +94,6 @@ class MangaDataRepository @Inject constructor( mode = -1, cfBrightness = 0f, cfContrast = 0f, + cfInvert = false, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt index 36bbbc0c7..8fab1b77c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -22,6 +22,8 @@ interface MangaRepository { val sortOrders: Set + var defaultSortOrder: SortOrder + suspend fun getList(offset: Int, query: String): List suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index 0f3d6d482..3df9988bd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -39,8 +39,8 @@ class RemoteMangaRepository( override val sortOrders: Set get() = parser.sortOrders - var defaultSortOrder: SortOrder? - get() = getConfig().defaultSortOrder ?: sortOrders.firstOrNull() + override var defaultSortOrder: SortOrder + get() = getConfig().defaultSortOrder ?: sortOrders.first() set(value) { getConfig().defaultSortOrder = value } @@ -101,7 +101,7 @@ class RemoteMangaRepository( } fun getAvailableMirrors(): List { - return parser.configKeyDomain.presetValues?.toList().orEmpty() + return parser.configKeyDomain.presetValues.toList() } private fun getConfig() = parser.config as SourceSettings diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt index be0d0dcfc..6af4218ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt @@ -86,7 +86,7 @@ class FaviconFetcher( if (!options.diskCachePolicy.readEnabled) { return null } - val snapshot = diskCache.value?.get(diskCacheKey) ?: return null + val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null return SourceResult( source = snapshot.toImageSource(), mimeType = null, @@ -98,12 +98,12 @@ class FaviconFetcher( if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) { return null } - val editor = diskCache.value?.edit(diskCacheKey) ?: return null + val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null try { fileSystem.write(editor.data) { body.source().readAll(this) } - return editor.commitAndGet() + return editor.commitAndOpenSnapshot() } catch (e: Throwable) { try { editor.abort() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index e3a2e2b5c..80ef36e2a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -277,6 +277,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isReaderSliderEnabled: Boolean get() = prefs.getBoolean(KEY_READER_SLIDER, true) + val isImagesProxyEnabled: Boolean + get() = prefs.getBoolean(KEY_IMAGES_PROXY, false) + val dnsOverHttps: DoHProvider get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) @@ -295,6 +298,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val proxyPort: Int get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0 + val proxyLogin: String? + get() = prefs.getString(KEY_PROXY_LOGIN, null)?.takeUnless { it.isEmpty() } + + val proxyPassword: String? + get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.takeUnless { it.isEmpty() } + var localListOrder: SortOrder get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } @@ -449,6 +458,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_PROXY_TYPE = "proxy_type" const val KEY_PROXY_ADDRESS = "proxy_address" const val KEY_PROXY_PORT = "proxy_port" + const val KEY_PROXY_AUTH = "proxy_auth" + const val KEY_PROXY_LOGIN = "proxy_login" + const val KEY_PROXY_PASSWORD = "proxy_password" + const val KEY_IMAGES_PROXY = "images_proxy" // About const val KEY_APP_UPDATE = "app_update" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseBottomSheet.kt index 9c81104a1..0cf8dfc1e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseBottomSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseBottomSheet.kt @@ -18,6 +18,10 @@ import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.getDisplaySize import com.google.android.material.R as materialR +@Deprecated( + "Use BaseAdaptiveSheet", + replaceWith = ReplaceWith("BaseAdaptiveSheet", "org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet"), +) abstract class BaseBottomSheet : BottomSheetDialogFragment() { var viewBinding: B? = null diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt index 96a240e55..9a7ccbd2e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt @@ -3,57 +3,36 @@ package org.koitharu.kotatsu.core.ui import android.graphics.Color import android.os.Build import android.os.Bundle -import android.view.View import android.view.WindowManager +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.viewbinding.ViewBinding -@Suppress("DEPRECATION") -private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - -@Suppress("DEPRECATION") -private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or - View.SYSTEM_UI_FLAG_FULLSCREEN or - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - abstract class BaseFullscreenActivity : - BaseActivity(), - View.OnSystemUiVisibilityChangeListener { + BaseActivity() { + + private lateinit var insetsControllerCompat: WindowInsetsControllerCompat override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) with(window) { + insetsControllerCompat = WindowInsetsControllerCompat(this, decorView) statusBarColor = Color.TRANSPARENT navigationBarColor = Color.TRANSPARENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES } - decorView.setOnSystemUiVisibilityChangeListener(this@BaseFullscreenActivity) } + insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE showSystemUI() } - @Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith") - @Deprecated("Deprecated in Java") - final override fun onSystemUiVisibilityChange(visibility: Int) { - onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0) - } - - // TODO WindowInsetsControllerCompat works incorrect - @Suppress("DEPRECATION") protected fun hideSystemUI() { - window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN + insetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars()) } - @Suppress("DEPRECATION") protected fun showSystemUI() { - window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN + insetsControllerCompat.show(WindowInsetsCompat.Type.systemBars()) } - - protected open fun onSystemUiVisibilityChanged(isVisible: Boolean) = Unit } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt index f9d41fec8..9aa7cdf93 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt @@ -3,8 +3,10 @@ package org.koitharu.kotatsu.core.ui.list import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) : - RecyclerView.OnScrollListener() { +abstract class BoundsScrollListener( + @JvmField protected val offsetTop: Int, + @JvmField protected val offsetBottom: Int +) : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) @@ -24,9 +26,16 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs if (firstVisibleItemPosition <= offsetTop) { onScrolledToStart(recyclerView) } + onPostScrolled(recyclerView, firstVisibleItemPosition, visibleItemCount) } abstract fun onScrolledToStart(recyclerView: RecyclerView) abstract fun onScrolledToEnd(recyclerView: RecyclerView) + + protected open fun onPostScrolled( + recyclerView: RecyclerView, + firstVisibleItemPosition: Int, + visibleItemCount: Int + ) = Unit } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ScrollListenerInvalidationObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ScrollListenerInvalidationObserver.kt deleted file mode 100644 index 5acc5862c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ScrollListenerInvalidationObserver.kt +++ /dev/null @@ -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) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt index 2b62a6d49..1a1590019 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.AttributeSet import android.view.ViewGroup import androidx.annotation.AttrRes +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R @@ -15,6 +16,12 @@ class FastScrollRecyclerView @JvmOverloads constructor( val fastScroller = FastScroller(context, attrs) + var isFastScrollerEnabled: Boolean = true + set(value) { + field = value + fastScroller.isVisible = value && isVisible + } + init { fastScroller.id = R.id.fast_scroller fastScroller.layoutParams = ViewGroup.LayoutParams( @@ -30,7 +37,7 @@ class FastScrollRecyclerView @JvmOverloads constructor( override fun setVisibility(visibility: Int) { super.setVisibility(visibility) - fastScroller.visibility = visibility + fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE } override fun onAttachedToWindow() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt index d7eca512d..63480c8d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt @@ -56,6 +56,7 @@ class FastScroller @JvmOverloads constructor( private var bubbleHeight = 0 private var handleHeight = 0 private var viewHeight = 0 + private var offset = 0 private var hideScrollbar = true private var showBubble = true private var showBubbleAlways = false @@ -137,6 +138,7 @@ class FastScroller @JvmOverloads constructor( bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL) val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize) binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) + offset = getDimensionPixelOffset(R.styleable.FastScroller_scrollerOffset, offset) } setTrackColor(trackColor) @@ -248,7 +250,7 @@ class FastScroller @JvmOverloads constructor( layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply { height = 0 - setMargins(0, marginTop, 0, marginBottom) + setMargins(offset, marginTop, offset, marginBottom) } } @@ -256,13 +258,13 @@ class FastScroller @JvmOverloads constructor( height = LayoutParams.MATCH_PARENT anchorGravity = GravityCompat.END anchorId = recyclerViewId - setMargins(0, marginTop, 0, marginBottom) + setMargins(offset, marginTop, offset, marginBottom) } is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply { height = LayoutParams.MATCH_PARENT gravity = GravityCompat.END - setMargins(0, marginTop, 0, marginBottom) + setMargins(offset, marginTop, offset, marginBottom) } is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply { @@ -270,7 +272,7 @@ class FastScroller @JvmOverloads constructor( addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) addRule(RelativeLayout.ALIGN_END, recyclerViewId) - setMargins(0, marginTop, 0, marginBottom) + setMargins(offset, marginTop, offset, marginBottom) } else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetBehavior.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetBehavior.kt new file mode 100644 index 000000000..bedd65148 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetBehavior.kt @@ -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() + + 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 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetCallback.kt new file mode 100644 index 000000000..9abaebbc1 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetCallback.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetHeaderBar.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetHeaderBar.kt new file mode 100644 index 000000000..0e062a844 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetHeaderBar.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt new file mode 100644 index 000000000..ba6b5be97 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt @@ -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 : 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(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(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(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) {} + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/BottomSheetHeaderBar.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/BottomSheetHeaderBar.kt index 354206ad4..1fa1dcf37 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/BottomSheetHeaderBar.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/BottomSheetHeaderBar.kt @@ -30,6 +30,7 @@ import com.google.android.material.R as materialR private const val THROTTLE_DELAY = 200L +@Deprecated("") class BottomSheetHeaderBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index 3ad0838ad..c2c526e91 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -6,6 +6,7 @@ import android.content.res.ColorStateList import android.util.AttributeSet import android.view.View.OnClickListener import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat import androidx.core.content.res.getColorStateListOrThrow import androidx.core.view.children @@ -101,6 +102,13 @@ class ChipsView @JvmOverloads constructor( chip.setTextColor(tint ?: defaultChipTextColor) chip.isClickable = onChipClickListener != null || model.isCheckable chip.isCheckable = model.isCheckable + if (model.icon == 0) { + chip.chipIcon = null + chip.isChipIconVisible = false + } else { + chip.setChipIconResource(model.icon) + chip.isChipIconVisible = true + } chip.isChecked = model.isChecked chip.tag = model.data } @@ -134,6 +142,7 @@ class ChipsView @JvmOverloads constructor( class ChipModel( @ColorRes val tint: Int, val title: CharSequence, + @DrawableRes val icon: Int, val isCheckable: Boolean, val isChecked: Boolean, val data: Any? = null, @@ -147,6 +156,7 @@ class ChipsView @JvmOverloads constructor( if (tint != other.tint) return false if (title != other.title) return false + if (icon != other.icon) return false if (isCheckable != other.isCheckable) return false if (isChecked != other.isChecked) return false return data == other.data @@ -155,6 +165,7 @@ class ChipsView @JvmOverloads constructor( override fun hashCode(): Int { var result = tint.hashCode() result = 31 * result + title.hashCode() + result = 31 * result + icon.hashCode() result = 31 * result + isCheckable.hashCode() result = 31 * result + isChecked.hashCode() result = 31 * result + (data?.hashCode() ?: 0) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeRunnable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeRunnable.kt new file mode 100644 index 000000000..fe56ffc3c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeRunnable.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.util + +class CompositeRunnable( + private val children: List, +) : Runnable, Collection by children { + + override fun run() { + for (child in children) { + child.run() + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Event.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Event.kt index e14d2703c..1fd768af1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Event.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Event.kt @@ -8,7 +8,7 @@ class Event( private var isConsumed = false suspend fun consume(collector: FlowCollector) { - if (isConsumed) { + if (!isConsumed) { collector.emit(data) isConsumed = true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt index cf5645f1c..b14e842c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.cancel import kotlin.coroutines.CoroutineContext class RetainedLifecycleCoroutineScope( - private val lifecycle: RetainedLifecycle, + val lifecycle: RetainedLifecycle, ) : CoroutineScope, RetainedLifecycle.OnClearedListener { override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt index 02c0f3ba0..393a2095b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext import android.app.Activity import android.app.ActivityManager +import android.app.ActivityManager.MemoryInfo import android.app.ActivityOptions import android.content.Context import android.content.Context.ACTIVITY_SERVICE @@ -140,6 +141,13 @@ fun Context.isLowRamDevice(): Boolean { return activityManager?.isLowRamDevice ?: false } +val Context.ramAvailable: Long + get() { + val result = MemoryInfo() + activityManager?.getMemoryInfo(result) + return result.availMem + } + fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation( view, 0, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/BottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/BottomSheet.kt new file mode 100644 index 000000000..faba08d87 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/BottomSheet.kt @@ -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 + }, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt index 632030e09..35e2a1e56 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt @@ -8,9 +8,11 @@ import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.lifecycle.RetainedLifecycle import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine +import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -18,6 +20,9 @@ import kotlin.coroutines.resumeWithException val processLifecycleScope: LifecycleCoroutineScope inline get() = ProcessLifecycleOwner.get().lifecycleScope +val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope + inline get() = RetainedLifecycleCoroutineScope(this) + suspend fun Lifecycle.awaitStateAtLeast(state: Lifecycle.State) { if (currentState.isAtLeast(state)) { return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/FlowObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/FlowObserver.kt index bfd2db25f..e13f221e1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/FlowObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/FlowObserver.kt @@ -9,13 +9,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.util.Event fun Flow.observe(owner: LifecycleOwner, collector: FlowCollector) { - if (BuildConfig.DEBUG) { - require((this as? StateFlow)?.value !is Event<*>) - } val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT owner.lifecycleScope.launch(start = start) { owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt index f5a23453e..45463f045 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.util.ext +import okhttp3.HttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response @@ -23,3 +24,17 @@ fun Response.parseJsonOrNull(): JSONObject? { closeQuietly() } } + +val HttpUrl.isHttpOrHttps: Boolean + get() { + val s = scheme.lowercase() + return s == "https" || s == "http" + } + +fun Response.ensureSuccess() = apply { + if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) { + val message = "Invalid response: $code $message at ${request.url}" + closeQuietly() + throw IllegalStateException(message) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt index baf078b7f..046bd94ca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.core.util.ext +import org.koitharu.kotatsu.core.util.CompositeRunnable + @Suppress("UNCHECKED_CAST") fun Class.castOrNull(obj: Any?): T? { if (obj == null || !isInstance(obj)) { @@ -7,3 +9,15 @@ fun Class.castOrNull(obj: Any?): T? { } return obj as T } + +/* CompositeRunnable */ + +operator fun Runnable.plus(other: Runnable): Runnable { + val list = ArrayList(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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt index 102b9bdb1..dae151af2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt @@ -5,6 +5,7 @@ import android.graphics.Color import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.FloatRange +import androidx.annotation.Px import androidx.core.content.res.use import androidx.core.graphics.ColorUtils @@ -22,6 +23,22 @@ fun Context.getThemeColor( it.getColor(0, fallback) } +@Px +fun Context.getThemeDimensionPixelSize( + @AttrRes resId: Int, + @ColorInt fallback: Int = 0, +) = obtainStyledAttributes(intArrayOf(resId)).use { + it.getDimensionPixelSize(0, fallback) +} + +@Px +fun Context.getThemeDimensionPixelOffset( + @AttrRes resId: Int, + @ColorInt fallback: Int = 0, +) = obtainStyledAttributes(intArrayOf(resId)).use { + it.getDimensionPixelOffset(0, fallback) +} + @ColorInt fun Context.getThemeColor( @AttrRes resId: Int, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt index 27108dc26..8fe5f8f47 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt @@ -1,10 +1,12 @@ package org.koitharu.kotatsu.core.util.ext +import android.annotation.SuppressLint import androidx.annotation.MainThread import androidx.fragment.app.Fragment import androidx.fragment.app.createViewModelLazy import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore import androidx.lifecycle.viewmodel.CreationExtras @MainThread @@ -17,3 +19,7 @@ inline fun Fragment.parentFragmentViewModels( extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras }, factoryProducer = factoryProducer ?: { requireParentFragment().defaultViewModelProviderFactory }, ) + +val ViewModelStore.values: Collection + @SuppressLint("RestrictedApi") + get() = this.keys().mapNotNull { get(it) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt index 2e0929ae9..727ff31f3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt @@ -6,18 +6,25 @@ import androidx.activity.OnBackPressedCallback import androidx.appcompat.view.ActionMode import com.google.android.material.bottomsheet.BottomSheetBehavior import org.koitharu.kotatsu.core.ui.util.ActionModeListener -import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar +import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged class ChaptersBottomSheetMediator( - bottomSheet: View, + private val behavior: BottomSheetBehavior<*>, ) : OnBackPressedCallback(false), ActionModeListener, - BottomSheetHeaderBar.OnExpansionChangeListener, OnLayoutChangeListener { - private val behavior = BottomSheetBehavior.from(bottomSheet) private var lockCounter = 0 + init { + behavior.doOnExpansionsChanged { isExpanded -> + isEnabled = isExpanded + if (!isExpanded) { + unlock() + } + } + } + override fun handleOnBackPressed() { behavior.state = BottomSheetBehavior.STATE_COLLAPSED } @@ -30,13 +37,6 @@ class ChaptersBottomSheetMediator( unlock() } - override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) { - isEnabled = isExpanded - if (!isExpanded) { - unlock() - } - } - override fun onLayoutChange( v: View?, left: Int, @@ -61,6 +61,9 @@ class ChaptersBottomSheetMediator( fun unlock() { lockCounter-- + if (lockCounter < 0) { + lockCounter = 0 + } behavior.isDraggable = lockCounter <= 0 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index 07a6f8902..576aa88c9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.details.ui +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -56,6 +57,9 @@ class ChaptersFragment : checkNotNull(selectionController).attachToRecyclerView(this) setHasFixedSize(true) adapter = chaptersAdapter + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + scrollIndicators = if (resources.getBoolean(R.bool.is_tablet)) 0 else View.SCROLL_INDICATOR_TOP + } } viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt index 27a937c0c..257d4bef5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt @@ -1,9 +1,11 @@ package org.koitharu.kotatsu.details.ui +import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.mapToSet fun mapChapters( remoteManga: Manga?, @@ -11,12 +13,14 @@ fun mapChapters( history: MangaHistory?, newCount: Int, branch: String?, + bookmarks: List, ): List { val remoteChapters = remoteManga?.getChapters(branch).orEmpty() val localChapters = localManga?.getChapters(branch).orEmpty() if (remoteChapters.isEmpty() && localChapters.isEmpty()) { return emptyList() } + val bookmarked = bookmarks.mapToSet { it.chapterId } val currentId = history?.chapterId ?: 0L val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount val chaptersSize = maxOf(remoteChapters.size, localChapters.size) @@ -41,6 +45,7 @@ fun mapChapters( isUnread = isUnread, isNew = isUnread && result.size >= newFrom, isDownloaded = local != null, + isBookmarked = chapter.id in bookmarked, ) } if (!localMap.isNullOrEmpty()) { @@ -53,6 +58,7 @@ fun mapChapters( isUnread = isUnread, isNew = false, isDownloaded = remoteManga != null, + isBookmarked = chapter.id in bookmarked, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMenuProvider.kt index c6c3fc64b..e42fa2ca8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMenuProvider.kt @@ -3,14 +3,18 @@ package org.koitharu.kotatsu.details.ui import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import androidx.activity.OnBackPressedCallback import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R +import java.lang.ref.WeakReference class ChaptersMenuProvider( private val viewModel: DetailsViewModel, private val bottomSheetMediator: ChaptersBottomSheetMediator?, -) : MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener { +) : OnBackPressedCallback(false), MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener { + + private var searchItemRef: WeakReference? = null override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_chapters, menu) @@ -20,6 +24,7 @@ class ChaptersMenuProvider( searchView.setOnQueryTextListener(this) searchView.setIconifiedByDefault(false) searchView.queryHint = searchMenuItem.title + searchItemRef = WeakReference(searchMenuItem) } override fun onPrepareMenu(menu: Menu) { @@ -32,15 +37,22 @@ class ChaptersMenuProvider( viewModel.setChaptersReversed(!menuItem.isChecked) true } + else -> false } + override fun handleOnBackPressed() { + searchItemRef?.get()?.collapseActionView() + } + override fun onMenuItemActionExpand(item: MenuItem): Boolean { bottomSheetMediator?.lock() + isEnabled = true return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + isEnabled = false (item.actionView as? SearchView)?.setQuery("", false) viewModel.performChapterSearch(null) bottomSheetMediator?.unlock() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 64abbc9f8..038348767 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -4,12 +4,14 @@ import android.content.Context import android.content.DialogInterface import android.content.Intent import android.os.Bundle +import android.transition.AutoTransition import android.transition.Slide import android.transition.TransitionManager import android.view.Gravity import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams import android.view.animation.AccelerateDecelerateInterpolator import android.widget.Toast import androidx.activity.viewModels @@ -17,7 +19,9 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.graphics.Insets import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding +import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -31,8 +35,11 @@ import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar import org.koitharu.kotatsu.core.util.ViewBadge +import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged +import org.koitharu.kotatsu.core.util.ext.getAnimationDuration +import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled +import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat @@ -49,19 +56,16 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import javax.inject.Inject +import com.google.android.material.R as materialR @AndroidEntryPoint class DetailsActivity : BaseActivity(), View.OnClickListener, - BottomSheetHeaderBar.OnExpansionChangeListener, NoModalBottomSheetOwner, View.OnLongClickListener, PopupMenu.OnMenuItemClickListener { - override val bsHeader: BottomSheetHeaderBar? - get() = viewBinding.headerChapters - @Inject lateinit var shortcutsUpdater: ShortcutsUpdater @@ -82,16 +86,22 @@ class DetailsActivity : viewBinding.buttonDropdown.setOnClickListener(this) viewBadge = ViewBadge(viewBinding.buttonRead, this) - chaptersMenuProvider = if (viewBinding.layoutBottom != null) { - val bsMediator = ChaptersBottomSheetMediator(checkNotNull(viewBinding.layoutBottom)) + if (viewBinding.layoutBottom != null) { + val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom)) + val bsMediator = ChaptersBottomSheetMediator(behavior) actionModeDelegate.addListener(bsMediator) - checkNotNull(viewBinding.headerChapters).addOnExpansionChangeListener(bsMediator) - checkNotNull(viewBinding.headerChapters).addOnLayoutChangeListener(bsMediator) + checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator) onBackPressedDispatcher.addCallback(bsMediator) - ChaptersMenuProvider(viewModel, bsMediator) + chaptersMenuProvider = ChaptersMenuProvider(viewModel, bsMediator) + behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged) + viewBinding.toolbarChapters?.setNavigationOnClickListener { + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } } else { - ChaptersMenuProvider(viewModel, null) + chaptersMenuProvider = ChaptersMenuProvider(viewModel, null) + addMenuProvider(chaptersMenuProvider) } + onBackPressedDispatcher.addCallback(chaptersMenuProvider) viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) @@ -114,11 +124,11 @@ class DetailsActivity : } viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.selectedBranch.observe(this) { - viewBinding.headerChapters?.subtitle = it + viewBinding.toolbarChapters?.subtitle = it viewBinding.textViewSubtitle?.textAndVisible = it } viewModel.isChaptersReversed.observe(this) { - viewBinding.headerChapters?.invalidateMenu() ?: invalidateOptionsMenu() + viewBinding.toolbarChapters?.invalidateMenu() ?: invalidateOptionsMenu() } viewModel.favouriteCategories.observe(this) { invalidateOptionsMenu() @@ -137,7 +147,10 @@ class DetailsActivity : shortcutsUpdater = shortcutsUpdater, ), ) - viewBinding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider) + } + + override fun getBottomSheetCollapsedHeight(): Int { + return viewBinding.layoutBsHeader?.measureHeight() ?: 0 } override fun onClick(v: View) { @@ -184,11 +197,19 @@ class DetailsActivity : } } - override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) { + private fun onChaptersSheetStateChanged(isExpanded: Boolean) { + val toolbar = viewBinding.toolbarChapters ?: return + if (isAnimationsEnabled) { + val transition = AutoTransition() + transition.duration = getAnimationDuration(R.integer.config_shorterAnimTime) + TransitionManager.beginDelayedTransition(toolbar, transition) + } if (isExpanded) { - headerBar.addMenuProvider(chaptersMenuProvider) + toolbar.addMenuProvider(chaptersMenuProvider) + toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material) } else { - headerBar.removeMenuProvider(chaptersMenuProvider) + toolbar.removeMenuProvider(chaptersMenuProvider) + toolbar.navigationIcon = null } viewBinding.buttonRead.isGone = isExpanded } @@ -219,6 +240,12 @@ class DetailsActivity : if (insets.bottom > 0) { window.setNavigationBarTransparentCompat(this, viewBinding.layoutBottom?.elevation ?: 0f, 0.9f) } + viewBinding.cardChapters?.updateLayoutParams { + bottomMargin = insets.bottom + marginEnd + } + viewBinding.dragHandle?.updateLayoutParams { + bottomMargin = insets.bottom + } } private fun onHistoryChanged(info: HistoryInfo) { @@ -237,7 +264,7 @@ class DetailsActivity : info.totalChapters == 0 -> getString(R.string.no_chapters) else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters) } - viewBinding.headerChapters?.title = text + viewBinding.toolbarChapters?.title = text viewBinding.textViewTitle?.text = text } @@ -282,8 +309,6 @@ class DetailsActivity : } } - private fun isTabletLayout() = viewBinding.layoutBottom == null - private fun showBottomSheet(isVisible: Boolean) { val view = viewBinding.layoutBottom ?: return if (view.isVisible == isVisible) return @@ -297,7 +322,7 @@ class DetailsActivity : private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar { val sb = Snackbar.make(viewBinding.containerDetails, text, duration) if (viewBinding.layoutBottom?.isVisible == true) { - sb.anchorView = viewBinding.headerChapters + sb.anchorView = viewBinding.toolbarChapters } return sb } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index dbd6318e3..7c581df2b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -34,7 +34,6 @@ import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.drawableTop import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf @@ -84,6 +83,7 @@ class DetailsFragment : binding.infoLayout.textViewSource.setOnClickListener(this) binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.chipsTags.onChipClickListener = this + TitleScrollCoordinator(binding.textViewTitle).attach(binding.scrollView) viewModel.manga.filterNotNull().observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged) @@ -271,7 +271,7 @@ class DetailsFragment : override fun onWindowInsetsChanged(insets: Insets) { requireViewBinding().root.updatePadding( bottom = ( - (activity as? NoModalBottomSheetOwner)?.bsHeader?.measureHeight() + (activity as? NoModalBottomSheetOwner)?.getBottomSheetCollapsedHeight() ?.plus(insets.bottom)?.plus(resources.resolveDp(16)) ) ?: insets.bottom, @@ -284,6 +284,7 @@ class DetailsFragment : ChipsView.ChipModel( title = tag.title, tint = tagHighlighter.getTint(tag), + icon = 0, data = tag, isCheckable = false, isChecked = false, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt index 0f20ad3e0..4d333c345 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt @@ -18,11 +18,11 @@ import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.details.ui.model.MangaBranch -import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapNotNullToSet -import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet +import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity class DetailsMenuProvider( @@ -63,7 +63,7 @@ class DetailsMenuProvider( R.id.action_favourite -> { viewModel.manga.value?.let { - FavouriteCategoriesBottomSheet.show(activity.supportFragmentManager, it) + FavouriteCategoriesSheet.show(activity.supportFragmentManager, it) } } @@ -105,7 +105,7 @@ class DetailsMenuProvider( R.id.action_scrobbling -> { viewModel.manga.value?.let { - ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it, null) + ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 7579b3301..0c5c13484 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -171,8 +171,9 @@ class DetailsViewModel @Inject constructor( history, selectedBranch, newChaptersCount, - ) { manga, history, branch, news -> - mapChapters(manga?.remote, manga?.local, history, news, branch) + bookmarks, + ) { manga, history, branch, news, bookmarks -> + mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks) }, isChaptersReversed, chaptersQuery, @@ -209,8 +210,8 @@ class DetailsViewModel @Inject constructor( } fun removeBookmark(bookmark: Bookmark) { - launchJob { - bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId) + launchJob(Dispatchers.Default) { + bookmarksRepository.removeBookmark(bookmark) onShowToast.call(R.string.bookmark_removed) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleScrollCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleScrollCoordinator.kt new file mode 100644 index 000000000..364357531 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleScrollCoordinator.kt @@ -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? = 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 + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt index 6ff818ccf..9865b59cb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt @@ -1,10 +1,12 @@ package org.koitharu.kotatsu.details.ui.adapter +import androidx.core.content.ContextCompat import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemChapterBinding @@ -43,7 +45,13 @@ fun chapterListItemAD( binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary)) } } + binding.imageViewBookmarked.isVisible = item.isBookmarked binding.imageViewDownloaded.isVisible = item.isDownloaded - binding.imageViewNew.isVisible = item.isNew + // binding.imageViewNew.isVisible = item.isNew + binding.textViewTitle.drawableStart = if (item.isNew) { + ContextCompat.getDrawable(context, R.drawable.ic_new) + } else { + null + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt index 2a62acb1e..5c3cfd46e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt @@ -31,6 +31,9 @@ class ChapterListItem( val isDownloaded: Boolean get() = hasFlag(FLAG_DOWNLOADED) + val isBookmarked: Boolean + get() = hasFlag(FLAG_BOOKMARKED) + val isNew: Boolean get() = hasFlag(FLAG_NEW) @@ -70,6 +73,7 @@ class ChapterListItem( const val FLAG_UNREAD = 2 const val FLAG_CURRENT = 4 const val FLAG_NEW = 8 + const val FLAG_BOOKMARKED = 16 const val FLAG_DOWNLOADED = 32 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt index 73d70d3df..95c4cae15 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.details.ui.model +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_BOOKMARKED import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW @@ -11,11 +12,13 @@ fun MangaChapter.toListItem( isUnread: Boolean, isNew: Boolean, isDownloaded: Boolean, + isBookmarked: Boolean, ): ChapterListItem { var flags = 0 if (isCurrent) flags = flags or FLAG_CURRENT if (isUnread) flags = flags or FLAG_UNREAD if (isNew) flags = flags or FLAG_NEW + if (isBookmarked) flags = flags or FLAG_BOOKMARKED if (isDownloaded) flags = flags or FLAG_DOWNLOADED return ChapterListItem( chapter = this, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt index 7b73a0277..42644501a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt @@ -19,7 +19,7 @@ fun scrobblingInfoAD( { layoutInflater, parent -> ItemScrobblingInfoBinding.inflate(layoutInflater, parent, false) }, ) { binding.root.setOnClickListener { - ScrobblingInfoBottomSheet.show(fragmentManager, bindingAdapterPosition) + ScrobblingInfoSheet.show(fragmentManager, bindingAdapterPosition) } bind { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt index 045fb420d..7578154e1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt @@ -17,7 +17,7 @@ import androidx.fragment.app.activityViewModels import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseBottomSheet +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.newImageRequest @@ -30,12 +30,12 @@ import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus -import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet +import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import javax.inject.Inject @AndroidEntryPoint -class ScrobblingInfoBottomSheet : - BaseBottomSheet(), +class ScrobblingInfoSheet : + BaseAdaptiveSheet(), AdapterView.OnItemSelectedListener, RatingBar.OnRatingBarChangeListener, View.OnClickListener, @@ -74,7 +74,7 @@ class ScrobblingInfoBottomSheet : menu = PopupMenu(binding.root.context, binding.buttonMenu).apply { inflate(R.menu.opt_scrobbling) - setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet) + setOnMenuItemClickListener(this@ScrobblingInfoSheet) } } @@ -152,7 +152,7 @@ class ScrobblingInfoBottomSheet : R.id.action_edit -> { val manga = viewModel.manga.value ?: return false val scrobblerService = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.scrobbler - ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga, scrobblerService) + ScrobblingSelectorSheet.show(parentFragmentManager, manga, scrobblerService) dismiss() } } @@ -164,7 +164,7 @@ class ScrobblingInfoBottomSheet : private const val TAG = "ScrobblingInfoBottomSheet" private const val ARG_INDEX = "index" - fun show(fm: FragmentManager, index: Int) = ScrobblingInfoBottomSheet().withArgs(1) { + fun show(fm: FragmentManager, index: Int) = ScrobblingInfoSheet().withArgs(1) { putInt(ARG_INDEX, index) }.show(fm, TAG) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 69b6c9d5c..bf58733d5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -80,7 +80,7 @@ class ExploreFragment : viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) - viewModel.onShowSuggestionsTip.observe(viewLifecycleOwner) { + viewModel.onShowSuggestionsTip.observeEvent(viewLifecycleOwner) { showSuggestionsTip() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt index e9d4603f3..9449ee21a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt @@ -18,9 +18,7 @@ sealed interface ExploreItem : ListModel { other as Buttons - if (isSuggestionsEnabled != other.isSuggestionsEnabled) return false - - return true + return isSuggestionsEnabled == other.isSuggestionsEnabled } override fun hashCode(): Int { @@ -40,9 +38,7 @@ sealed interface ExploreItem : ListModel { other as Header if (titleResId != other.titleResId) return false - if (isButtonVisible != other.isButtonVisible) return false - - return true + return isButtonVisible == other.isButtonVisible } override fun hashCode(): Int { @@ -64,9 +60,7 @@ sealed interface ExploreItem : ListModel { other as Source if (source != other.source) return false - if (isGrid != other.isGrid) return false - - return true + return isGrid == other.isGrid } override fun hashCode(): Int { @@ -76,7 +70,6 @@ sealed interface ExploreItem : ListModel { } } - @Deprecated("") class EmptyHint( @DrawableRes icon: Int, @StringRes textPrimary: Int, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 590b66436..066b031ce 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -6,6 +6,7 @@ import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.flow.Flow import org.intellij.lang.annotations.Language import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.parsers.model.SortOrder @Dao @@ -71,12 +72,12 @@ abstract class FavouritesDao { ) abstract suspend fun findAllManga(categoryId: Int): List - suspend fun findCovers(categoryId: Long, order: SortOrder): List { + suspend fun findCovers(categoryId: Long, order: SortOrder): List { val orderBy = getOrderBy(order) @Language("RoomSql") val query = SimpleSQLiteQuery( - "SELECT m.cover_url FROM favourites AS f LEFT JOIN manga AS m ON f.manga_id = m.manga_id " + + "SELECT m.cover_url AS url, m.source AS source FROM favourites AS f LEFT JOIN manga AS m ON f.manga_id = m.manga_id " + "WHERE f.category_id = ? AND deleted_at = 0 ORDER BY $orderBy", arrayOf(categoryId), ) @@ -145,7 +146,7 @@ abstract class FavouritesDao { protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow> @RawQuery - protected abstract suspend fun findCoversImpl(query: SupportSQLiteQuery): List + protected abstract suspend fun findCoversImpl(query: SupportSQLiteQuery): List @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId") protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index e99653964..a21923ed5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -19,6 +19,7 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toManga import org.koitharu.kotatsu.favourites.data.toMangaList +import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels @@ -66,11 +67,11 @@ class FavouritesRepository @Inject constructor( }.distinctUntilChanged() } - fun observeCategoriesWithCovers(): Flow>> { + fun observeCategoriesWithCovers(): Flow>> { return db.favouriteCategoriesDao.observeAll() .map { db.withTransaction { - val res = LinkedHashMap>() + val res = LinkedHashMap>() for (entity in it) { val cat = entity.toFavouriteCategory() res[cat] = db.favouritesDao.findCovers( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/model/Cover.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/model/Cover.kt new file mode 100644 index 000000000..293a318a9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/model/Cover.kt @@ -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)" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt index 57d73ad5e..9ab263483 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt @@ -15,11 +15,12 @@ import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.animatorDurationScale import org.koitharu.kotatsu.core.util.ext.disposeImageRequest import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.newImageRequest +import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemCategoryBinding import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.list.ui.model.ListModel @@ -53,10 +54,7 @@ fun categoryAD( ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)) val fallback = ColorDrawable(Color.TRANSPARENT) val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3) - val crossFadeDuration = ( - context.resources.getInteger(R.integer.config_defaultAnimTime) * - context.animatorDurationScale - ).toInt() + val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt() itemView.setOnClickListener(eventListener) itemView.setOnLongClickListener(eventListener) itemView.setOnTouchListener(eventListener) @@ -77,9 +75,11 @@ fun categoryAD( ) } repeat(coverViews.size) { i -> - coverViews[i].newImageRequest(lifecycleOwner, item.covers.getOrNull(i))?.run { + val cover = item.covers.getOrNull(i) + coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run { placeholder(R.drawable.ic_placeholder) fallback(fallback) + source(cover?.mangaSource) crossfade(crossFadeDuration * (i + 1)) error(R.drawable.ic_error_placeholder) allowRgb565(true) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt index abc5e6314..f09c775d1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt @@ -1,11 +1,12 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.ui.model.ListModel class CategoryListModel( val mangaCount: Int, - val covers: List, + val covers: List, val category: FavouriteCategory, val isReorderMode: Boolean, ) : ListModel { @@ -21,9 +22,7 @@ class CategoryListModel( if (covers != other.covers) return false if (category.id != other.category.id) return false if (category.title != other.category.title) return false - if (category.order != other.category.order) return false - - return true + return category.order == other.category.order } override fun hashCode(): Int { @@ -35,4 +34,4 @@ class CategoryListModel( result = 31 * result + category.order.hashCode() return result } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesSheet.kt similarity index 62% rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesSheet.kt index 983246319..7d5be8b7b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesSheet.kt @@ -2,34 +2,29 @@ package org.koitharu.kotatsu.favourites.ui.categories.select import android.os.Bundle import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.appcompat.widget.Toolbar import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.ui.BaseBottomSheet import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding -import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga @AndroidEntryPoint -class FavouriteCategoriesBottomSheet : - BaseBottomSheet(), - OnListItemClickListener, - View.OnClickListener, - Toolbar.OnMenuItemClickListener { +class FavouriteCategoriesSheet : + BaseAdaptiveSheet(), + OnListItemClickListener { private val viewModel: MangaCategoriesViewModel by viewModels() @@ -40,13 +35,13 @@ class FavouriteCategoriesBottomSheet : container: ViewGroup?, ) = SheetFavoriteCategoriesBinding.inflate(inflater, container, false) - override fun onViewBindingCreated(binding: SheetFavoriteCategoriesBinding, savedInstanceState: Bundle?) { + override fun onViewBindingCreated( + binding: SheetFavoriteCategoriesBinding, + savedInstanceState: Bundle?, + ) { super.onViewBindingCreated(binding, savedInstanceState) adapter = MangaCategoriesAdapter(this) binding.recyclerViewCategories.adapter = adapter - binding.buttonDone.setOnClickListener(this) - binding.headerBar.toolbar.setOnMenuItemClickListener(this) - viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) } @@ -56,25 +51,11 @@ class FavouriteCategoriesBottomSheet : super.onDestroyView() } - override fun onClick(v: View) { - when (v.id) { - R.id.button_done -> dismiss() - } - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext())) - else -> return false - } - return true - } - override fun onItemClick(item: MangaCategoryItem, view: View) { viewModel.setChecked(item.id, !item.isChecked) } - private fun onContentChanged(categories: List) { + private fun onContentChanged(categories: List) { adapter?.items = categories } @@ -89,11 +70,17 @@ class FavouriteCategoriesBottomSheet : fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga)) - fun show(fm: FragmentManager, manga: Collection) = FavouriteCategoriesBottomSheet().withArgs(1) { - putParcelableArrayList( - KEY_MANGA_LIST, - manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) }, - ) - }.show(fm, TAG) + fun show(fm: FragmentManager, manga: Collection) = + FavouriteCategoriesSheet().withArgs(1) { + putParcelableArrayList( + KEY_MANGA_LIST, + manga.mapTo(ArrayList(manga.size)) { + ParcelableManga( + it, + withChapters = false, + ) + }, + ) + }.show(fm, TAG) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt index 69180f1aa..ef5707f75 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus @@ -12,8 +13,10 @@ import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet.Companion.KEY_MANGA_LIST +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet.Companion.KEY_MANGA_LIST +import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem +import org.koitharu.kotatsu.list.ui.model.ListModel import javax.inject.Inject @HiltViewModel @@ -23,17 +26,21 @@ class MangaCategoriesViewModel @Inject constructor( ) : BaseViewModel() { private val manga = requireNotNull(savedStateHandle.get>(KEY_MANGA_LIST)).map { it.manga } + private val header = CategoriesHeaderItem() - val content = combine( + val content: StateFlow> = combine( favouritesRepository.observeCategories(), observeCategoriesIds(), ) { all, checked -> - all.map { - MangaCategoryItem( - id = it.id, - name = it.title, - isChecked = it.id in checked, - ) + buildList(all.size + 1) { + add(header) + all.mapTo(this) { + MangaCategoryItem( + id = it.id, + name = it.title, + isChecked = it.id in checked, + ) + } } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt new file mode 100644 index 000000000..cc878a1ac --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt @@ -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( + { 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) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt index 351037748..e0578cf56 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt @@ -3,32 +3,39 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.adapter import androidx.recyclerview.widget.DiffUtil import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem +import org.koitharu.kotatsu.list.ui.model.ListModel class MangaCategoriesAdapter( - clickListener: OnListItemClickListener -) : AsyncListDifferDelegationAdapter(DiffCallback()) { + clickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { delegatesManager.addDelegate(mangaCategoryAD(clickListener)) + .addDelegate(categoriesHeaderAD()) } - private class DiffCallback : DiffUtil.ItemCallback() { + private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: MangaCategoryItem, - newItem: MangaCategoryItem - ): Boolean = oldItem.id == newItem.id + oldItem: ListModel, + newItem: ListModel, + ): Boolean = when { + oldItem is MangaCategoryItem && newItem is MangaCategoryItem -> oldItem.id == newItem.id + oldItem is CategoriesHeaderItem && newItem is CategoriesHeaderItem -> oldItem == newItem + else -> false + } override fun areContentsTheSame( - oldItem: MangaCategoryItem, - newItem: MangaCategoryItem + oldItem: ListModel, + newItem: ListModel, ): Boolean = oldItem == newItem override fun getChangePayload( - oldItem: MangaCategoryItem, - newItem: MangaCategoryItem + oldItem: ListModel, + newItem: ListModel, ): Any? { - if (oldItem.isChecked != newItem.isChecked) { + if (oldItem is MangaCategoryItem && newItem is MangaCategoryItem && oldItem.isChecked != newItem.isChecked) { return newItem.isChecked } return super.getChangePayload(oldItem, newItem) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt index 3badc482b..05f4b7505 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt @@ -4,10 +4,11 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem +import org.koitharu.kotatsu.list.ui.model.ListModel fun mangaCategoryAD( - clickListener: OnListItemClickListener -) = adapterDelegateViewBinding( + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( { inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) }, ) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt new file mode 100644 index 000000000..cde85e768 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt index 95447a2af..721176233 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt @@ -1,7 +1,9 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.model +import org.koitharu.kotatsu.list.ui.model.ListModel + data class MangaCategoryItem( val id: Long, val name: String, - val isChecked: Boolean -) \ No newline at end of file + val isChecked: Boolean, +) : ListModel diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt new file mode 100644 index 000000000..74d7459fc --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt @@ -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, +) : AsyncListDifferDelegationAdapter(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 + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt new file mode 100644 index 000000000..c2125070c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt @@ -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( + { 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( + { 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(R.layout.item_sources_empty) { + + bind { + (itemView as TextView).setText(item.textResId) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt similarity index 63% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 0ff57d7a3..f4442dbd8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -1,7 +1,9 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui import androidx.annotation.WorkerThread -import kotlinx.coroutines.CoroutineScope +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -17,21 +19,40 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.util.ext.lifecycleScope +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel +import org.koitharu.kotatsu.filter.ui.model.FilterItem +import org.koitharu.kotatsu.filter.ui.model.FilterState +import org.koitharu.kotatsu.list.ui.model.ListHeader +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter +import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment +import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.text.Collator +import java.util.LinkedList import java.util.Locale import java.util.TreeSet +import javax.inject.Inject -class FilterCoordinator( - private val repository: RemoteMangaRepository, +@ViewModelScoped +class FilterCoordinator @Inject constructor( + savedStateHandle: SavedStateHandle, + mangaRepositoryFactory: MangaRepository.Factory, dataRepository: MangaDataRepository, - private val coroutineScope: CoroutineScope, -) : OnFilterChangedListener { + private val searchRepository: MangaSearchRepository, + lifecycle: ViewModelLifecycle, +) : FilterOwner { + private val coroutineScope = lifecycle.lifecycleScope + private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) private val currentState = MutableStateFlow(FilterState(repository.defaultSortOrder, emptySet())) private var searchQuery = MutableStateFlow("") private val localTags = SuspendLazy { @@ -39,13 +60,23 @@ class FilterCoordinator( } private var availableTagsDeferred = loadTagsAsync() - val items: StateFlow> = getItemsFlow() - .stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(FilterItem.Loading)) + override val filterItems: StateFlow> = getItemsFlow() + .stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) + + override val header: StateFlow = getHeaderFlow().stateIn( + scope = coroutineScope + Dispatchers.Default, + started = SharingStarted.Lazily, + initialValue = FilterHeaderModel(emptyList(), repository.defaultSortOrder, false), + ) init { observeState() } + override fun applyFilter(tags: Set) { + setTags(tags) + } + override fun onSortItemClick(item: FilterItem.Sort) { currentState.update { oldValue -> FilterState(item.order, oldValue.tags) @@ -91,6 +122,14 @@ class FilterCoordinator( searchQuery.value = query } + private fun getHeaderFlow() = combine( + observeState(), + observeAvailableTags(), + ) { state, available -> + val chips = createChipsList(state, available.orEmpty()) + FilterHeaderModel(chips, state.sortOrder, state.tags.isNotEmpty()) + } + private fun getItemsFlow() = combine( getTagsAsFlow(), currentState, @@ -110,24 +149,66 @@ class FilterCoordinator( } } + private suspend fun createChipsList( + filterState: FilterState, + availableTags: Set, + ): List { + 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() + for (tag in tags) { + val model = ChipsView.ChipModel( + tint = 0, + title = tag.title, + icon = 0, + isCheckable = true, + isChecked = selectedTags.remove(tag), + data = tag, + ) + if (model.isChecked) { + result.addFirst(model) + } else { + result.addLast(model) + } + } + for (tag in selectedTags) { + val model = ChipsView.ChipModel( + tint = 0, + title = tag.title, + icon = 0, + isCheckable = true, + isChecked = true, + data = tag, + ) + result.addFirst(model) + } + return result + } + @WorkerThread private fun buildFilterList( allTags: TagsWrapper, state: FilterState, query: String, - ): List { + ): List { val sortOrders = repository.sortOrders.sortedBy { it.ordinal } val tags = mergeTags(state.tags, allTags.tags).toList() - val list = ArrayList(tags.size + sortOrders.size + 3) + val list = ArrayList(tags.size + sortOrders.size + 3) if (query.isEmpty()) { if (sortOrders.isNotEmpty()) { - list.add(FilterItem.Header(R.string.sort_order, 0)) + list.add(ListHeader(R.string.sort_order, 0, null)) sortOrders.mapTo(list) { FilterItem.Sort(it, isSelected = it == state.sortOrder) } } if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) { - list.add(FilterItem.Header(R.string.genres, state.tags.size)) + list.add(ListHeader(R.string.genres, 0, null)) tags.mapTo(list) { FilterItem.Tag(it, isChecked = it in state.tags) } @@ -135,7 +216,7 @@ class FilterCoordinator( if (allTags.isError) { list.add(FilterItem.Error(R.string.filter_load_error)) } else if (allTags.isLoading) { - list.add(FilterItem.Loading) + list.add(LoadingFooter()) } } else { tags.mapNotNullTo(list) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterDiffCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterDiffCallback.kt new file mode 100644 index 000000000..d3319c431 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterDiffCallback.kt @@ -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() { + + 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) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt new file mode 100644 index 000000000..4f50e9df5 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -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(), 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, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt new file mode 100644 index 000000000..b302e7692 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt @@ -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> + + val header: StateFlow + + fun applyFilter(tags: Set) + + 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 } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt new file mode 100644 index 000000000..c9734d9c7 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt @@ -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(), + AdaptiveSheetCallback, + AsyncListDiffer.ListListener { + + 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, currentList: MutableList) { + 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) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt similarity index 56% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt index a28596c9f..bf3f15f93 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt @@ -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 { fun onSortItemClick(item: FilterItem.Sort) fun onTagItemClick(item: FilterItem.Tag) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader2.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt similarity index 70% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader2.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt index dab335ee2..cf9dcd834 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader2.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt @@ -1,19 +1,23 @@ -package org.koitharu.kotatsu.list.ui.model +package org.koitharu.kotatsu.filter.ui.model import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.SortOrder -class ListHeader2( +class FilterHeaderModel( val chips: Collection, val sortOrder: SortOrder?, val hasSelectedTags: Boolean, ) : ListModel { + val textSummary: String + get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString() + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false - other as ListHeader2 + other as FilterHeaderModel if (chips != other.chips) return false return sortOrder == other.sortOrder diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt new file mode 100644 index 000000000..a2ad2cddb --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterState.kt similarity index 84% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterState.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterState.kt index d9e387b89..b4ab41c7a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterState.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui.model import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder @@ -15,9 +15,7 @@ class FilterState( other as FilterState if (sortOrder != other.sortOrder) return false - if (tags != other.tags) return false - - return true + return tags == other.tags } override fun hashCode(): Int { @@ -25,4 +23,4 @@ class FilterState( result = 31 * result + tags.hashCode() return result } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProviderImpl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProviderImpl.kt new file mode 100644 index 000000000..c972feb7e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProviderImpl.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/ListModeBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/ListModeBottomSheet.kt index 2b7141385..75b18132d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/ListModeBottomSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/ListModeBottomSheet.kt @@ -11,7 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.ui.BaseBottomSheet +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.databinding.DialogListModeBinding @@ -19,7 +19,7 @@ import javax.inject.Inject @AndroidEntryPoint class ListModeBottomSheet : - BaseBottomSheet(), + BaseAdaptiveSheet(), Slider.OnChangeListener, MaterialButtonToggleGroup.OnButtonCheckedListener { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 146767467..aaf868a15 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -45,7 +45,7 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver -import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID import org.koitharu.kotatsu.list.ui.adapter.MangaListListener @@ -296,7 +296,7 @@ abstract class MangaListFragment : } R.id.action_favourite -> { - FavouriteCategoriesBottomSheet.show(childFragmentManager, selectedItems) + FavouriteCategoriesSheet.show(childFragmentManager, selectedItems) mode.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt index 4a0f8f2fe..518983bd4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt @@ -1,24 +1,20 @@ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.databinding.ItemHeader2Binding -import org.koitharu.kotatsu.list.ui.model.ListHeader2 +import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding +import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.MangaTag +@Deprecated("") fun listHeader2AD( listener: MangaListListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemHeader2Binding.inflate(layoutInflater, parent, false) }, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> FragmentFilterHeaderBinding.inflate(layoutInflater, parent, false) }, ) { var ignoreChecking = false - binding.textViewFilter.setOnClickListener { - listener.onFilterClick(it) - } binding.chipsTags.setOnCheckedStateChangeListener { _, _ -> if (!ignoreChecking) { listener.onUpdateFilter(binding.chipsTags.getCheckedData(MangaTag::class.java)) @@ -36,6 +32,5 @@ fun listHeader2AD( ignoreChecking = true binding.chipsTags.setChips(item.chips) // TODO use recyclerview ignoreChecking = false - binding.textViewFilter.setTextAndVisible(item.sortOrder?.titleRes ?: 0) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt index 51a58e2e0..8076dc68b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding +import org.koitharu.kotatsu.databinding.ItemHeaderSingleBinding import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel @@ -22,3 +23,12 @@ fun listHeaderAD( binding.buttonMore.setTextAndVisible(item.buttonTextRes) } } + +fun listSimpleHeaderAD() = adapterDelegateViewBinding( + { inflater, parent -> ItemHeaderSingleBinding.inflate(inflater, parent, false) }, +) { + + bind { + binding.textViewTitle.text = item.getText(context) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index f471cc99a..09a4809d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -5,8 +5,8 @@ import androidx.recyclerview.widget.DiffUtil import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.core.ui.model.DateTimeAgo +import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListHeader2 import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.MangaGridModel @@ -82,7 +82,7 @@ open class MangaListAdapter( } } - is ListHeader2 -> Unit + is FilterHeaderModel -> Unit else -> super.getChangePayload(oldItem, newItem) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt deleted file mode 100644 index 6b1fe569f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt +++ /dev/null @@ -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, -) : AsyncListDifferDelegationAdapter( - FilterDiffCallback(), - filterSortDelegate(listener), - filterTagDelegate(listener), - filterHeaderDelegate(), - filterLoadingDelegate(), - filterErrorDelegate(), -) { - - init { - differ.addListListener(listListener) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt deleted file mode 100644 index 5dcb3a21a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt +++ /dev/null @@ -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( - { 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( - { 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( - { 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(R.layout.item_loading_footer) {} - -fun filterErrorDelegate() = adapterDelegate(R.layout.item_sources_empty) { - - bind { - (itemView as TextView).setText(item.textResId) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt deleted file mode 100644 index a00341a55..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt +++ /dev/null @@ -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(), - MenuItem.OnActionExpandListener, - SearchView.OnQueryTextListener, - AsyncListDiffer.ListListener { - - private val viewModel by parentFragmentViewModels() - 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, currentList: MutableList) { - 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) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt deleted file mode 100644 index 4549c46cf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.koitharu.kotatsu.list.ui.filter - -import androidx.recyclerview.widget.DiffUtil - -class FilterDiffCallback : DiffUtil.ItemCallback() { - - 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) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt deleted file mode 100644 index bbef939cb..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt +++ /dev/null @@ -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 -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt index ea24b01fa..172fe3aeb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt @@ -43,6 +43,7 @@ fun Manga.toListDetailedModel( ChipsView.ChipModel( tint = tagHighlighter?.getTint(it) ?: 0, title = it.title, + icon = 0, isCheckable = false, isChecked = false, data = it, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index 7ac0dc282..1bd6dceb8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.CompositeMutex import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.local.data.input.LocalMangaInput @@ -28,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.io.File +import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton @@ -37,11 +39,20 @@ private const val MAX_PARALLELISM = 4 class LocalMangaRepository @Inject constructor( private val storageManager: LocalStorageManager, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, + private val settings: AppSettings, ) : MangaRepository { override val source = MangaSource.LOCAL private val locks = CompositeMutex() + override val sortOrders: Set = 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 { if (offset > 0) { return emptyList() @@ -137,8 +148,6 @@ class LocalMangaRepository @Inject constructor( }.firstOrNull()?.getManga() } - override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING) - override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getTags() = emptySet() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt index 755ed222b..336fe6511 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -54,9 +54,10 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) { val file = File(cacheDir.get().parentFile, url.longHashCode().toString()) try { - file.sink(append = false).buffer().use { + val bytes = file.sink(append = false).buffer().use { it.writeAllCancellable(source) } + check(bytes != 0L) { "No data has been written" } lruCache.get().put(url, file) } finally { file.delete() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt index e62943d07..c2cac2c60 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt @@ -130,7 +130,6 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) { } val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null return ZipFile(cbz).use { zip -> - val filter = ImageFileFilter() zip.entries().asSequence() .firstOrNull { x -> !x.isDirectory && filter.accept(x) } ?.let { entry -> zipUri(cbz, entry.name) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 3f8fd2bb2..1cf6c2a79 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -5,7 +5,6 @@ import android.view.Menu import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode -import androidx.appcompat.widget.PopupMenu import androidx.core.net.toFile import androidx.core.net.toUri import androidx.fragment.app.viewModels @@ -16,11 +15,14 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding +import org.koitharu.kotatsu.filter.ui.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment -class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { +class LocalListFragment : MangaListFragment() { override val viewModel by viewModels() @@ -35,11 +37,7 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener } override fun onFilterClick(view: View?) { - super.onFilterClick(view) - val menu = PopupMenu(requireContext(), view ?: requireViewBinding().recyclerView) - menu.inflate(R.menu.popup_order) - menu.setOnMenuItemClickListener(this) - menu.show() + FilterSheetFragment.show(childFragmentManager) } override fun onScrolledToEnd() = Unit @@ -67,17 +65,6 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener } } - override fun onMenuItemClick(item: MenuItem): Boolean { - val order = when (item.itemId) { - R.id.action_order_new -> SortOrder.NEWEST - R.id.action_order_abs -> SortOrder.ALPHABETICAL - R.id.action_order_rating -> SortOrder.RATING - else -> return false - } - viewModel.setSortOrder(order) - return true - } - private fun showDeletionConfirm(ids: Set, mode: ActionMode) { MaterialAlertDialogBuilder(context ?: return) .setTitle(R.string.delete_manga) @@ -96,6 +83,8 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener companion object { - fun newInstance() = LocalListFragment() + fun newInstance() = LocalListFragment().withArgs(1) { + putSerializable(RemoteListFragment.ARG_SOURCE, MangaSource.LOCAL) // required by FilterCoordinator + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 80836cdd7..65f7a393e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -1,123 +1,57 @@ package org.koitharu.kotatsu.local.ui -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.history.data.PROGRESS_NONE +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListHeader2 -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toUi -import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import java.util.LinkedList +import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import javax.inject.Inject @HiltViewModel class LocalListViewModel @Inject constructor( - private val repository: LocalMangaRepository, - private val historyRepository: HistoryRepository, - private val trackingRepository: TrackingRepository, - private val settings: AppSettings, - private val tagHighlighter: MangaTagHighlighter, - @LocalStorageChanges private val localStorageChanges: SharedFlow, - private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + savedStateHandle: SavedStateHandle, + mangaRepositoryFactory: MangaRepository.Factory, + filter: FilterCoordinator, + tagHighlighter: MangaTagHighlighter, + settings: AppSettings, downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider { + listExtraProvider: ListExtraProvider, + private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + @LocalStorageChanges private val localStorageChanges: SharedFlow, +) : RemoteListViewModel( + savedStateHandle, + mangaRepositoryFactory, + filter, + tagHighlighter, + settings, + listExtraProvider, + downloadScheduler, +) { val onMangaRemoved = MutableEventFlow() - val sortOrder = MutableStateFlow(settings.localListOrder) - private val listError = MutableStateFlow(null) - private val mangaList = MutableStateFlow?>(null) - private val selectedTags = MutableStateFlow>(emptySet()) - private var refreshJob: Job? = null - - override val content = combine( - mangaList, - listMode, - sortOrder, - selectedTags, - listError, - ) { list, mode, order, tags, error -> - when { - error != null -> listOf(error.toErrorState(canRetry = true)) - list == null -> listOf(LoadingState) - list.isEmpty() -> listOf( - EmptyState( - icon = R.drawable.ic_empty_local, - textPrimary = R.string.text_local_holder_primary, - textSecondary = R.string.text_local_holder_secondary, - actionStringRes = R.string._import, - ), - ) - - else -> buildList(list.size + 1) { - add(createHeader(list, tags, order)) - list.toUi(this, mode, this@LocalListViewModel, tagHighlighter) - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { - onRefresh() launchJob(Dispatchers.Default) { localStorageChanges - .collectLatest { - if (refreshJob?.isActive != true) { - doRefresh() - } + .collect { + loadList(filter.snapshot(), append = false).join() } } } - override fun onUpdateFilter(tags: Set) { - 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) { launchLoadingJob(Dispatchers.Default) { deleteLocalMangaUseCase(ids) @@ -125,60 +59,12 @@ class LocalListViewModel @Inject constructor( } } - private suspend fun doRefresh() { - try { - listError.value = null - mangaList.value = repository.getList(0, selectedTags.value, sortOrder.value) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - listError.value = e - } - } - - private fun createHeader(mangaList: List, selectedTags: Set, order: SortOrder): ListHeader2 { - val tags = HashMap() - 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() - for ((tag, _) in topTags) { - val model = ChipsView.ChipModel( - tint = 0, - title = tag.title, - isCheckable = true, - isChecked = tag in selectedTags, - data = tag, - ) - if (model.isChecked) { - chips.addFirst(model) - } else { - chips.addLast(model) - } - } - return ListHeader2( - chips = chips, - sortOrder = order, - hasSelectedTags = selectedTags.isNotEmpty(), + override fun createEmptyState(canResetFilter: Boolean): EmptyState { + return EmptyState( + icon = R.drawable.ic_empty_local, + textPrimary = R.string.text_local_holder_primary, + textSecondary = R.string.text_local_holder_secondary, + actionStringRes = R.string._import, ) } - - override suspend fun getCounter(mangaId: Long): Int { - return if (settings.isTrackerEnabled) { - trackingRepository.getNewChaptersCount(mangaId) - } else { - 0 - } - } - - override suspend fun getProgress(mangaId: Long): Float { - return if (settings.isReadingIndicatorsEnabled) { - historyRepository.getProgress(mangaId) - } else { - PROGRESS_NONE - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 024cb6994..731aa12de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -129,7 +129,7 @@ class MainActivity : navigationDelegate = MainNavigationDelegate(checkNotNull(bottomNav ?: viewBinding.navRail), supportFragmentManager) navigationDelegate.addOnFragmentChangedListener(this) - navigationDelegate.onCreate(savedInstanceState) + navigationDelegate.onCreate() onBackPressedDispatcher.addCallback(ExitCallback(this, viewBinding.container)) onBackPressedDispatcher.addCallback(navigationDelegate) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt index fe23e3cc2..adfec639a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.main.ui -import android.os.Bundle import android.view.MenuItem import androidx.activity.OnBackPressedCallback import androidx.annotation.IdRes @@ -57,7 +56,7 @@ class MainNavigationDelegate( navBar.selectedItemId = R.id.nav_shelf } - fun onCreate(savedInstanceState: Bundle?) { + fun onCreate() { primaryFragment?.let { onFragmentChanged(it, fromUser = false) val itemId = getItemId(it) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/NoModalBottomSheetOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/NoModalBottomSheetOwner.kt index e03f90ad2..3920088d4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/NoModalBottomSheetOwner.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/NoModalBottomSheetOwner.kt @@ -1,8 +1,6 @@ package org.koitharu.kotatsu.main.ui.owners -import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar - interface NoModalBottomSheetOwner { - val bsHeader: BottomSheetHeaderBar? + fun getBottomSheetCollapsedHeight(): Int } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt index fe4ad571f..08a3e345b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okhttp3.OkHttpClient import org.koitharu.kotatsu.core.model.findChapter +import org.koitharu.kotatsu.core.network.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository @@ -14,7 +15,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.util.ext.printStackTraceDebug @@ -28,6 +28,7 @@ class DetectReaderModeUseCase @Inject constructor( private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, @MangaHttpClient private val okHttpClient: OkHttpClient, + private val imageProxyInterceptor: ImageProxyInterceptor, ) { suspend operator fun invoke(manga: Manga, state: ReaderState?): ReaderMode { @@ -70,7 +71,7 @@ class DetectReaderModeUseCase @Inject constructor( } } else { val request = PageLoader.createPageRequest(page, url) - okHttpClient.newCall(request).await().use { + imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { runInterruptible(Dispatchers.IO) { getBitmapSize(it.body?.byteStream()) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 49701829a..d37d80a30 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.reader.domain +import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri @@ -8,6 +9,7 @@ import androidx.collection.LongSparseArray import androidx.collection.set import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.lifecycle.RetainedLifecycle +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ActivityRetainedScoped import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -22,18 +24,21 @@ import okhttp3.OkHttpClient import okhttp3.Request import okio.source import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.network.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope +import org.koitharu.kotatsu.core.util.ext.ensureSuccess +import org.koitharu.kotatsu.core.util.ext.ramAvailable import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.io.File @@ -46,11 +51,13 @@ import kotlin.coroutines.CoroutineContext @ActivityRetainedScoped class PageLoader @Inject constructor( + @ApplicationContext private val context: Context, lifecycle: ActivityRetainedLifecycle, @MangaHttpClient private val okHttp: OkHttpClient, private val cache: PagesCache, private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, + private val imageProxyInterceptor: ImageProxyInterceptor, ) : RetainedLifecycle.OnClearedListener { init { @@ -74,7 +81,7 @@ class PageLoader @Inject constructor( } fun isPrefetchApplicable(): Boolean { - return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled + return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled && !isLowRam() } @AnyThread @@ -115,6 +122,9 @@ class PageLoader @Inject constructor( suspend fun convertInPlace(file: File) { convertLock.withLock { + if (context.ramAvailable < file.length() * 2) { + return@withLock + } runInterruptible(Dispatchers.Default) { val image = BitmapFactory.decodeFile(file.absolutePath) try { @@ -191,10 +201,7 @@ class PageLoader @Inject constructor( } } else { val request = createPageRequest(page, pageUrl) - okHttp.newCall(request).await().use { response -> - check(response.isSuccessful) { - "Invalid response: ${response.code} ${response.message} at $pageUrl" - } + imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> val body = checkNotNull(response.body) { "Null response" } @@ -205,6 +212,10 @@ class PageLoader @Inject constructor( } } + private fun isLowRam(): Boolean { + return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) + } + private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { @@ -217,6 +228,7 @@ class PageLoader @Inject constructor( private const val PROGRESS_UNDEFINED = -1f private const val PREFETCH_LIMIT_DEFAULT = 10 + private const val PREFETCH_MIN_RAM_MB = 80L fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder() .url(pageUrl) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt index 1511a29fc..8852ff2da 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt @@ -6,19 +6,52 @@ import android.graphics.ColorMatrixColorFilter class ReaderColorFilter( val brightness: Float, val contrast: Float, + val isInverted: Boolean, ) { val isEmpty: Boolean - get() = brightness == 0f && contrast == 0f + get() = !isInverted && brightness == 0f && contrast == 0f fun toColorFilter(): ColorMatrixColorFilter { val cm = ColorMatrix() - val scale = brightness + 1f - cm.setScale(scale, scale, scale, 1f) + if (isInverted) { + cm.inverted() + } + cm.setBrightness(brightness) cm.setContrast(contrast) return ColorMatrixColorFilter(cm) } + private fun ColorMatrix.setBrightness(brightness: Float) { + val scale = brightness + 1f + val matrix = ColorMatrix() + matrix.setScale(scale, scale, scale, 1f) + postConcat(matrix) + } + + private fun ColorMatrix.setContrast(contrast: Float) { + val scale = contrast + 1f + val translate = (-.5f * scale + .5f) * 255f + val array = floatArrayOf( + scale, 0f, 0f, 0f, translate, + 0f, scale, 0f, 0f, translate, + 0f, 0f, scale, 0f, translate, + 0f, 0f, 0f, 1f, 0f, + ) + val matrix = ColorMatrix(array) + postConcat(matrix) + } + + private fun ColorMatrix.inverted() { + val matrix = floatArrayOf( + -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, + 0.0f, -1.0f, 0.0f, 1.0f, 1.0f, + 0.0f, 0.0f, -1.0f, 1.0f, 1.0f, + 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, + ) + set(matrix) + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -27,26 +60,13 @@ class ReaderColorFilter( if (brightness != other.brightness) return false if (contrast != other.contrast) return false - - return true + return isInverted == other.isInverted } override fun hashCode(): Int { var result = brightness.hashCode() result = 31 * result + contrast.hashCode() + result = 31 * result + isInverted.hashCode() return result } - - private fun ColorMatrix.setContrast(contrast: Float) { - val scale = contrast + 1f - val translate = (-.5f * scale + .5f) * 255f - val array = floatArrayOf( - scale, 0f, 0f, 0f, translate, - 0f, scale, 0f, 0f, translate, - 0f, 0f, scale, 0f, translate, - 0f, 0f, 0f, 1f, 0f, - ) - val matrix = ColorMatrix(array) - postConcat(matrix) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ChaptersSheet.kt similarity index 92% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ChaptersSheet.kt index 568a79e1e..4dcfce2e8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ChaptersSheet.kt @@ -9,8 +9,8 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BaseBottomSheet import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.ext.getParcelableCompat import org.koitharu.kotatsu.core.util.ext.withArgs @@ -23,7 +23,7 @@ import javax.inject.Inject import kotlin.math.roundToInt @AndroidEntryPoint -class ChaptersBottomSheet : BaseBottomSheet(), OnListItemClickListener { +class ChaptersSheet : BaseAdaptiveSheet(), OnListItemClickListener { @Inject lateinit var settings: AppSettings @@ -47,6 +47,7 @@ class ChaptersBottomSheet : BaseBottomSheet(), OnListItemC isUnread = index > currentPosition, isNew = false, isDownloaded = false, + isBookmarked = false, ) } binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter -> @@ -83,7 +84,7 @@ class ChaptersBottomSheet : BaseBottomSheet(), OnListItemC fm: FragmentManager, chapters: List, currentId: Long, - ) = ChaptersBottomSheet().withArgs(2) { + ) = ChaptersSheet().withArgs(2) { putParcelable(ARG_CHAPTERS, ParcelableMangaChapters(chapters)) putLong(ARG_CURRENT_ID, currentId) }.show(fm, TAG) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index f3f655ede..afc354213 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -14,6 +14,7 @@ import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View +import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager import androidx.activity.viewModels import androidx.core.graphics.Insets @@ -21,6 +22,7 @@ import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar @@ -50,7 +52,7 @@ import org.koitharu.kotatsu.core.util.ext.zipWithPrevious import org.koitharu.kotatsu.databinding.ActivityReaderBinding import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.reader.ui.config.ReaderConfigBottomSheet +import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener @@ -62,10 +64,10 @@ import javax.inject.Inject @AndroidEntryPoint class ReaderActivity : BaseFullscreenActivity(), - ChaptersBottomSheet.OnChapterChangeListener, + ChaptersSheet.OnChapterChangeListener, GridTouchHelper.OnGridTouchListener, OnPageSelectListener, - ReaderConfigBottomSheet.Callback, + ReaderConfigSheet.Callback, ReaderControlDelegate.OnInteractionListener, OnApplyWindowInsetsListener, IdlingDetector.Callback { @@ -177,7 +179,7 @@ class ReaderActivity : } R.id.action_chapters -> { - ChaptersBottomSheet.show( + ChaptersSheet.show( supportFragmentManager, viewModel.manga?.chapters.orEmpty(), viewModel.getCurrentState()?.chapterId ?: 0L, @@ -205,7 +207,7 @@ class ReaderActivity : R.id.action_options -> { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) val currentMode = readerManager.currentMode ?: return false - ReaderConfigBottomSheet.show(supportFragmentManager, currentMode) + ReaderConfigSheet.show(supportFragmentManager, currentMode) } else -> return super.onOptionsItemSelected(item) @@ -333,11 +335,11 @@ class ReaderActivity : right = systemBars.right, left = systemBars.left, ) - viewBinding.appbarBottom?.updatePadding( - bottom = systemBars.bottom, - right = systemBars.right, - left = systemBars.left, - ) + viewBinding.appbarBottom?.updateLayoutParams { + bottomMargin = systemBars.bottom + topMargin + rightMargin = systemBars.right + topMargin + leftMargin = systemBars.left + topMargin + } return WindowInsetsCompat.Builder(insets) .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) .build() @@ -373,19 +375,15 @@ class ReaderActivity : } private fun onUiStateChanged(pair: Pair) { - val (uiState: ReaderUiState?, previous: ReaderUiState?) = pair - title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_) + val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair + title = uiState?.resolveTitle(this) ?: getString(R.string.loading_) viewBinding.infoBar.update(uiState) if (uiState == null) { supportActionBar?.subtitle = null viewBinding.slider.isVisible = false return } - supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) { - getString(R.string.chapter_d_of_d, uiState.chapterNumber, uiState.chaptersTotal) - } else { - null - } + supportActionBar?.subtitle = uiState.chapterName if (previous?.chapterName != null && uiState.chapterName != previous.chapterName) { if (!uiState.chapterName.isNullOrEmpty()) { viewBinding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index f32dbbbb6..17b52edd0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -41,6 +41,7 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase import org.koitharu.kotatsu.details.domain.model.DoubleManga @@ -140,7 +141,9 @@ class ReaderViewModel @Inject constructor( flowOf(false) } else { bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) - .map { it != null } + .map { + it != null && it.chapterId == state.chapterId && it.page == state.page + } } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) @@ -285,7 +288,7 @@ class ReaderViewModel @Inject constructor( chapterId = state.chapterId, page = state.page, scroll = state.scroll, - imageUrl = page.preview ?: pageLoader.getPageUrl(page), + imageUrl = page.preview.ifNullOrEmpty { pageLoader.getPageUrl(page) }, createdAt = Date(), percent = computePercent(state.chapterId, state.page), ) @@ -301,8 +304,8 @@ class ReaderViewModel @Inject constructor( bookmarkJob = launchJob { loadingJob?.join() val manga = checkNotNull(mangaData.value?.any) - val page = checkNotNull(getCurrentPage()) { "Page not found" } - bookmarksRepository.removeBookmark(manga.id, page.id) + val state = checkNotNull(getCurrentState()) + bookmarksRepository.removeBookmark(manga.id, state.chapterId, state.page) onShowToast.call(R.string.bookmark_removed) } } @@ -366,6 +369,7 @@ class ReaderViewModel @Inject constructor( val chapter = state?.chapterId?.let { chaptersLoader.peekChapter(it) } val newState = ReaderUiState( mangaName = manga?.any?.title, + branch = chapter?.branch, chapterName = chapter?.name, chapterNumber = chapter?.number ?: 0, chaptersTotal = manga?.any?.getChapters(chapter?.branch)?.size ?: 0, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt index d77cdc62c..7bcdc6c63 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt @@ -6,6 +6,7 @@ import android.content.res.Resources import android.os.Bundle import android.view.View import android.view.ViewGroup +import android.widget.CompoundButton import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.updateLayoutParams @@ -26,6 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.indicator import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.databinding.ActivityColorFilterBinding import org.koitharu.kotatsu.parsers.model.Manga @@ -39,7 +41,7 @@ import com.google.android.material.R as materialR class ColorFilterConfigActivity : BaseActivity(), Slider.OnChangeListener, - View.OnClickListener { + View.OnClickListener, CompoundButton.OnCheckedChangeListener { @Inject lateinit var coil: ImageLoader @@ -58,6 +60,7 @@ class ColorFilterConfigActivity : val formatter = PercentLabelFormatter(resources) viewBinding.sliderContrast.setLabelFormatter(formatter) viewBinding.sliderBrightness.setLabelFormatter(formatter) + viewBinding.switchInvert.setOnCheckedChangeListener(this) viewBinding.buttonDone.setOnClickListener(this) viewBinding.buttonReset.setOnClickListener(this) @@ -80,6 +83,10 @@ class ColorFilterConfigActivity : } } + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + viewModel.setInversion(isChecked) + } + override fun onClick(v: View) { when (v.id) { R.id.button_done -> viewModel.save() @@ -103,13 +110,14 @@ class ColorFilterConfigActivity : private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) { viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f) viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f) + viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false) viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() } private fun onPreviewChanged(preview: MangaPage?) { if (preview == null) return ImageRequest.Builder(this@ColorFilterConfigActivity) - .data(preview.url) + .data(preview) .scale(Scale.FILL) .decodeRegion() .tag(preview.source) @@ -117,7 +125,7 @@ class ColorFilterConfigActivity : .error(R.drawable.ic_error_placeholder) .size(ViewSizeResolver(viewBinding.imageViewBefore)) .allowRgb565(false) - .target(ShadowViewTarget(viewBinding.imageViewBefore, viewBinding.imageViewAfter)) + .target(DoubleViewTarget(viewBinding.imageViewBefore, viewBinding.imageViewAfter)) .enqueueWith(coil) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt index d4568ed2d..74e7d1044 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt @@ -55,12 +55,32 @@ class ColorFilterConfigViewModel @Inject constructor( fun setBrightness(brightness: Float) { val cf = colorFilter.value - colorFilter.value = ReaderColorFilter(brightness, cf?.contrast ?: 0f).takeUnless { it.isEmpty } + colorFilter.value = ReaderColorFilter( + brightness = brightness, + contrast = cf?.contrast ?: 0f, + isInverted = cf?.isInverted ?: false, + ).takeUnless { it.isEmpty } } fun setContrast(contrast: Float) { val cf = colorFilter.value - colorFilter.value = ReaderColorFilter(cf?.brightness ?: 0f, contrast).takeUnless { it.isEmpty } + colorFilter.value = ReaderColorFilter( + brightness = cf?.brightness ?: 0f, + contrast = contrast, + isInverted = cf?.isInverted ?: false, + ).takeUnless { it.isEmpty } + } + + fun setInversion(invert: Boolean) { + val cf = colorFilter.value + if (invert == cf?.isInverted) { + return + } + colorFilter.value = ReaderColorFilter( + brightness = cf?.brightness ?: 0f, + contrast = cf?.contrast ?: 0f, + isInverted = invert, + ).takeUnless { it.isEmpty } } fun reset() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ShadowViewTarget.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt similarity index 58% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ShadowViewTarget.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt index fcd05cbd3..28bf4f38c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ShadowViewTarget.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt @@ -4,15 +4,15 @@ import android.graphics.drawable.Drawable import android.widget.ImageView import coil.target.ImageViewTarget -class ShadowViewTarget( - view: ImageView, - private val shadowView: ImageView, -) : ImageViewTarget(view) { +class DoubleViewTarget( + primaryView: ImageView, + private val secondaryView: ImageView, +) : ImageViewTarget(primaryView) { override var drawable: Drawable? get() = super.drawable set(value) { super.drawable = value - shadowView.setImageDrawable(value?.constantState?.newDrawable()) + secondaryView.setImageDrawable(value?.constantState?.newDrawable()) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt similarity index 90% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigBottomSheet.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt index 4fa2db3f8..a29154d6f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigBottomSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt @@ -23,7 +23,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.observeAsStateFlow -import org.koitharu.kotatsu.core.ui.BaseBottomSheet +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ScreenOrientationHelper import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope @@ -36,12 +36,13 @@ import org.koitharu.kotatsu.settings.SettingsActivity import javax.inject.Inject @AndroidEntryPoint -class ReaderConfigBottomSheet : - BaseBottomSheet(), +class ReaderConfigSheet : + BaseAdaptiveSheet(), ActivityResultCallback, View.OnClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener, - Slider.OnChangeListener, CompoundButton.OnCheckedChangeListener { + Slider.OnChangeListener, + CompoundButton.OnCheckedChangeListener { private val viewModel by activityViewModels() private val savePageRequest = registerForActivityResult(PageSaveContract(), this) @@ -58,11 +59,17 @@ class ReaderConfigBottomSheet : ?: ReaderMode.STANDARD } - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetReaderConfigBinding { + override fun onCreateViewBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ): SheetReaderConfigBinding { return SheetReaderConfigBinding.inflate(inflater, container, false) } - override fun onViewBindingCreated(binding: SheetReaderConfigBinding, savedInstanceState: Bundle?) { + override fun onViewBindingCreated( + binding: SheetReaderConfigBinding, + savedInstanceState: Bundle?, + ) { super.onViewBindingCreated(binding, savedInstanceState) observeScreenOrientation() binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD @@ -126,7 +133,11 @@ class ReaderConfigBottomSheet : } } - override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { + override fun onButtonChecked( + group: MaterialButtonToggleGroup?, + checkedId: Int, + isChecked: Boolean, + ) { if (!isChecked) { return } @@ -179,7 +190,7 @@ class ReaderConfigBottomSheet : private const val TAG = "ReaderConfigBottomSheet" private const val ARG_MODE = "mode" - fun show(fm: FragmentManager, mode: ReaderMode) = ReaderConfigBottomSheet().withArgs(1) { + fun show(fm: FragmentManager, mode: ReaderMode) = ReaderConfigSheet().withArgs(1) { putInt(ARG_MODE, mode.id) }.show(fm, TAG) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt index 03a6c49e9..c1b5f5f4b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt @@ -26,7 +26,7 @@ class ReaderSettings( get() = settings.zoomMode val colorFilter: ReaderColorFilter? - get() = colorFilterFlow.value + get() = colorFilterFlow.value?.takeUnless { it.isEmpty } val isPagesNumbersEnabled: Boolean get() = settings.isPagesNumbersEnabled diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt index e97192ecf..650c4439a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt @@ -1,7 +1,11 @@ package org.koitharu.kotatsu.reader.ui.pager +import android.content.Context +import org.koitharu.kotatsu.R + data class ReaderUiState( val mangaName: String?, + val branch: String?, val chapterName: String?, val chapterNumber: Int, val chaptersTotal: Int, @@ -14,4 +18,10 @@ data class ReaderUiState( fun isSliderAvailable(): Boolean { return isSliderEnabled && totalPages > 1 && currentPage < totalPages } + + fun resolveTitle(context: Context): String? = when { + mangaName == null -> null + branch == null -> mangaName + else -> context.getString(R.string.manga_branch_title_template, mangaName, branch) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt index 3246d0a99..393f6398d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt @@ -9,21 +9,24 @@ import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.request.Options +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okhttp3.OkHttpClient import okio.Path.Companion.toOkioPath import okio.buffer import okio.source +import org.koitharu.kotatsu.core.network.ImageProxyInterceptor +import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.reader.domain.PageLoader import java.util.zip.ZipFile +import javax.inject.Inject class MangaPageFetcher( private val context: Context, @@ -32,6 +35,7 @@ class MangaPageFetcher( private val options: Options, private val page: MangaPage, private val mangaRepositoryFactory: MangaRepository.Factory, + private val imageProxyInterceptor: ImageProxyInterceptor, ) : Fetcher { override suspend fun fetch(): FetchResult { @@ -66,7 +70,7 @@ class MangaPageFetcher( ) } else { val request = PageLoader.createPageRequest(page, pageUrl) - okHttpClient.newCall(request).await().use { response -> + imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response -> check(response.isSuccessful) { "Invalid response: ${response.code} ${response.message} at $pageUrl" } @@ -89,11 +93,12 @@ class MangaPageFetcher( } } - class Factory( - private val context: Context, - private val okHttpClient: OkHttpClient, + class Factory @Inject constructor( + @ApplicationContext private val context: Context, + @MangaHttpClient private val okHttpClient: OkHttpClient, private val pagesCache: PagesCache, private val mangaRepositoryFactory: MangaRepository.Factory, + private val imageProxyInterceptor: ImageProxyInterceptor, ) : Fetcher.Factory { override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader): Fetcher { @@ -104,6 +109,7 @@ class MangaPageFetcher( page = data, context = context, mangaRepositoryFactory = mangaRepositoryFactory, + imageProxyInterceptor = imageProxyInterceptor, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt index 39255497c..a5aa7ac9c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt @@ -14,31 +14,33 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BaseBottomSheet import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.ScrollListenerInvalidationObserver import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration -import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar +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.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.plus import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetPagesBinding import org.koitharu.kotatsu.list.ui.MangaListSpanResolver +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter -import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.TargetScrollObserver -import org.koitharu.kotatsu.util.LoggingAdapterDataObserver import javax.inject.Inject +import kotlin.math.roundToInt @AndroidEntryPoint class PagesThumbnailsSheet : - BaseBottomSheet(), - OnListItemClickListener, - BottomSheetHeaderBar.OnExpansionChangeListener { + BaseAdaptiveSheet(), + AdaptiveSheetCallback, + OnListItemClickListener { private val viewModel by viewModels() @@ -63,12 +65,8 @@ class PagesThumbnailsSheet : override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) + addSheetCallback(this) spanResolver = MangaListSpanResolver(binding.root.resources) - with(binding.headerBar) { - title = viewModel.title - subtitle = null - addOnExpansionChangeListener(this@PagesThumbnailsSheet) - } thumbnailsAdapter = PageThumbnailAdapter( coil = coil, lifecycleOwner = viewLifecycleOwner, @@ -83,18 +81,9 @@ class PagesThumbnailsSheet : spanResolver?.setGridSize(settings.gridSize / 100f, this) addOnScrollListener(ScrollListener().also { scrollListener = it }) (layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup - thumbnailsAdapter?.registerAdapterDataObserver( - ScrollListenerInvalidationObserver(this, checkNotNull(scrollListener)), - ) - thumbnailsAdapter?.registerAdapterDataObserver(TargetScrollObserver(this)) - thumbnailsAdapter?.registerAdapterDataObserver(LoggingAdapterDataObserver("THUMB")) - } - viewModel.thumbnails.observe(viewLifecycleOwner) { - thumbnailsAdapter?.setItems(it, listCommitCallback) - } - viewModel.branch.observe(viewLifecycleOwner) { - onExpansionStateChanged(binding.headerBar, binding.headerBar.isExpanded) } + viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) + viewModel.branch.observe(viewLifecycleOwner, ::updateTitle) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) } @@ -118,11 +107,38 @@ class PagesThumbnailsSheet : dismiss() } - override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) { - if (isExpanded) { - headerBar.subtitle = viewModel.branch.value + override fun onStateChanged(sheet: View, newState: Int) { + viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED + } + + private fun updateTitle(branch: String?) { + val mangaName = viewModel.manga.title + viewBinding?.headerBar?.title = if (branch != null) { + getString(R.string.manga_branch_title_template, mangaName, branch) + } else { + mangaName + } + } + + private fun onThumbnailsChanged(list: List) { + val adapter = thumbnailsAdapter ?: return + if (adapter.itemCount == 0) { + var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent } + if (position > 0) { + val spanCount = spanResolver?.spanCount ?: 0 + val offset = if (position > spanCount + 1) { + (resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt() + } else { + position = 0 + 0 + } + val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset) + adapter.setItems(list, listCommitCallback + scrollCallback) + } else { + adapter.setItems(list, listCommitCallback) + } } else { - headerBar.subtitle = null + adapter.setItems(list, listCommitCallback) } } @@ -135,6 +151,13 @@ class PagesThumbnailsSheet : override fun onScrolledToEnd(recyclerView: RecyclerView) { viewModel.loadNextChapter() } + + override fun onPostScrolled(recyclerView: RecyclerView, firstVisibleItemPosition: Int, visibleItemCount: Int) { + super.onPostScrolled(recyclerView, firstVisibleItemPosition, visibleItemCount) + if (firstVisibleItemPosition > offsetTop) { + viewModel.allowLoadAbove() + } + } } private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt index 30e7ccb75..ef0463ba0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt @@ -39,10 +39,10 @@ class PagesThumbnailsViewModel @Inject constructor( private var loadingJob: Job? = null private var loadingPrevJob: Job? = null private var loadingNextJob: Job? = null + private var isLoadAboveAllowed = false val thumbnails = MutableStateFlow>(emptyList()) val branch = MutableStateFlow(null) - val title = manga.title init { loadingJob = launchJob(Dispatchers.Default) { @@ -52,8 +52,17 @@ class PagesThumbnailsViewModel @Inject constructor( } } + fun allowLoadAbove() { + if (!isLoadAboveAllowed) { + loadingJob = launchJob(Dispatchers.Default) { + isLoadAboveAllowed = true + updateList() + } + } + } + fun loadPrevChapter() { - if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) { + if (!isLoadAboveAllowed || loadingJob?.isActive == true || loadingPrevJob?.isActive == true) { return } loadingPrevJob = loadPrevNextChapter(isNext = false) @@ -75,7 +84,7 @@ class PagesThumbnailsViewModel @Inject constructor( private suspend fun updateList() { val snapshot = chaptersLoader.snapshot() val mangaChapters = mangaDetails.tryGet().getOrNull()?.chapters.orEmpty() - val hasPrevChapter = snapshot.firstOrNull()?.chapterId != mangaChapters.firstOrNull()?.id + val hasPrevChapter = isLoadAboveAllowed && snapshot.firstOrNull()?.chapterId != mangaChapters.firstOrNull()?.id val hasNextChapter = snapshot.lastOrNull()?.chapterId != mangaChapters.lastOrNull()?.id val pages = buildList(snapshot.size + chaptersLoader.size + 2) { if (hasPrevChapter) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt index e1169638a..1b197fdb6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt @@ -1,10 +1,12 @@ package org.koitharu.kotatsu.reader.ui.thumbnails.adapter +import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.DiffUtil import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.model.ListHeader @@ -16,7 +18,7 @@ class PageThumbnailAdapter( coil: ImageLoader, lifecycleOwner: LifecycleOwner, clickListener: OnListItemClickListener, -) : AsyncListDifferDelegationAdapter(DiffCallback()) { +) : AsyncListDifferDelegationAdapter(DiffCallback()), FastScroller.SectionIndexer { init { delegatesManager.addDelegate(ITEM_TYPE_THUMBNAIL, pageThumbnailAD(coil, lifecycleOwner, clickListener)) @@ -24,6 +26,17 @@ class PageThumbnailAdapter( .addDelegate(ITEM_LOADING, loadingFooterAD()) } + 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 ListHeader) { + return item.getText(context) + } + } + return null + } + private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index c441f6974..fed94e8c9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding +import org.koitharu.kotatsu.filter.ui.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.search.ui.SearchActivity @@ -25,7 +25,7 @@ import org.koitharu.kotatsu.settings.SettingsActivity @AndroidEntryPoint class RemoteListFragment : MangaListFragment() { - public override val viewModel by viewModels() + override val viewModel by viewModels() override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) @@ -42,7 +42,7 @@ class RemoteListFragment : MangaListFragment() { } override fun onFilterClick(view: View?) { - FilterBottomSheet.show(childFragmentManager) + FilterSheetFragment.show(childFragmentManager) } override fun onEmptyActionClick() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index ee0890f4f..130a34233 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -18,22 +17,18 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaTagHighlighter -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.download.ui.worker.DownloadWorker +import org.koitharu.kotatsu.filter.ui.FilterCoordinator +import org.koitharu.kotatsu.filter.ui.FilterOwner +import org.koitharu.kotatsu.filter.ui.model.FilterState +import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.filter.FilterCoordinator -import org.koitharu.kotatsu.list.ui.filter.FilterItem -import org.koitharu.kotatsu.list.ui.filter.FilterState -import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListHeader2 import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorFooter @@ -42,50 +37,42 @@ import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.util.ext.printStackTraceDebug -import java.util.LinkedList import javax.inject.Inject private const val FILTER_MIN_INTERVAL = 250L @HiltViewModel -class RemoteListViewModel @Inject constructor( +open class RemoteListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, - private val searchRepository: MangaSearchRepository, - settings: AppSettings, - dataRepository: MangaDataRepository, + private val filter: FilterCoordinator, private val tagHighlighter: MangaTagHighlighter, + settings: AppSettings, + listExtraProvider: ListExtraProvider, downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler), OnFilterChangedListener { +) : MangaListViewModel(settings, downloadScheduler), FilterOwner by filter { val source = savedStateHandle.require(RemoteListFragment.ARG_SOURCE) - private val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository - private val filter = FilterCoordinator(repository, dataRepository, viewModelScope) + private val repository = mangaRepositoryFactory.create(source) private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) private var loadingJob: Job? = null - val filterItems: StateFlow> - get() = filter.items - override val content = combine( mangaList, listMode, - createHeaderFlow(), listError, hasNextPage, - ) { list, mode, header, error, hasNext -> + ) { list, mode, error, hasNext -> buildList(list?.size?.plus(2) ?: 2) { - add(header) when { list.isNullOrEmpty() && error != null -> add(error.toErrorState(canRetry = true)) list == null -> add(LoadingState) - list.isEmpty() -> add(createEmptyState(header.hasSelectedTags)) + list.isEmpty() -> add(createEmptyState(header.value.hasSelectedTags)) else -> { - list.toUi(this, mode, tagHighlighter) + list.toUi(this, mode, listExtraProvider, tagHighlighter) when { error != null -> add(error.toErrorFooter()) hasNext -> add(LoadingFooter()) @@ -116,37 +103,23 @@ class RemoteListViewModel @Inject constructor( loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty()) } - override fun onSortItemClick(item: FilterItem.Sort) { - filter.onSortItemClick(item) - } - - override fun onTagItemClick(item: FilterItem.Tag) { - filter.onTagItemClick(item) - } - fun loadNextPage() { if (hasNextPage.value && listError.value == null) { loadList(filter.snapshot(), append = true) } } - fun filterSearch(query: String) = filter.performSearch(query) - fun resetFilter() = filter.reset() override fun onUpdateFilter(tags: Set) { applyFilter(tags) } - fun applyFilter(tags: Set) { - filter.setTags(tags) - } - - private fun loadList(filterState: FilterState, append: Boolean) { - if (loadingJob?.isActive == true) { - return + protected fun loadList(filterState: FilterState, append: Boolean): Job { + loadingJob?.let { + if (it.isActive) return it } - loadingJob = launchLoadingJob(Dispatchers.Default) { + return launchLoadingJob(Dispatchers.Default) { try { listError.value = null val list = repository.getList( @@ -169,61 +142,13 @@ class RemoteListViewModel @Inject constructor( errorEvent.call(e) } } - } + }.also { loadingJob = it } } - private fun createEmptyState(canResetFilter: Boolean) = EmptyState( + protected open fun createEmptyState(canResetFilter: Boolean) = EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, textSecondary = 0, actionStringRes = if (canResetFilter) R.string.reset_filter else 0, ) - - private fun createHeaderFlow() = combine( - filter.observeState(), - filter.observeAvailableTags(), - ) { state, available -> - val chips = createChipsList(state, available.orEmpty()) - ListHeader2(chips, state.sortOrder, state.tags.isNotEmpty()) - } - - private suspend fun createChipsList( - filterState: FilterState, - availableTags: Set, - ): List { - 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() - for (tag in tags) { - val model = ChipsView.ChipModel( - tint = 0, - title = tag.title, - 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, - isCheckable = true, - isChecked = true, - data = tag, - ) - result.addFirst(model) - } - return result - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt index f1a4fce2b..0afdf0dcb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt @@ -6,7 +6,6 @@ import android.os.Bundle import android.view.View import androidx.activity.viewModels import androidx.core.graphics.Insets -import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -29,6 +28,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter import javax.inject.Inject +import com.google.android.material.R as materialR @AndroidEntryPoint class ScrobblerConfigActivity : BaseActivity(), @@ -67,7 +67,7 @@ class ScrobblerConfigActivity : BaseActivity(), viewModel.user.observe(this, this::onUserChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - viewModel.onLoggedOut.observe(this) { + viewModel.onLoggedOut.observeEvent(this) { finishAfterTransition() } @@ -115,11 +115,11 @@ class ScrobblerConfigActivity : BaseActivity(), private fun onUserChanged(user: ScrobblerUser?) { if (user == null) { viewBinding.imageViewAvatar.disposeImageRequest() - viewBinding.imageViewAvatar.isVisible = false + viewBinding.imageViewAvatar.setImageResource(materialR.drawable.abc_ic_menu_overflow_material) return } - viewBinding.imageViewAvatar.isVisible = true viewBinding.imageViewAvatar.newImageRequest(this, user.avatar) + ?.placeholder(R.drawable.bg_badge_empty) ?.enqueueWith(coil) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt similarity index 89% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorBottomSheet.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt index 65e79be5e..ba4a89330 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorBottomSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt @@ -7,7 +7,6 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.widget.SearchView -import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import coil.ImageLoader @@ -16,9 +15,9 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.ui.BaseBottomSheet import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.getDisplayMessage @@ -35,8 +34,8 @@ import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerSelec import javax.inject.Inject @AndroidEntryPoint -class ScrobblingSelectorBottomSheet : - BaseBottomSheet(), +class ScrobblingSelectorSheet : + BaseAdaptiveSheet(), OnListItemClickListener, PaginationScrollListener.Callback, View.OnClickListener, @@ -58,12 +57,13 @@ class ScrobblingSelectorBottomSheet : override fun onViewBindingCreated(binding: SheetScrobblingSelectorBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) + disableFitToContents() val listAdapter = ScrobblerSelectorAdapter(viewLifecycleOwner, coil, this, this) val decoration = ScrobblerMangaSelectionDecoration(binding.root.context) with(binding.recyclerView) { adapter = listAdapter addItemDecoration(decoration) - addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorBottomSheet)) + addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorSheet)) } binding.buttonDone.setOnClickListener(this) initOptionsMenu() @@ -84,9 +84,7 @@ class ScrobblingSelectorBottomSheet : tab.select() } } - viewModel.searchQuery.observe(viewLifecycleOwner) { - binding.headerBar.subtitle = it - } + viewModel.searchQuery.observe(viewLifecycleOwner, ::onSearchQueryChanged) } override fun onDestroyView() { @@ -135,7 +133,7 @@ class ScrobblingSelectorBottomSheet : return false } viewModel.search(query) - requireViewBinding().headerBar.menu.findItem(R.id.action_search)?.collapseActionView() + requireViewBinding().toolbar.menu.findItem(R.id.action_search)?.collapseActionView() return true } @@ -155,10 +153,14 @@ class ScrobblingSelectorBottomSheet : } private fun openSearch() { - val menuItem = requireViewBinding().headerBar.menu.findItem(R.id.action_search) ?: return + val menuItem = requireViewBinding().toolbar.menu.findItem(R.id.action_search) ?: return menuItem.expandActionView() } + private fun onSearchQueryChanged(query: String?) { + + } + private fun onError(e: Throwable) { Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() if (viewModel.isEmpty) { @@ -167,8 +169,8 @@ class ScrobblingSelectorBottomSheet : } private fun initOptionsMenu() { - requireViewBinding().headerBar.inflateMenu(R.menu.opt_shiki_selector) - val searchMenuItem = requireViewBinding().headerBar.menu.findItem(R.id.action_search) + requireViewBinding().toolbar.inflateMenu(R.menu.opt_shiki_selector) + val searchMenuItem = requireViewBinding().toolbar.menu.findItem(R.id.action_search) searchMenuItem.setOnActionExpandListener(this) val searchView = searchMenuItem.actionView as SearchView searchView.setOnQueryTextListener(this) @@ -182,10 +184,6 @@ class ScrobblingSelectorBottomSheet : private fun initTabs() { val entries = viewModel.availableScrobblers val tabs = requireViewBinding().tabs - if (entries.size <= 1) { - tabs.isVisible = false - return - } val selectedId = arguments?.getInt(ARG_SCROBBLER, -1) ?: -1 tabs.removeAllTabs() tabs.clearOnTabSelectedListeners() @@ -200,7 +198,6 @@ class ScrobblingSelectorBottomSheet : tab.select() } } - tabs.isVisible = true } companion object { @@ -209,7 +206,7 @@ class ScrobblingSelectorBottomSheet : private const val ARG_SCROBBLER = "scrobbler" fun show(fm: FragmentManager, manga: Manga, scrobblerService: ScrobblerService?) = - ScrobblingSelectorBottomSheet().withArgs(2) { + ScrobblingSelectorSheet().withArgs(2) { putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false)) if (scrobblerService != null) { putInt(ARG_SCROBBLER, scrobblerService.id) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index 1dd93c2a2..bec248475 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -3,17 +3,30 @@ package org.koitharu.kotatsu.search.ui import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.commit import com.google.android.material.appbar.AppBarLayout import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat -import org.koitharu.kotatsu.databinding.ActivityContainerBinding +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.setTextAndVisible +import org.koitharu.kotatsu.databinding.ActivityMangaListBinding +import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment +import org.koitharu.kotatsu.filter.ui.FilterOwner +import org.koitharu.kotatsu.filter.ui.FilterSheetFragment +import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource @@ -22,15 +35,15 @@ import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment @AndroidEntryPoint class MangaListActivity : - BaseActivity(), - AppBarOwner { + BaseActivity(), + AppBarOwner, View.OnClickListener { override val appBar: AppBarLayout get() = viewBinding.appbar override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) + setContentView(ActivityMangaListBinding.inflate(layoutInflater)) val tags = intent.getParcelableExtraCompat(EXTRA_TAGS)?.tags supportActionBar?.setDisplayHomeAsUpEnabled(true) val source = intent.getSerializableExtraCompat(EXTRA_SOURCE) ?: tags?.firstOrNull()?.source @@ -38,7 +51,28 @@ class MangaListActivity : finishAfterTransition() return } + viewBinding.chipSort?.setOnClickListener(this) title = if (source == MangaSource.LOCAL) getString(R.string.local_storage) else source.title + initList(source, tags) + } + + override fun onWindowInsetsChanged(insets: Insets) { + viewBinding.root.updatePadding( + left = insets.left, + right = insets.right, + ) + viewBinding.cardFilter?.updateLayoutParams { + bottomMargin = marginStart + insets.bottom + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.chip_sort -> FilterSheetFragment.show(supportFragmentManager) + } + } + + private fun initList(source: MangaSource, tags: Set?) { val fm = supportFragmentManager if (fm.findFragmentById(R.id.container) == null) { fm.commit { @@ -52,24 +86,54 @@ class MangaListActivity : if (!tags.isNullOrEmpty() && fragment is RemoteListFragment) { runOnCommit(ApplyFilterRunnable(fragment, tags)) } + runOnCommit { initFilter() } } + } else { + initFilter() } } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) + private fun initFilter() { + if (viewBinding.containerFilter != null) { + if (supportFragmentManager.findFragmentById(R.id.container_filter) == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.container_filter, FilterSheetFragment::class.java, null) + } + } + } else if (viewBinding.containerFilterHeader != null) { + if (supportFragmentManager.findFragmentById(R.id.container_filter_header) == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.container_filter_header, FilterHeaderFragment::class.java, null) + } + } + } + val filterOwner = FilterOwner.from(this) + val chipSort = viewBinding.chipSort + if (chipSort != null) { + filterOwner.header.observe(this) { + chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0) + } + } else { + filterOwner.header.map { + it.textSummary + }.flowOn(Dispatchers.Default) + .observe(this) { + supportActionBar?.subtitle = it + } + } } private class ApplyFilterRunnable( - private val fragment: RemoteListFragment, + private val fragment: MangaListFragment, private val tags: Set, ) : Runnable { override fun run() { - fragment.viewModel.applyFilter(tags) + checkNotNull(FilterOwner.find(fragment)) { + "Cannot find FilterOwner" + }.applyFilter(tags) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt index c8c75b2dd..36a1125fc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt @@ -27,7 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver -import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.MangaListListener @@ -159,7 +159,7 @@ class MultiSearchActivity : } R.id.action_favourite -> { - FavouriteCategoriesBottomSheet.show(supportFragmentManager, collectSelectedItems()) + FavouriteCategoriesSheet.show(supportFragmentManager, collectSelectedItems()) mode.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt index a01a2d31f..d44357b08 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -135,6 +135,7 @@ class SearchSuggestionViewModel @Inject constructor( ChipsView.ChipModel( tint = 0, title = tag.title, + icon = 0, data = tag, isCheckable = false, isChecked = false, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index e062e6800..9e9091beb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -146,7 +146,7 @@ class ContentSettingsFragment : summary = if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) { context.getString(R.string.disabled) } else { - "$type $address:$port" + "$address:$port" } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt index 7b86847c4..4125d46e2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt @@ -6,11 +6,15 @@ import android.view.View import android.view.inputmethod.EditorInfo import androidx.preference.EditTextPreference import androidx.preference.Preference +import androidx.preference.PreferenceCategory import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.settings.utils.EditTextBindListener +import org.koitharu.kotatsu.settings.utils.PasswordSummaryProvider +import org.koitharu.kotatsu.settings.utils.validation.DomainValidator +import org.koitharu.kotatsu.settings.utils.validation.PortNumberValidator import java.net.Proxy @AndroidEntryPoint @@ -30,9 +34,19 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_NUMBER, hint = null, - validator = null, + validator = PortNumberValidator(), ), ) + findPreference(AppSettings.KEY_PROXY_PASSWORD)?.let { pref -> + pref.setOnBindEditTextListener( + EditTextBindListener( + inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD, + hint = null, + validator = null, + ), + ) + pref.summaryProvider = PasswordSummaryProvider() + } updateDependencies() } @@ -56,5 +70,8 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), val isProxyEnabled = settings.proxyType != Proxy.Type.DIRECT findPreference(AppSettings.KEY_PROXY_ADDRESS)?.isEnabled = isProxyEnabled findPreference(AppSettings.KEY_PROXY_PORT)?.isEnabled = isProxyEnabled + findPreference(AppSettings.KEY_PROXY_AUTH)?.isEnabled = isProxyEnabled + findPreference(AppSettings.KEY_PROXY_LOGIN)?.isEnabled = isProxyEnabled + findPreference(AppSettings.KEY_PROXY_PASSWORD)?.isEnabled = isProxyEnabled } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsExt.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsExt.kt index 293f4847d..d0123bee7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsExt.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsExt.kt @@ -11,6 +11,8 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference import org.koitharu.kotatsu.settings.utils.EditTextBindListener import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider +import org.koitharu.kotatsu.settings.utils.validation.DomainValidator +import org.koitharu.kotatsu.settings.utils.validation.HeaderValidator fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMangaRepository) { val configKeys = repository.getConfigKeys() @@ -19,10 +21,12 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang val preference: Preference = when (key) { is ConfigKey.Domain -> { val presetValues = key.presetValues - if (presetValues.isNullOrEmpty()) { + if (presetValues.size <= 1) { EditTextPreference(requireContext()) } else { - AutoCompleteTextViewPreference(requireContext()).apply { entries = presetValues } + AutoCompleteTextViewPreference(requireContext()).apply { + entries = presetValues.toStringArray() + } }.apply { summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue) setOnBindEditTextListener( @@ -64,3 +68,7 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang screen.addPreference(preference) } } + +private fun Array.toStringArray(): Array { + return Array(size) { i -> this[i] as? String ?: "" } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt index 88a1f64e4..cc086c8cd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt @@ -19,6 +19,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding private const val MIN_PASSWORD_LENGTH = 4 @@ -46,13 +47,13 @@ class ProtectSetupActivity : viewBinding.switchBiometric.setOnCheckedChangeListener(this) viewModel.isSecondStep.observe(this, this::onStepChanged) - viewModel.onPasswordSet.observe(this) { + viewModel.onPasswordSet.observeEvent(this) { finishAfterTransition() } - viewModel.onPasswordMismatch.observe(this) { + viewModel.onPasswordMismatch.observeEvent(this) { viewBinding.editPassword.error = getString(R.string.passwords_mismatch) } - viewModel.onClearText.observe(this) { + viewModel.onClearText.observeEvent(this) { viewBinding.editPassword.text?.clear() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt index 942ef03ee..c9ad94181 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt @@ -4,22 +4,20 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.BaseBottomSheet import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.SheetBaseBinding @AndroidEntryPoint class TrackerCategoriesConfigSheet : - BaseBottomSheet(), - OnListItemClickListener, - View.OnClickListener { + BaseAdaptiveSheet(), + OnListItemClickListener { private val viewModel by viewModels() @@ -30,8 +28,6 @@ class TrackerCategoriesConfigSheet : override fun onViewBindingCreated(binding: SheetBaseBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.headerBar.setTitle(R.string.favourites_categories) - binding.buttonDone.isVisible = true - binding.buttonDone.setOnClickListener(this) val adapter = TrackerCategoriesConfigAdapter(this) binding.recyclerView.adapter = adapter @@ -42,10 +38,6 @@ class TrackerCategoriesConfigSheet : viewModel.toggleItem(item) } - override fun onClick(v: View?) { - dismiss() - } - companion object { private const val TAG = "TrackerCategoriesConfigSheet" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PasswordSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PasswordSummaryProvider.kt new file mode 100644 index 000000000..5d6068aca --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PasswordSummaryProvider.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.settings.utils + +import android.text.TextUtils +import androidx.preference.EditTextPreference +import androidx.preference.Preference + +class PasswordSummaryProvider() : Preference.SummaryProvider { + + private val delegate = EditTextPreference.SimpleSummaryProvider.getInstance() + + override fun provideSummary(preference: EditTextPreference): CharSequence? { + val summary = delegate.provideSummary(preference) + return if (summary != null && !TextUtils.isEmpty(preference.text)) { + String(CharArray(summary.length) { '\u2022' }) + } else { + summary + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DomainValidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/DomainValidator.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/DomainValidator.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/DomainValidator.kt index 0ac467edc..201fabeec 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DomainValidator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/DomainValidator.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings +package org.koitharu.kotatsu.settings.utils.validation import okhttp3.HttpUrl import org.koitharu.kotatsu.R diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/HeaderValidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/HeaderValidator.kt similarity index 92% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/HeaderValidator.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/HeaderValidator.kt index b25be70d6..36891f980 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/HeaderValidator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/HeaderValidator.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings +package org.koitharu.kotatsu.settings.utils.validation import okhttp3.Headers import org.koitharu.kotatsu.R diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/PortNumberValidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/PortNumberValidator.kt new file mode 100644 index 000000000..3bee9f9a5 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/PortNumberValidator.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.settings.utils.validation + +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.EditTextValidator + +class PortNumberValidator : EditTextValidator() { + + override fun validate(text: String): ValidationResult { + val trimmed = text.trim() + if (trimmed.isEmpty()) { + return ValidationResult.Success + } + return if (!checkCharacters(trimmed)) { + ValidationResult.Failed(context.getString(R.string.invalid_port_number)) + } else { + ValidationResult.Success + } + } + + private fun checkCharacters(value: String): Boolean { + val intValue = value.toIntOrNull() ?: return false + return intValue in 1..65535 + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfSelectionCallback.kt index a5c5a942e..204dd7599 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfSelectionCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfSelectionCallback.kt @@ -12,7 +12,7 @@ import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations -import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.flattenTo @@ -63,7 +63,7 @@ class ShelfSelectionCallback( } R.id.action_favourite -> { - FavouriteCategoriesBottomSheet.show(fragmentManager, collectSelectedItems(controller)) + FavouriteCategoriesSheet.show(fragmentManager, collectSelectedItems(controller)) mode.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt index f26b3dd2d..a7cd65491 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt @@ -57,7 +57,7 @@ class SyncAuthActivity : BaseActivity(), View.OnClickLi viewModel.onTokenObtained.observeEvent(this, ::onTokenReceived) viewModel.onError.observeEvent(this, ::onError) viewModel.isLoading.observe(this, ::onLoadingStateChanged) - viewModel.onAccountAlreadyExists.observe(this) { + viewModel.onAccountAlreadyExists.observeEvent(this) { onAccountAlreadyExists() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt index 735adb19a..2de3183f4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt @@ -14,7 +14,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding -import org.koitharu.kotatsu.settings.DomainValidator +import org.koitharu.kotatsu.settings.utils.validation.DomainValidator import org.koitharu.kotatsu.sync.data.SyncSettings import javax.inject.Inject diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt index 2e9cfd162..588e22677 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt @@ -79,7 +79,7 @@ class FeedFragment : viewModel.content.observe(viewLifecycleOwner, this::onListChanged) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onFeedCleared.observe(viewLifecycleOwner) { + viewModel.onFeedCleared.observeEvent(viewLifecycleOwner) { onFeedCleared() } TrackWorker.observeIsRunning(binding.root.context.applicationContext) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt index 64963e946..063e17d8c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt @@ -1,9 +1,11 @@ package org.koitharu.kotatsu.tracker.ui.feed.adapter +import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.DiffUtil import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD @@ -21,7 +23,7 @@ class FeedAdapter( coil: ImageLoader, lifecycleOwner: LifecycleOwner, listener: MangaListListener, -) : AsyncListDifferDelegationAdapter(DiffCallback()) { +) : AsyncListDifferDelegationAdapter(DiffCallback()), FastScroller.SectionIndexer { init { delegatesManager @@ -34,6 +36,17 @@ class FeedAdapter( .addDelegate(ITEM_TYPE_DATE_HEADER, relatedDateItemAD()) } + 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 DateTimeAgo) { + return item.format(context.resources) + } + } + return null + } + private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when { diff --git a/app/src/main/res/drawable/ic_new.xml b/app/src/main/res/drawable/ic_new.xml index f51c265cf..f0e81e12d 100644 --- a/app/src/main/res/drawable/ic_new.xml +++ b/app/src/main/res/drawable/ic_new.xml @@ -2,7 +2,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?colorControlNormal" + android:tint="?colorError" android:viewportWidth="24" android:viewportHeight="24"> + + + diff --git a/app/src/main/res/layout-land/item_empty_state.xml b/app/src/main/res/layout-land/item_empty_state.xml new file mode 100644 index 000000000..f039c9cbc --- /dev/null +++ b/app/src/main/res/layout-land/item_empty_state.xml @@ -0,0 +1,47 @@ + + + + + + + + + +