diff --git a/.weblate b/.weblate new file mode 100644 index 000000000..4a120e1a3 --- /dev/null +++ b/.weblate @@ -0,0 +1,3 @@ +[weblate] +url = https://hosted.weblate.org/api/ +translation = kotatsu/strings diff --git a/app/build.gradle b/app/build.gradle index c6167b8fa..ec7a73bbf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 33 - versionCode 545 - versionName '5.1.1' + versionCode 552 + versionName '5.2' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -82,17 +82,17 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2' //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:cae7073f87') { + implementation('com.github.KotatsuApp:kotatsu-parsers:f732582d55') { exclude group: 'org.json', module: 'json' } - implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21' + implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' 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.fragment:fragment-ktx:1.5.7' + implementation 'androidx.activity:activity-ktx:1.7.2' + implementation 'androidx.fragment:fragment-ktx:1.6.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-service:2.6.1' @@ -100,7 +100,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' @@ -109,7 +109,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' @@ -119,8 +119,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' @@ -131,8 +131,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' @@ -140,7 +140,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/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt similarity index 91% rename from app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt rename to app/src/androidTest/java/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt index 4b1784bed..d7fad17d9 100644 --- a/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt +++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt @@ -8,7 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import javax.inject.Inject import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -19,11 +18,12 @@ import org.junit.runner.RunWith import org.koitharu.kotatsu.SampleData import org.koitharu.kotatsu.awaitForIdle import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.data.HistoryRepository +import javax.inject.Inject @HiltAndroidTest @RunWith(AndroidJUnit4::class) -class ShortcutsUpdaterTest { +class AppShortcutManagerTest { @get:Rule var hiltRule = HiltAndroidRule(this) @@ -32,7 +32,7 @@ class ShortcutsUpdaterTest { lateinit var historyRepository: HistoryRepository @Inject - lateinit var shortcutsUpdater: ShortcutsUpdater + lateinit var appShortcutManager: AppShortcutManager @Inject lateinit var database: MangaDatabase @@ -72,6 +72,6 @@ class ShortcutsUpdaterTest { private suspend fun awaitUpdate() { val instrumentation = InstrumentationRegistry.getInstrumentation() instrumentation.awaitForIdle() - shortcutsUpdater.await() + appShortcutManager.await() } } diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt index f885ddb36..9984821cc 100644 --- a/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt +++ b/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt @@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.data.HistoryRepository import java.io.File import javax.inject.Inject 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/debug/java/org/koitharu/kotatsu/util/LoggingAdapterDataObserver.kt b/app/src/debug/java/org/koitharu/kotatsu/core/util/LoggingAdapterDataObserver.kt similarity index 96% rename from app/src/debug/java/org/koitharu/kotatsu/util/LoggingAdapterDataObserver.kt rename to app/src/debug/java/org/koitharu/kotatsu/core/util/LoggingAdapterDataObserver.kt index 07ae3c4bb..2d47a63b3 100644 --- a/app/src/debug/java/org/koitharu/kotatsu/util/LoggingAdapterDataObserver.kt +++ b/app/src/debug/java/org/koitharu/kotatsu/core/util/LoggingAdapterDataObserver.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.util +package org.koitharu.kotatsu.core.util import android.util.Log import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver diff --git a/app/src/debug/java/org/koitharu/kotatsu/util/ext/DebugExt.kt b/app/src/debug/java/org/koitharu/kotatsu/core/util/ext/DebugExt.kt similarity index 57% rename from app/src/debug/java/org/koitharu/kotatsu/util/ext/DebugExt.kt rename to app/src/debug/java/org/koitharu/kotatsu/core/util/ext/DebugExt.kt index acaab0f0c..62af20cbc 100644 --- a/app/src/debug/java/org/koitharu/kotatsu/util/ext/DebugExt.kt +++ b/app/src/debug/java/org/koitharu/kotatsu/core/util/ext/DebugExt.kt @@ -1,3 +1,3 @@ -package org.koitharu.kotatsu.util.ext +package org.koitharu.kotatsu.core.util.ext fun Throwable.printStackTraceDebug() = printStackTrace() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 10faa7ae2..2a167d459 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -102,6 +102,10 @@ android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:windowSoftInputMode="adjustResize" /> + >> @@ -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..98116f8a4 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,8 @@ package org.koitharu.kotatsu.bookmarks.domain import org.koitharu.kotatsu.parsers.model.Manga -import java.util.* +import org.koitharu.kotatsu.parsers.model.MangaPage +import java.util.Date class Bookmark( val manga: Manga, @@ -14,6 +15,20 @@ class Bookmark( val percent: Float, ) { + val directImageUrl: String? + get() = if (isImageUrlDirect()) imageUrl else null + + fun toMangaPage() = MangaPage( + id = pageId, + url = imageUrl, + preview = null, + source = manga.source, + ) + + private fun isImageUrlDirect(): Boolean { + return imageUrl.substringAfterLast('.').length in 2..4 + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -27,9 +42,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 +56,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..fab2180f3 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 @@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.util.ext.mapItems +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.util.ext.printStackTraceDebug import javax.inject.Inject @Reusable @@ -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/BookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt index e76ee3ed0..cbc9656f0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt @@ -30,6 +30,8 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.reverseAsync import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding import org.koitharu.kotatsu.details.ui.DetailsActivity @@ -81,8 +83,8 @@ class BookmarksFragment : binding.recyclerView.addItemDecoration(spacingDecoration) viewModel.content.observe(viewLifecycleOwner, ::onListChanged) - viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) + viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone) } override fun onDestroyView() { @@ -93,8 +95,11 @@ class BookmarksFragment : override fun onItemClick(item: Bookmark, view: View) { if (selectionController?.onItemClick(item.manga, item.pageId) != true) { - val intent = ReaderActivity.newIntent(view.context, item) - startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) + val intent = ReaderActivity.IntentBuilder(view.context) + .bookmark(item) + .incognito(true) + .build() + startActivity(intent, scaleUpActivityOptionsOf(view)) Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt index 614691d49..d08ce07b4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt @@ -1,18 +1,21 @@ package org.koitharu.kotatsu.bookmarks.ui -import androidx.lifecycle.LiveData 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.catch import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState @@ -25,9 +28,9 @@ class BookmarksViewModel @Inject constructor( private val repository: BookmarksRepository, ) : BaseViewModel() { - val onActionDone = SingleLiveEvent() + val onActionDone = MutableEventFlow() - val content: LiveData> = repository.observeBookmarks() + val content: StateFlow> = repository.observeBookmarks() .map { list -> if (list.isEmpty()) { listOf( @@ -43,12 +46,12 @@ class BookmarksViewModel @Inject constructor( } } .catch { e -> emit(listOf(e.toErrorState(canRetry = false))) } - .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) fun removeBookmarks(ids: Map>) { launchJob(Dispatchers.Default) { val handle = repository.removeBookmarks(ids) - onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle)) + onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle)) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt index 886aba926..5996df31d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt @@ -28,7 +28,8 @@ fun bookmarkListAD( binding.root.setOnLongClickListener(listener) bind { - binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run { + val data: Any = item.directImageUrl ?: item.toMangaPage() + binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run { size(CoverSizeResolver(binding.imageViewThumb)) placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder) 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/browser/BrowserActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt index f84ca4899..7d9217ea5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt @@ -103,6 +103,7 @@ class BrowserActivity : BaseActivity(), BrowserCallback override fun onDestroy() { super.onDestroy() + viewBinding.webView.stopLoading() viewBinding.webView.destroy() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt new file mode 100644 index 000000000..51d29fc9f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt @@ -0,0 +1,175 @@ +package org.koitharu.kotatsu.browser.cloudflare + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.webkit.CookieManager +import android.webkit.WebSettings +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.graphics.Insets +import androidx.core.net.toUri +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import dagger.hilt.android.AndroidEntryPoint +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.browser.WebViewBackPressedCallback +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor +import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.TaggedActivityResult +import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability +import org.koitharu.kotatsu.databinding.ActivityBrowserBinding +import javax.inject.Inject +import com.google.android.material.R as materialR + +@AndroidEntryPoint +class CloudFlareActivity : BaseActivity(), CloudFlareCallback { + + private var pendingResult = RESULT_CANCELED + + @Inject + lateinit var cookieJar: MutableCookieJar + + private var onBackPressedCallback: WebViewBackPressedCallback? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) { + return + } + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) + } + val url = intent?.dataString.orEmpty() + with(viewBinding.webView.settings) { + javaScriptEnabled = true + cacheMode = WebSettings.LOAD_DEFAULT + domStorageEnabled = true + databaseEnabled = true + userAgentString = intent?.getStringExtra(ARG_UA) ?: CommonHeadersInterceptor.userAgentFallback + } + viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url) + onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also { + onBackPressedDispatcher.addCallback(it) + } + CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) + if (savedInstanceState != null) { + return + } + if (url.isEmpty()) { + finishAfterTransition() + } else { + onTitleChanged(getString(R.string.loading_), url) + viewBinding.webView.loadUrl(url) + } + } + + override fun onDestroy() { + viewBinding.webView.run { + stopLoading() + destroy() + } + super.onDestroy() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + viewBinding.webView.saveState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + viewBinding.webView.restoreState(savedInstanceState) + } + + override fun onWindowInsetsChanged(insets: Insets) { + viewBinding.appbar.updatePadding( + top = insets.top, + ) + viewBinding.root.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom, + ) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + viewBinding.webView.stopLoading() + finishAfterTransition() + true + } + + else -> super.onOptionsItemSelected(item) + } + + override fun onResume() { + super.onResume() + viewBinding.webView.onResume() + } + + override fun onPause() { + viewBinding.webView.onPause() + super.onPause() + } + + override fun finish() { + setResult(pendingResult) + super.finish() + } + + override fun onPageLoaded() { + viewBinding.progressBar.isInvisible = true + } + + override fun onCheckPassed() { + pendingResult = RESULT_OK + finishAfterTransition() + } + + override fun onLoadingStateChanged(isLoading: Boolean) { + viewBinding.progressBar.isVisible = isLoading + } + + override fun onHistoryChanged() { + onBackPressedCallback?.onHistoryChanged() + } + + override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { + setTitle(title) + supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle + } + + class Contract : ActivityResultContract, TaggedActivityResult>() { + override fun createIntent(context: Context, input: Pair): Intent { + return newIntent(context, input.first, input.second) + } + + override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult { + return TaggedActivityResult(TAG, resultCode) + } + } + + companion object { + + const val TAG = "CloudFlareActivity" + private const val ARG_UA = "ua" + + fun newIntent( + context: Context, + url: String, + headers: Headers?, + ) = Intent(context, CloudFlareActivity::class.java).apply { + data = url.toUri() + headers?.get(CommonHeaders.USER_AGENT)?.let { + putExtra(ARG_UA, it) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt deleted file mode 100644 index 0a1284448..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt +++ /dev/null @@ -1,123 +0,0 @@ -package org.koitharu.kotatsu.browser.cloudflare - -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import android.webkit.CookieManager -import android.webkit.WebSettings -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isInvisible -import androidx.fragment.app.setFragmentResult -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import okhttp3.Headers -import org.koitharu.kotatsu.browser.WebViewBackPressedCallback -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor -import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar -import org.koitharu.kotatsu.core.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding -import javax.inject.Inject - -@AndroidEntryPoint -class CloudFlareDialog : AlertDialogFragment(), CloudFlareCallback { - - private lateinit var url: String - private val pendingResult = Bundle(1) - - @Inject - lateinit var cookieJar: MutableCookieJar - - private var onBackPressedCallback: WebViewBackPressedCallback? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - url = requireArguments().getString(ARG_URL).orEmpty() - } - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentCloudflareBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - with(binding.webView.settings) { - javaScriptEnabled = true - cacheMode = WebSettings.LOAD_DEFAULT - domStorageEnabled = true - databaseEnabled = true - userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome - } - binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url) - CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true) - if (url.isEmpty()) { - dismissAllowingStateLoss() - } else { - binding.webView.loadUrl(url) - } - } - - override fun onDestroyView() { - requireViewBinding().webView.stopLoading() - requireViewBinding().webView.destroy() - onBackPressedCallback = null - super.onDestroyView() - } - - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null) - } - - override fun onDialogCreated(dialog: AlertDialog) { - super.onDialogCreated(dialog) - onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also { - dialog.onBackPressedDispatcher.addCallback(it) - } - } - - override fun onResume() { - super.onResume() - requireViewBinding().webView.onResume() - } - - override fun onPause() { - requireViewBinding().webView.onPause() - super.onPause() - } - - override fun onDismiss(dialog: DialogInterface) { - setFragmentResult(TAG, pendingResult) - super.onDismiss(dialog) - } - - override fun onPageLoaded() { - viewBinding?.progressBar?.isInvisible = true - } - - override fun onCheckPassed() { - pendingResult.putBoolean(EXTRA_RESULT, true) - dismissAllowingStateLoss() - } - - override fun onHistoryChanged() { - onBackPressedCallback?.onHistoryChanged() - } - - companion object { - - const val TAG = "CloudFlareDialog" - const val EXTRA_RESULT = "result" - private const val ARG_URL = "url" - private const val ARG_UA = "ua" - - fun newInstance(url: String, headers: Headers?) = CloudFlareDialog().withArgs(2) { - putString(ARG_URL, url) - headers?.get(CommonHeaders.USER_AGENT)?.let { - putString(ARG_UA, it) - } - } - } -} 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 cbf361112..c6d273615 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt @@ -29,22 +29,22 @@ import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.StubContentCache import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.network.* +import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher import org.koitharu.kotatsu.core.ui.image.CoilImageGetter import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle +import org.koitharu.kotatsu.core.util.AcraScreenLogger 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.local.data.CacheDir import org.koitharu.kotatsu.local.data.CbzFetcher -import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher @@ -86,7 +86,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 +109,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() } @@ -124,12 +126,12 @@ interface AppModule { @ElementsIntoSet fun provideDatabaseObservers( widgetUpdater: WidgetUpdater, - shortcutsUpdater: ShortcutsUpdater, + appShortcutManager: AppShortcutManager, backupObserver: BackupObserver, syncController: SyncController, ): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf( widgetUpdater, - shortcutsUpdater, + appShortcutManager, backupObserver, syncController, ) @@ -140,10 +142,12 @@ interface AppModule { appProtectHelper: AppProtectHelper, activityRecreationHandle: ActivityRecreationHandle, incognitoModeIndicator: IncognitoModeIndicator, + acraScreenLogger: AcraScreenLogger, ): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf( appProtectHelper, activityRecreationHandle, incognitoModeIndicator, + acraScreenLogger, ) @Provides 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/exceptions/resolve/DialogErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt index 68949cc36..1edf3b662 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt @@ -22,10 +22,7 @@ class DialogErrorObserver( fragment: Fragment?, ) : this(host, fragment, null, null) - override fun onChanged(value: Throwable?) { - if (value == null) { - return - } + override suspend fun emit(value: Throwable) { val listener = DialogListener(value) val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context) .setMessage(value.getDisplayMessage(host.context.resources)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt index 3af75685e..a64fb9edf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt @@ -7,8 +7,8 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.Observer import androidx.lifecycle.coroutineScope +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.koitharu.kotatsu.core.util.ext.findActivity @@ -19,7 +19,7 @@ abstract class ErrorObserver( protected val fragment: Fragment?, private val resolver: ExceptionResolver?, private val onResolved: Consumer?, -) : Observer { +) : FlowCollector { protected val activity = host.context.findActivity() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt index b5e267dcb..9407ab6e9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt @@ -6,15 +6,13 @@ import androidx.annotation.StringRes import androidx.collection.ArrayMap import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity -import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Headers import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.BrowserActivity -import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog +import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.util.TaggedActivityResult -import org.koitharu.kotatsu.core.util.isSuccess import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.MangaSource @@ -23,20 +21,26 @@ import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -class ExceptionResolver private constructor( - private val activity: FragmentActivity?, - private val fragment: Fragment?, -) : ActivityResultCallback { +class ExceptionResolver : ActivityResultCallback { private val continuations = ArrayMap>(1) - private lateinit var sourceAuthContract: ActivityResultLauncher - - constructor(activity: FragmentActivity) : this(activity = activity, fragment = null) { + private val activity: FragmentActivity? + private val fragment: Fragment? + private val sourceAuthContract: ActivityResultLauncher + private val cloudflareContract: ActivityResultLauncher> + + constructor(activity: FragmentActivity) { + this.activity = activity + fragment = null sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this) + cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this) } - constructor(fragment: Fragment) : this(activity = null, fragment = fragment) { + constructor(fragment: Fragment) { + this.fragment = fragment + activity = null sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this) + cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this) } override fun onActivityResult(result: TaggedActivityResult) { @@ -58,22 +62,9 @@ class ExceptionResolver private constructor( else -> false } - private suspend fun resolveCF(url: String, headers: Headers): Boolean { - val dialog = CloudFlareDialog.newInstance(url, headers) - val fm = getFragmentManager() - return suspendCancellableCoroutine { cont -> - fm.clearFragmentResult(CloudFlareDialog.TAG) - continuations[CloudFlareDialog.TAG] = cont - fm.setFragmentResultListener(CloudFlareDialog.TAG, checkNotNull(fragment ?: activity)) { key, result -> - continuations.remove(key)?.resume(result.getBoolean(CloudFlareDialog.EXTRA_RESULT)) - } - dialog.show(fm, CloudFlareDialog.TAG) - cont.invokeOnCancellation { - continuations.remove(CloudFlareDialog.TAG, cont) - fm.clearFragmentResultListener(CloudFlareDialog.TAG) - dialog.dismissAllowingStateLoss() - } - } + private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont -> + continuations[CloudFlareActivity.TAG] = cont + cloudflareContract.launch(url to headers) } private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt index 9a1ef14d5..e39897cfc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt @@ -22,10 +22,7 @@ class SnackbarErrorObserver( fragment: Fragment?, ) : this(host, fragment, null, null) - override fun onChanged(value: Throwable?) { - if (value == null) { - return - } + override suspend fun emit(value: Throwable) { val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT) if (activity is BottomNavOwner) { snackbar.anchorView = activity.bottomNav diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt index 03047a807..4d26db92a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt @@ -16,12 +16,12 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.asArrayList +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.byte2HexFormatted import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull import org.koitharu.kotatsu.parsers.util.parseJsonArray import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.io.ByteArrayInputStream import java.io.InputStream import java.security.MessageDigest diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt index d63c2e3bb..ee75e9e21 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt @@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.subdir import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.io.File import java.io.FileOutputStream import java.text.SimpleDateFormat diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index 4ed4c7885..ce0b3e7f1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet fun Collection.ids() = mapToSet { it.id } @@ -23,6 +24,10 @@ fun Collection.countChaptersByBranch(): Int { return acc.values.max() } +fun Manga.findChapter(id: Long): MangaChapter? { + return chapters?.find { it.id == id } +} + fun Manga.getPreferredBranch(history: MangaHistory?): String? { val ch = chapters if (ch.isNullOrEmpty()) { @@ -54,3 +59,6 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? { } return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key } + +val Manga.isLocal: Boolean + get() = source == MangaSource.LOCAL 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..87a410ba6 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 @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.core.network import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.io.IOException import java.net.InetSocketAddress import java.net.Proxy @@ -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/CommonHeadersInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt index ce873fbc6..979dcd7f2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt @@ -12,7 +12,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mergeWith -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.net.IDN import java.util.Locale import javax.inject.Inject diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt index 9547b4da5..0cc4b6db0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt @@ -6,7 +6,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.dnsoverhttps.DnsOverHttps import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.net.InetAddress import java.net.UnknownHostException 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/network/SSLBypass.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLBypass.kt index a7dc8a04a..d5ef6fd59 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLBypass.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLBypass.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.network import android.annotation.SuppressLint import okhttp3.OkHttpClient -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.security.SecureRandom import java.security.cert.X509Certificate import javax.net.ssl.SSLContext diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt index 4a709f38b..623824e57 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.Cookie import okhttp3.HttpUrl -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug private const val PREFS_NAME = "cookies" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/ShortcutsUpdater.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/os/ShortcutsUpdater.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt index 43c904696..c1db43782 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/ShortcutsUpdater.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt @@ -15,7 +15,6 @@ import androidx.core.graphics.drawable.toBitmap import androidx.room.InvalidationTracker import coil.ImageLoader import coil.request.ImageRequest -import coil.size.Precision import coil.size.Scale import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -25,18 +24,19 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.ReaderActivity -import org.koitharu.kotatsu.util.ext.printStackTraceDebug import javax.inject.Inject import javax.inject.Singleton @Singleton -class ShortcutsUpdater @Inject constructor( +class AppShortcutManager @Inject constructor( @ApplicationContext private val context: Context, private val coil: ImageLoader, private val historyRepository: HistoryRepository, @@ -128,8 +128,8 @@ class ShortcutsUpdater @Inject constructor( .data(manga.coverUrl) .size(iconSize.width, iconSize.height) .tag(manga.source) - .precision(Precision.EXACT) .scale(Scale.FILL) + .transformations(ThumbnailTransformation()) .build(), ).getDrawableOrThrow().toBitmap() }.fold( @@ -142,8 +142,9 @@ class ShortcutsUpdater @Inject constructor( .setLongLabel(manga.title) .setIcon(icon) .setIntent( - ReaderActivity.newIntent(context, manga.id) - .setAction(ReaderActivity.ACTION_MANGA_READ), + ReaderActivity.IntentBuilder(context) + .mangaId(manga.id) + .build(), ) } 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 393f5357f..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 @@ -1,41 +1,25 @@ package org.koitharu.kotatsu.core.parser -import android.graphics.BitmapFactory -import android.net.Uri -import android.util.Size import androidx.room.withTransaction import dagger.Reusable -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runInterruptible -import okhttp3.OkHttpClient -import okhttp3.Request 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.CommonHeaders -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.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.reader.domain.ReaderColorFilter -import java.io.File -import java.io.InputStream -import java.util.zip.ZipFile import javax.inject.Inject -import kotlin.math.roundToInt @Reusable class MangaDataRepository @Inject constructor( - @MangaHttpClient private val okHttpClient: OkHttpClient, private val db: MangaDatabase, ) { @@ -55,6 +39,7 @@ class MangaDataRepository @Inject constructor( entity.copy( cfBrightness = colorFilter?.brightness ?: 0f, cfContrast = colorFilter?.contrast ?: 0f, + cfInvert = colorFilter?.isInverted ?: false, ), ) } @@ -97,74 +82,18 @@ 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 } } - /** - * Automatic determine type of manga by page size - * @return ReaderMode.WEBTOON if page is wide - */ - suspend fun determineMangaIsWebtoon(repository: MangaRepository, pages: List): Boolean { - val pageIndex = (pages.size * 0.3).roundToInt() - val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" } - val url = repository.getPageUrl(page) - val uri = Uri.parse(url) - val size = if (uri.scheme == "cbz") { - runInterruptible(Dispatchers.IO) { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - zip.getInputStream(entry).use { - getBitmapSize(it) - } - } - } else { - val request = Request.Builder() - .url(url) - .get() - .tag(MangaSource::class.java, page.source) - .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) - .build() - okHttpClient.newCall(request).await().use { - runInterruptible(Dispatchers.IO) { - getBitmapSize(it.body?.byteStream()) - } - } - } - return size.width * MIN_WEBTOON_RATIO < size.height - } - private fun newEntity(mangaId: Long) = MangaPrefsEntity( mangaId = mangaId, mode = -1, cfBrightness = 0f, cfContrast = 0f, + cfInvert = false, ) - - companion object { - - private const val MIN_WEBTOON_RATIO = 2 - - suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) { - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeFile(file.path, options)?.recycle() - options.outMimeType - } - - private fun getBitmapSize(input: InputStream?): Size { - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeStream(input, null, options)?.recycle() - val imageHeight: Int = options.outHeight - val imageWidth: Int = options.outWidth - check(imageHeight > 0 && imageWidth > 0) - return Size(imageWidth, imageHeight) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt index 5c3d54a17..8ab582147 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt @@ -12,28 +12,31 @@ import org.koitharu.kotatsu.parsers.model.Manga class MangaIntent private constructor( @JvmField val manga: Manga?, - @JvmField val mangaId: Long, + @JvmField val id: Long, @JvmField val uri: Uri?, ) { constructor(intent: Intent?) : this( manga = intent?.getParcelableExtraCompat(KEY_MANGA)?.manga, - mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, + id = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, uri = intent?.data, ) constructor(savedStateHandle: SavedStateHandle) : this( manga = savedStateHandle.get(KEY_MANGA)?.manga, - mangaId = savedStateHandle[KEY_ID] ?: ID_NONE, + id = savedStateHandle[KEY_ID] ?: ID_NONE, uri = savedStateHandle[BaseActivity.EXTRA_DATA], ) constructor(args: Bundle?) : this( manga = args?.getParcelableCompat(KEY_MANGA)?.manga, - mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, + id = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, uri = null, ) + val mangaId: Long + get() = if (id != ID_NONE) id else manga?.id ?: uri?.lastPathSegment?.toLongOrNull() ?: ID_NONE + companion object { const val ID_NONE = 0L 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 b90b10b52..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 @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.parser import androidx.annotation.AnyThread import org.koitharu.kotatsu.core.cache.ContentCache -import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter @@ -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/MangaTagHighlighter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaTagHighlighter.kt deleted file mode 100644 index 7168445bd..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaTagHighlighter.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -import android.content.Context -import androidx.annotation.ColorRes -import dagger.Reusable -import dagger.hilt.android.qualifiers.ApplicationContext -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.parsers.model.MangaTag -import javax.inject.Inject - -@Reusable -class MangaTagHighlighter @Inject constructor( - @ApplicationContext context: Context, -) { - - private val dict by lazy { - context.resources.openRawResource(R.raw.tags_redlist).use { - val set = HashSet() - it.bufferedReader().forEachLine { x -> - val line = x.trim() - if (line.isNotEmpty()) { - set.add(line) - } - } - set - } - } - - @ColorRes - fun getTint(tag: MangaTag): Int { - return if (tag.title.lowercase() in dict) { - R.color.warning - } else { - 0 - } - } -} 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 fe2e24192..b48123670 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 @@ -2,7 +2,9 @@ package org.koitharu.kotatsu.core.prefs import android.content.Context import android.content.SharedPreferences +import android.net.ConnectivityManager import android.net.Uri +import android.os.Build import android.provider.Settings import androidx.annotation.FloatRange import androidx.appcompat.app.AppCompatDelegate @@ -23,7 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.shelf.domain.ShelfSection +import org.koitharu.kotatsu.shelf.domain.model.ShelfSection import java.io.File import java.net.Proxy import java.util.Collections @@ -179,10 +181,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isUnstableUpdatesAllowed: Boolean get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) - fun isContentPrefetchEnabled(): Boolean { - val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER) - return policy.isNetworkAllowed(connectivityManager) - } + val isContentPrefetchEnabled: Boolean + get() { + if (isBackgroundNetworkRestricted()) { + return false + } + val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER) + return policy.isNetworkAllowed(connectivityManager) + } var sourcesOrder: List get() = prefs.getString(KEY_SOURCES_ORDER, null) @@ -271,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) @@ -289,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) } @@ -301,10 +316,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f) set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) } - fun isPagesPreloadEnabled(): Boolean { - val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED) - return policy.isNetworkAllowed(connectivityManager) - } + val isPagesPreloadEnabled: Boolean + get() { + if (isBackgroundNetworkRestricted()) { + return false + } + val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED) + return policy.isNetworkAllowed(connectivityManager) + } fun getMangaSources(includeHidden: Boolean): List { val list = remoteSources.toMutableList() @@ -342,6 +361,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { fun observe() = prefs.observe() + private fun isBackgroundNetworkRestricted(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED + } else { + false + } + } + companion object { const val PAGE_SWITCH_TAPS = "taps" @@ -430,6 +457,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/prefs/AppSettingsObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt index 606ae9d84..ed6d14f68 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt @@ -1,13 +1,11 @@ package org.koitharu.kotatsu.core.prefs -import androidx.lifecycle.liveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transform -import kotlin.coroutines.CoroutineContext fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { var lastValue: T = valueProducer() @@ -23,25 +21,9 @@ fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> } } -fun AppSettings.observeAsLiveData( - context: CoroutineContext, - key: String, - valueProducer: AppSettings.() -> T, -) = liveData(context) { - emit(valueProducer()) - observe().collect { - if (it == key) { - val value = valueProducer() - if (value != latestValue) { - emit(value) - } - } - } -} - fun AppSettings.observeAsStateFlow( - key: String, scope: CoroutineScope, + key: String, valueProducer: AppSettings.() -> T, ): StateFlow = observe().transform { if (it == key) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt index 40c091d4d..6809043ef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt @@ -27,6 +27,7 @@ abstract class AlertDialogFragment : DialogFragment() { .setView(binding.root) .run(::onBuildDialog) .create() + .also(::onDialogCreated) } final override fun onCreateView( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt index f547e527a..9eb35d7f6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt @@ -14,7 +14,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.Toolbar -import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat @@ -45,7 +44,7 @@ abstract class BaseActivity : protected val exceptionResolver = ExceptionResolver(this) @JvmField - protected val insetsDelegate = WindowInsetsDelegate(this) + protected val insetsDelegate = WindowInsetsDelegate() @JvmField val actionModeDelegate = ActionModeDelegate() @@ -62,6 +61,7 @@ abstract class BaseActivity : super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) insetsDelegate.handleImeInsets = true + insetsDelegate.addInsetsListener(this) putDataToExtras(intent) } @@ -103,7 +103,8 @@ abstract class BaseActivity : override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { - ActivityCompat.recreate(this) + // ActivityCompat.recreate(this) + error("Test") return true } return super.onKeyDown(keyCode, event) 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/BaseFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt index 6dfdadf1d..0809799b9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt @@ -26,7 +26,7 @@ abstract class BaseFragment : protected val exceptionResolver = ExceptionResolver(this) @JvmField - protected val insetsDelegate = WindowInsetsDelegate(this) + protected val insetsDelegate = WindowInsetsDelegate() protected val actionModeDelegate: ActionModeDelegate get() = (requireActivity() as BaseActivity<*>).actionModeDelegate @@ -44,11 +44,13 @@ abstract class BaseFragment : final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) insetsDelegate.onViewCreated(view) + insetsDelegate.addInsetsListener(this) onViewBindingCreated(requireViewBinding(), savedInstanceState) } override fun onDestroyView() { viewBinding = null + insetsDelegate.removeInsetsListener(this) insetsDelegate.onDestroyView() super.onDestroyView() } 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..e5faecd51 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,42 @@ 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.content.ContextCompat +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 +import org.koitharu.kotatsu.R 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 + navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim) + } else { + 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/BasePreferenceFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt index bfffb7ab3..ce407ddca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt @@ -12,10 +12,10 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate -import org.koitharu.kotatsu.settings.SettingsHeadersFragment +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.settings.SettingsActivity import javax.inject.Inject -@Suppress("LeakingThis") @AndroidEntryPoint abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(), @@ -26,27 +26,28 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : lateinit var settings: AppSettings @JvmField - protected val insetsDelegate = WindowInsetsDelegate(this) + protected val insetsDelegate = WindowInsetsDelegate() override val recyclerView: RecyclerView get() = listView override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + view.setBackgroundColor(view.context.getThemeColor(android.R.attr.colorBackground)) listView.clipToPadding = false insetsDelegate.onViewCreated(view) + insetsDelegate.addInsetsListener(this) } override fun onDestroyView() { + insetsDelegate.removeInsetsListener(this) insetsDelegate.onDestroyView() super.onDestroyView() } override fun onResume() { super.onResume() - if (titleId != 0) { - setTitle(getString(titleId)) - } + setTitle(if (titleId != 0) getString(titleId) else null) } @CallSuper @@ -56,9 +57,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : ) } - @Suppress("UsePropertyAccessSyntax") - protected fun setTitle(title: CharSequence) { - (parentFragment as? SettingsHeadersFragment)?.setTitle(title) - ?: activity?.setTitle(title) + protected fun setTitle(title: CharSequence?) { + (activity as? SettingsActivity)?.setSectionTitle(title) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt index 12cda4167..cdf31661b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.core.ui -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException @@ -8,26 +7,34 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.koitharu.kotatsu.core.ui.util.CountedBooleanLiveData -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.EventFlow +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext abstract class BaseViewModel : ViewModel() { @JvmField - protected val loadingCounter = CountedBooleanLiveData() + protected val loadingCounter = MutableStateFlow(0) @JvmField - protected val errorEvent = SingleLiveEvent() + protected val errorEvent = MutableEventFlow() - val onError: LiveData + val onError: EventFlow get() = errorEvent - val isLoading: LiveData - get() = loadingCounter + val isLoading: StateFlow + get() = loadingCounter.map { it > 0 } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) protected fun launchJob( context: CoroutineContext = EmptyCoroutineContext, @@ -51,7 +58,11 @@ abstract class BaseViewModel : ViewModel() { private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> throwable.printStackTraceDebug() if (throwable !is CancellationException) { - errorEvent.postCall(throwable) + errorEvent.call(throwable) } } + + protected fun MutableStateFlow.increment() = update { it + 1 } + + protected fun MutableStateFlow.decrement() = update { it - 1 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt index dc1c59bd4..2d4ead31d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug abstract class CoroutineIntentService : BaseService() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt new file mode 100644 index 000000000..c0ca38662 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.core.ui.image + +import android.graphics.Bitmap +import android.media.ThumbnailUtils +import coil.size.Size +import coil.size.pxOrElse +import coil.transform.Transformation + +class ThumbnailTransformation : Transformation { + + override val cacheKey: String = javaClass.name + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + return ThumbnailUtils.extractThumbnail( + input, + size.width.pxOrElse { input.width }, + size.height.pxOrElse { input.height }, + ) + } + + override fun equals(other: Any?) = other is ThumbnailTransformation + + override fun hashCode() = javaClass.hashCode() +} 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..946992aea 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 @@ -9,6 +9,7 @@ import android.util.AttributeSet import android.util.TypedValue import android.view.LayoutInflater import android.view.MotionEvent +import android.view.View import android.view.ViewGroup import android.widget.* import androidx.annotation.* @@ -24,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.isLayoutReversed +import org.koitharu.kotatsu.core.util.ext.parents import org.koitharu.kotatsu.databinding.FastScrollerBinding import kotlin.math.roundToInt import com.google.android.material.R as materialR @@ -56,6 +58,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 @@ -114,6 +117,9 @@ class FastScroller @JvmOverloads constructor( return viewHeight * proportion } + val isScrollbarVisible: Boolean + get() = binding.scrollbar.isVisible + init { clipChildren = false orientation = HORIZONTAL @@ -137,6 +143,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) @@ -163,7 +170,9 @@ class FastScroller @JvmOverloads constructor( when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { - if (event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) return false + if (!isScrollbarVisible || event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) { + return false + } requestDisallowInterceptTouchEvent(true) setHandleSelected(true) @@ -248,7 +257,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 +265,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 +279,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") @@ -294,10 +303,12 @@ class FastScroller @JvmOverloads constructor( if (parent is ViewGroup) { setLayoutParams(parent as ViewGroup) - } else if (recyclerView.parent is ViewGroup) { - val viewGroup = recyclerView.parent as ViewGroup - viewGroup.addView(this) - setLayoutParams(viewGroup) + } else { + val viewGroup = findValidParent(recyclerView) + if (viewGroup != null) { + viewGroup.addView(this) + setLayoutParams(viewGroup) + } } recyclerView.addOnScrollListener(scrollListener) @@ -511,6 +522,14 @@ class FastScroller @JvmOverloads constructor( return BubbleSize.values().getOrNull(ordinal) ?: defaultValue } + private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p -> + if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) { + p as ViewGroup + } else { + null + } + } + private val BubbleSize.textSize @Px get() = resources.getDimension(textSizeId) 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..d9941765d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt @@ -0,0 +1,174 @@ +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.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() = requireComponentDialog().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/util/ActionModeDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt index 585f39e69..2296aef53 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt @@ -14,7 +14,7 @@ class ActionModeDelegate : OnBackPressedCallback(false) { get() = activeActionMode != null override fun handleOnBackPressed() { - activeActionMode?.finish() + finishActionMode() } fun onSupportActionModeStarted(mode: ActionMode) { @@ -45,6 +45,10 @@ class ActionModeDelegate : OnBackPressedCallback(false) { owner.lifecycle.addObserver(ListenerLifecycleObserver(listener)) } + fun finishActionMode() { + activeActionMode?.finish() + } + private inner class ListenerLifecycleObserver( private val listener: ActionModeListener, ) : DefaultLifecycleObserver { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CountedBooleanLiveData.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CountedBooleanLiveData.kt deleted file mode 100644 index a737c0af9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CountedBooleanLiveData.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import androidx.annotation.AnyThread -import androidx.lifecycle.LiveData -import java.util.concurrent.atomic.AtomicInteger - -class CountedBooleanLiveData : LiveData(false) { - - private val counter = AtomicInteger(0) - - @AnyThread - fun increment() { - if (counter.getAndIncrement() == 0) { - postValue(true) - } - } - - @AnyThread - fun decrement() { - if (counter.decrementAndGet() == 0) { - postValue(false) - } - } - - @AnyThread - fun reset() { - if (counter.getAndSet(0) != 0) { - postValue(false) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleActionObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleActionObserver.kt index 4f6319f66..b66e64cbb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleActionObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleActionObserver.kt @@ -1,18 +1,15 @@ package org.koitharu.kotatsu.core.ui.util import android.view.View -import androidx.lifecycle.Observer import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R class ReversibleActionObserver( private val snackbarHost: View, -) : Observer { +) : FlowCollector { - override fun onChanged(value: ReversibleAction?) { - if (value == null) { - return - } + override suspend fun emit(value: ReversibleAction) { val handle = value.handle val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG val snackbar = Snackbar.make(snackbarHost, value.stringResId, length) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleHandle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleHandle.kt index d3d6bc475..6095da3c1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleHandle.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleHandle.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug fun interface ReversibleHandle { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt index a85868857..a5e8c2d43 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt @@ -5,10 +5,9 @@ import androidx.core.graphics.Insets import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import java.util.LinkedList -class WindowInsetsDelegate( - private val listener: WindowInsetsListener, -) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener { +class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeListener { @JvmField var handleImeInsets: Boolean = false @@ -16,6 +15,7 @@ class WindowInsetsDelegate( @JvmField var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null + private val listeners = LinkedList() private var lastInsets: Insets? = null override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { @@ -29,7 +29,7 @@ class WindowInsetsDelegate( handledInsets.getInsets(WindowInsetsCompat.Type.systemBars()) } if (newInsets != lastInsets) { - listener.onWindowInsetsChanged(newInsets) + listeners.forEach { it.onWindowInsetsChanged(newInsets) } lastInsets = newInsets } return handledInsets @@ -52,6 +52,15 @@ class WindowInsetsDelegate( } } + fun addInsetsListener(listener: WindowInsetsListener) { + listeners.add(listener) + lastInsets?.let { listener.onWindowInsetsChanged(it) } + } + + fun removeInsetsListener(listener: WindowInsetsListener) { + listeners.remove(listener) + } + fun onViewCreated(view: View) { ViewCompat.setOnApplyWindowInsetsListener(view, this) view.addOnLayoutChangeListener(this) 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/ui/widgets/SlidingBottomNavigationView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SlidingBottomNavigationView.kt index b34a958bf..3a95fa398 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SlidingBottomNavigationView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SlidingBottomNavigationView.kt @@ -92,6 +92,14 @@ class SlidingBottomNavigationView @JvmOverloads constructor( ) } + fun showOrHide(show: Boolean) { + if (show) { + show() + } else { + hide() + } + } + private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) { currentAnimator = animate() .translationY(targetY) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/AcraScreenLogger.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/AcraScreenLogger.kt new file mode 100644 index 000000000..5d768b82f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/AcraScreenLogger.kt @@ -0,0 +1,50 @@ +package org.koitharu.kotatsu.core.util + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks +import org.acra.ACRA +import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks { + + private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT) + + override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) { + super.onFragmentAttached(fm, f, context) + ACRA.errorReporter.putCustomData(f.key(), "${time()}: ${f.arguments}") + } + + override fun onFragmentDetached(fm: FragmentManager, f: Fragment) { + super.onFragmentDetached(fm, f) + ACRA.errorReporter.removeCustomData(f.key()) + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + super.onActivityCreated(activity, savedInstanceState) + ACRA.errorReporter.putCustomData(activity.key(), "${time()}: ${activity.intent}") + (activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(this, true) + } + + override fun onActivityDestroyed(activity: Activity) { + super.onActivityDestroyed(activity) + ACRA.errorReporter.removeCustomData(activity.key()) + } + + private fun Activity.key() = "Activity[${javaClass.simpleName}]" + + private fun Fragment.key() = "Fragment[${javaClass.simpleName}]" + + private fun time() = timeFormat.format(Date()) +} 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 new file mode 100644 index 000000000..1fd768af1 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Event.kt @@ -0,0 +1,36 @@ +package org.koitharu.kotatsu.core.util + +import kotlinx.coroutines.flow.FlowCollector + +class Event( + private val data: T, +) { + private var isConsumed = false + + suspend fun consume(collector: FlowCollector) { + if (!isConsumed) { + collector.emit(data) + isConsumed = true + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Event<*> + + if (data != other.data) return false + return isConsumed == other.isConsumed + } + + override fun hashCode(): Int { + var result = data?.hashCode() ?: 0 + result = 31 * result + isConsumed.hashCode() + return result + } + + override fun toString(): String { + return "Event(data=$data, isConsumed=$isConsumed)" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/FlowLiveData.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/FlowLiveData.kt deleted file mode 100644 index 7b3693902..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/FlowLiveData.kt +++ /dev/null @@ -1,86 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import androidx.lifecycle.LiveData -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -private const val DEFAULT_TIMEOUT = 5_000L - -/** - * Similar to a CoroutineLiveData but optimized for using within infinite flows - */ -class FlowLiveData( - private val flow: Flow, - defaultValue: T, - context: CoroutineContext = EmptyCoroutineContext, - private val timeoutInMs: Long = DEFAULT_TIMEOUT, -) : LiveData(defaultValue) { - - private val scope = CoroutineScope(Dispatchers.Main.immediate + context + SupervisorJob(context[Job])) - private var job: Job? = null - private var cancellationJob: Job? = null - - override fun onActive() { - super.onActive() - cancellationJob?.cancel() - cancellationJob = null - if (job?.isActive == true) { - return - } - job = scope.launch { - flow.collect(Collector()) - } - } - - override fun onInactive() { - super.onInactive() - cancellationJob?.cancel() - cancellationJob = scope.launch(Dispatchers.Main.immediate) { - delay(timeoutInMs) - if (!hasActiveObservers()) { - job?.cancel() - job = null - } - } - } - - private inner class Collector : FlowCollector { - - private var previousValue: Any? = value - private val dispatcher = Dispatchers.Main.immediate - - override suspend fun emit(value: T) { - if (previousValue != value) { - previousValue = value - if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { - withContext(dispatcher) { - setValue(value) - } - } else { - setValue(value) - } - } - } - } -} - -fun Flow.asFlowLiveData( - context: CoroutineContext = EmptyCoroutineContext, - defaultValue: T, - timeoutInMs: Long = DEFAULT_TIMEOUT, -): LiveData = FlowLiveData(this, defaultValue, context, timeoutInMs) - -fun StateFlow.asFlowLiveData( - context: CoroutineContext = EmptyCoroutineContext, - timeoutInMs: Long = DEFAULT_TIMEOUT, -): LiveData = FlowLiveData(this, value, context, timeoutInMs) 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/SingleLiveEvent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/SingleLiveEvent.kt deleted file mode 100644 index 504690d6f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/SingleLiveEvent.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import androidx.annotation.AnyThread -import androidx.annotation.MainThread -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.EmptyCoroutineContext - -class SingleLiveEvent : LiveData() { - - private val pending = AtomicBoolean(false) - - override fun observe(owner: LifecycleOwner, observer: Observer) { - super.observe(owner) { - if (pending.compareAndSet(true, false)) { - observer.onChanged(it) - } - } - } - - override fun setValue(value: T) { - pending.set(true) - super.setValue(value) - } - - @MainThread - fun call(newValue: T) { - setValue(newValue) - } - - @AnyThread - fun postCall(newValue: T) { - postValue(newValue) - } - - suspend fun emitCall(newValue: T) { - val dispatcher = Dispatchers.Main.immediate - if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { - withContext(dispatcher) { - setValue(newValue) - } - } else { - setValue(newValue) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt index 8fba053eb..c55aaa121 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt @@ -5,7 +5,8 @@ import android.app.Activity class TaggedActivityResult( val tag: String, val result: Int, -) +) { -val TaggedActivityResult.isSuccess: Boolean - get() = this.result == Activity.RESULT_OK + val isSuccess: Boolean + get() = result == Activity.RESULT_OK +} 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..463a16ec5 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 @@ -15,6 +16,7 @@ import android.database.SQLException import android.graphics.Color import android.net.Uri import android.os.Build +import android.os.Bundle import android.provider.Settings import android.view.View import android.view.ViewPropertyAnimator @@ -42,7 +44,6 @@ import org.jsoup.internal.StringUtil.StringJoiner import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException import kotlin.math.roundToLong @@ -140,13 +141,24 @@ fun Context.isLowRamDevice(): Boolean { return activityManager?.isLowRamDevice ?: false } -fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation( - view, - 0, - 0, - view.width, - view.height, -) +val Context.ramAvailable: Long + get() { + val result = MemoryInfo() + activityManager?.getMemoryInfo(result) + return result.availMem + } + +fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) { + ActivityOptions.makeScaleUpAnimation( + view, + 0, + 0, + view.width, + view.height, + ).toBundle() +} else { + null +} fun Resources.getLocalesConfig(): LocaleListCompat { val tagsList = StringJoiner(",") 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/EventFlow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/EventFlow.kt new file mode 100644 index 000000000..11fc25beb --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/EventFlow.kt @@ -0,0 +1,18 @@ +package org.koitharu.kotatsu.core.util.ext + +import androidx.annotation.AnyThread +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.core.util.Event + +@Suppress("FunctionName") +fun MutableEventFlow() = MutableStateFlow?>(null) + +typealias EventFlow = StateFlow?> + +typealias MutableEventFlow = MutableStateFlow?> + +@AnyThread +fun MutableEventFlow.call(data: T) { + value = Event(data) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt index ac98e2b48..79877887b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt @@ -26,6 +26,8 @@ fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() } fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } +fun File.isNotEmpty() = length() != 0L + fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use { it.readText() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt index 3db3bb15e..2aa0c1e62 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transformLatest +import org.koitharu.kotatsu.R fun Flow.onFirst(action: suspend (T) -> Unit): Flow { var isFirstCall = true @@ -52,3 +53,12 @@ fun Flow>.flatten(): Flow = flow { } } } + +fun Flow.zipWithPrevious(): Flow> = flow { + var previous: T? = null + collect { value -> + val result = previous to value + previous = value + emit(result) + } +} 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 new file mode 100644 index 000000000..d6c634468 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/FlowObserver.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.core.util.ext + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.core.util.Event + +fun Flow.observe(owner: LifecycleOwner, collector: FlowCollector) { + val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT + owner.lifecycleScope.launch(start = start) { + collect(collector) + } +} + +fun Flow?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector) { + owner.lifecycleScope.launch { + owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + collect { + it?.consume(collector) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt index d755911aa..aaa71435f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt @@ -52,3 +52,11 @@ suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner { } } } + +fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) { + val existing = fm.findFragmentByTag(tag) as? DialogFragment? + if (existing != null && existing.isVisible && existing.arguments == this.arguments) { + return + } + show(fm, tag) +} 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/LiveData.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LiveData.kt deleted file mode 100644 index ed8d96b62..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LiveData.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.core.util.BufferedObserver -import kotlin.coroutines.EmptyCoroutineContext - -fun LiveData.requireValue(): T = checkNotNull(value) { - "LiveData value is null" -} - -fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver) { - var previous: T? = null - this.observe(owner) { - observer.onChanged(it, previous) - previous = it - } -} - -suspend fun MutableLiveData.emitValue(newValue: T) { - val dispatcher = Dispatchers.Main.immediate - if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { - withContext(dispatcher) { - value = newValue - } - } else { - value = newValue - } -} 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/String.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt index bc43c656e..b0124dd74 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt @@ -2,10 +2,9 @@ package org.koitharu.kotatsu.core.util.ext import androidx.annotation.FloatRange import org.koitharu.kotatsu.parsers.util.levenshteinDistance -import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.util.UUID -inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { +inline fun C?.ifNullOrEmpty(defaultValue: () -> C): C { return if (this.isNullOrEmpty()) defaultValue() else this } @@ -35,3 +34,9 @@ fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float) val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f) return diff < threshold } + +fun CharSequence.sanitize(): CharSequence { + return filterNot { c -> c.isReplacement() } +} + +fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF' 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/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index 3623d56d1..29defe131 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -87,3 +87,6 @@ fun Throwable.isWebViewUnavailable(): Boolean { return (this is AndroidRuntimeException && message?.contains("WebView") == true) || cause?.isWebViewUnavailable() == true } + +@Suppress("FunctionName") +fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) 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/domain/DetailsInteractor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt new file mode 100644 index 000000000..350c66772 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt @@ -0,0 +1,80 @@ +package org.koitharu.kotatsu.details.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.details.domain.model.DoubleManga +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.local.domain.model.LocalManga +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import javax.inject.Inject + +@Deprecated("") +class DetailsInteractor @Inject constructor( + private val historyRepository: HistoryRepository, + private val favouritesRepository: FavouritesRepository, + private val localMangaRepository: LocalMangaRepository, + private val trackingRepository: TrackingRepository, + private val settings: AppSettings, + private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, +) { + + fun observeIsFavourite(mangaId: Long): Flow { + return favouritesRepository.observeCategoriesIds(mangaId) + .map { it.isNotEmpty() } + } + + fun observeNewChapters(mangaId: Long): Flow { + return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled } + .flatMapLatest { isEnabled -> + if (isEnabled) { + trackingRepository.observeNewChaptersCount(mangaId) + } else { + flowOf(0) + } + } + } + + fun observeScrobblingInfo(mangaId: Long): Flow> { + return combine( + scrobblers.map { it.observeScrobblingInfo(mangaId) }, + ) { scrobblingInfo -> + scrobblingInfo.filterNotNull() + } + } + + fun observeIncognitoMode(mangaFlow: Flow): Flow { + return mangaFlow + .distinctUntilChangedBy { it?.isNsfw } + .flatMapLatest { manga -> + if (manga != null) { + historyRepository.observeShouldSkip(manga) + } else { + settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled } + } + } + } + + suspend fun updateLocal(subject: DoubleManga?, localManga: LocalManga): DoubleManga? { + return if (subject?.any?.id == localManga.manga.id) { + subject.copy( + localManga = runCatchingCancellable { + localMangaRepository.getDetails(localManga.manga) + }, + ) + } else { + subject + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DoubleMangaLoadUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DoubleMangaLoadUseCase.kt new file mode 100644 index 000000000..143d1ae24 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DoubleMangaLoadUseCase.kt @@ -0,0 +1,65 @@ +package org.koitharu.kotatsu.details.domain + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.details.domain.model.DoubleManga +import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.parsers.exception.NotFoundException +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import javax.inject.Inject + +class DoubleMangaLoadUseCase @Inject constructor( + private val mangaDataRepository: MangaDataRepository, + private val localMangaRepository: LocalMangaRepository, + private val mangaRepositoryFactory: MangaRepository.Factory, +) { + + suspend operator fun invoke(manga: Manga): DoubleManga = coroutineScope { + val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) } + val localDeferred = async(Dispatchers.Default) { loadLocal(manga) } + DoubleManga( + remoteManga = remoteDeferred.await(), + localManga = localDeferred.await(), + ) + } + + suspend operator fun invoke(mangaId: Long): DoubleManga { + val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE() + return invoke(manga) + } + + suspend operator fun invoke(intent: MangaIntent): DoubleManga { + val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE() + return invoke(manga) + } + + private suspend fun loadLocal(manga: Manga): Result? { + return runCatchingCancellable { + if (manga.isLocal) { + localMangaRepository.getDetails(manga) + } else { + localMangaRepository.findSavedManga(manga)?.manga + } ?: return null + } + } + + private suspend fun loadRemote(manga: Manga): Result? { + return runCatchingCancellable { + val seed = if (manga.isLocal) { + localMangaRepository.getRemoteManga(manga) + } else { + manga + } ?: return null + val repository = mangaRepositoryFactory.create(seed.source) + repository.getDetails(seed) + } + } + + private fun throwNFE(): Nothing = throw NotFoundException("Cannot find manga", "") +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/model/DoubleManga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/model/DoubleManga.kt new file mode 100644 index 000000000..732f59902 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/model/DoubleManga.kt @@ -0,0 +1,76 @@ +package org.koitharu.kotatsu.details.domain.model + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.reader.data.filterChapters + +data class DoubleManga( + private val remoteManga: Result?, + private val localManga: Result?, +) { + + constructor(manga: Manga) : this( + remoteManga = if (manga.source != MangaSource.LOCAL) Result.success(manga) else null, + localManga = if (manga.source == MangaSource.LOCAL) Result.success(manga) else null, + ) + + val remote: Manga? + get() = remoteManga?.getOrNull() + + val local: Manga? + get() = localManga?.getOrNull() + + val any: Manga? + get() = remote ?: local + + val hasRemote: Boolean + get() = remoteManga?.isSuccess == true + + val hasLocal: Boolean + get() = localManga?.isSuccess == true + + val chapters: List? by lazy(LazyThreadSafetyMode.PUBLICATION) { + mergeChapters() + } + + fun requireAny(): Manga { + val result = remoteManga?.getOrNull() ?: localManga?.getOrNull() + if (result != null) { + return result + } + throw ( + remoteManga?.exceptionOrNull() + ?: localManga?.exceptionOrNull() + ?: IllegalStateException("No online either local manga available") + ) + } + + fun filterChapters(branch: String?) = DoubleManga( + remoteManga?.map { it.filterChapters(branch) }, + localManga?.map { it.filterChapters(branch) }, + ) + + private fun mergeChapters(): List? { + val remoteChapters = remote?.chapters + val localChapters = local?.chapters + if (localChapters == null && remoteChapters == null) { + return null + } + val localMap = if (!localChapters.isNullOrEmpty()) { + localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } + } else { + null + } + val result = ArrayList(maxOf(remoteChapters?.size ?: 0, localChapters?.size ?: 0)) + remoteChapters?.forEach { r -> + localMap?.remove(r.id)?.let { l -> + result.add(l) + } ?: result.add(r) + } + localMap?.values?.let { + result.addAll(it) + } + return result + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt index 4a549c6e7..6c483d475 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt @@ -10,12 +10,12 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import javax.inject.Inject @AndroidEntryPoint @@ -116,7 +116,7 @@ class MangaPrefetchService : CoroutineIntentService() { return false } val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java) - return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled() + return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ButtonTip.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ButtonTip.kt new file mode 100644 index 000000000..cc6a94502 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ButtonTip.kt @@ -0,0 +1,92 @@ +package org.koitharu.kotatsu.details.ui + +import android.transition.TransitionManager +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.graphics.Insets +import androidx.core.view.setMargins +import androidx.core.view.updateLayoutParams +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate +import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelSize +import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled +import org.koitharu.kotatsu.databinding.ItemTipBinding +import com.google.android.material.R as materialR + +class ButtonTip( + private val root: ViewGroup, + private val insetsDelegate: WindowInsetsDelegate, + private val viewModel: DetailsViewModel, +) : View.OnClickListener, WindowInsetsDelegate.WindowInsetsListener { + + private var selfBinding = ItemTipBinding.inflate(LayoutInflater.from(root.context), root, false) + private val actionBarSize = root.context.getThemeDimensionPixelSize(materialR.attr.actionBarSize) + + init { + selfBinding.textView.setText(R.string.details_button_tip) + selfBinding.imageViewIcon.setImageResource(R.drawable.ic_tap) + selfBinding.root.id = R.id.layout_tip + selfBinding.buttonClose.setOnClickListener(this) + } + + override fun onClick(v: View?) { + remove() + } + + override fun onWindowInsetsChanged(insets: Insets) { + if (root is CoordinatorLayout) { + selfBinding.root.updateLayoutParams { + bottomMargin = topMargin + insets.bottom + insets.top + actionBarSize + } + } + } + + fun addToRoot() { + val lp: ViewGroup.LayoutParams = when (root) { + is CoordinatorLayout -> CoordinatorLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + // anchorId = R.id.layout_bottom + // anchorGravity = Gravity.TOP + gravity = Gravity.BOTTOM + setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal)) + bottomMargin += actionBarSize + } + + is ConstraintLayout -> ConstraintLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + width = root.resources.getDimensionPixelSize(R.dimen.m3_side_sheet_width) + setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal)) + } + + else -> ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + root.addView(selfBinding.root, lp) + if (root is ConstraintLayout) { + val cs = ConstraintSet() + cs.clone(root) + cs.connect(R.id.layout_tip, ConstraintSet.TOP, R.id.appbar, ConstraintSet.BOTTOM) + cs.connect(R.id.layout_tip, ConstraintSet.START, R.id.card_chapters, ConstraintSet.START) + cs.connect(R.id.layout_tip, ConstraintSet.END, R.id.card_chapters, ConstraintSet.END) + cs.applyTo(root) + } + insetsDelegate.addInsetsListener(this) + } + + fun remove() { + if (root.context.isAnimationsEnabled) { + TransitionManager.beginDelayedTransition(root) + } + insetsDelegate.removeInsetsListener(this) + root.removeView(selfBinding.root) + viewModel.onButtonTipClosed() + } +} 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 e82993114..d1b6aa21d 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 @@ -16,6 +17,7 @@ import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter @@ -23,7 +25,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderState import kotlin.math.roundToInt @@ -55,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) @@ -74,12 +79,11 @@ class ChaptersFragment : return } startActivity( - ReaderActivity.newIntent( - context = view.context, - manga = viewModel.manga.value ?: return, - state = ReaderState(item.chapter.id, 0, 0), - ), - scaleUpActivityOptionsOf(view).toBundle(), + IntentBuilder(view.context) + .manga(viewModel.manga.value ?: return) + .state(ReaderState(item.chapter.id, 0, 0)) + .build(), + scaleUpActivityOptionsOf(view), ) } @@ -160,12 +164,14 @@ class ChaptersFragment : val selectedIds = selectionController?.peekCheckedIds() ?: return false val allItems = chaptersAdapter?.items.orEmpty() val items = allItems.withIndex().filter { (_, x) -> x.chapter.id in selectedIds } - menu.findItem(R.id.action_save).isVisible = items.none { (_, x) -> - x.chapter.source == MangaSource.LOCAL - } - menu.findItem(R.id.action_delete).isVisible = items.all { (_, x) -> - x.chapter.source == MangaSource.LOCAL + var canSave = true + var canDelete = true + items.forEach { (_, x) -> + val isLocal = x.isDownloaded || x.chapter.source == MangaSource.LOCAL + if (isLocal) canSave = false else canDelete = false } + menu.findItem(R.id.action_save).isVisible = canSave + menu.findItem(R.id.action_delete).isVisible = canDelete menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size menu.findItem(R.id.action_mark_current).isVisible = items.size == 1 mode.title = items.size.toString() 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 618d623e1..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()) { @@ -52,7 +57,8 @@ fun mapChapters( isCurrent = chapter.id == currentId, isUnread = isUnread, isNew = false, - isDownloaded = 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 f090a47fa..323a0d797 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,21 +19,29 @@ 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 androidx.lifecycle.Observer +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 +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.filterNotNull 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.os.ShortcutsUpdater +import org.koitharu.kotatsu.core.os.AppShortcutManager 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 import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivityDetailsBinding @@ -43,26 +53,25 @@ import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet +import java.lang.ref.WeakReference 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 + lateinit var appShortcutManager: AppShortcutManager private lateinit var viewBadge: ViewBadge + private var buttonTip: WeakReference? = null private val viewModel: DetailsViewModel by viewModels() private lateinit var chaptersMenuProvider: ChaptersMenuProvider @@ -79,21 +88,27 @@ 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.observe(this, ::onMangaUpdated) + viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) - viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) - viewModel.onError.observe( + viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) + viewModel.onError.observeEvent( this, SnackbarErrorObserver( host = viewBinding.containerDetails, @@ -106,16 +121,17 @@ class DetailsActivity : }, ), ) - viewModel.onShowToast.observe(this) { + viewModel.onShowToast.observeEvent(this) { makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show() } + viewModel.onShowTip.observeEvent(this) { showTip() } viewModel.historyInfo.observe(this, ::onHistoryChanged) - viewModel.selectedBranchName.observe(this) { - viewBinding.headerChapters?.subtitle = it + viewModel.selectedBranch.observe(this) { + 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() @@ -124,17 +140,20 @@ class DetailsActivity : viewBinding.buttonDropdown.isVisible = it.size > 1 } viewModel.chapters.observe(this, PrefetchObserver(this)) - viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(viewBinding.containerDetails)) + viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.containerDetails)) addMenuProvider( DetailsMenuProvider( activity = this, viewModel = viewModel, snackbarHost = viewBinding.containerChapters, - shortcutsUpdater = shortcutsUpdater, + appShortcutManager = appShortcutManager, ), ) - viewBinding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider) + } + + override fun getBottomSheetCollapsedHeight(): Int { + return viewBinding.layoutBsHeader?.measureHeight() ?: 0 } override fun onClick(v: View) { @@ -146,6 +165,8 @@ class DetailsActivity : override fun onLongClick(v: View): Boolean = when (v.id) { R.id.button_read -> { + buttonTip?.get()?.remove() + buttonTip = null val menu = PopupMenu(v.context, v) menu.inflate(R.menu.popup_read) menu.setOnMenuItemClickListener(this) @@ -165,12 +186,12 @@ class DetailsActivity : } R.id.action_pages_thumbs -> { - val history = viewModel.historyInfo.value?.history + val history = viewModel.historyInfo.value.history PagesThumbnailsSheet.show( fm = supportFragmentManager, manga = viewModel.manga.value ?: return false, chapterId = history?.chapterId - ?: viewModel.chapters.value?.firstOrNull()?.chapter?.id + ?: viewModel.chapters.value.firstOrNull()?.chapter?.id ?: return false, currentPage = history?.page ?: 0, ) @@ -181,11 +202,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 } @@ -216,6 +245,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.top + } } private fun onHistoryChanged(info: HistoryInfo) { @@ -234,7 +269,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 } @@ -253,25 +288,24 @@ class DetailsActivity : .setCancelable(true) .setNegativeButton(android.R.string.cancel, null) .setTitle(R.string.translations) - .setItems(viewModel.branches.value.orEmpty()) + .setItems(viewModel.branches.value) .create() .also { it.show() } } private fun openReader(isIncognitoMode: Boolean) { val manga = viewModel.manga.value ?: return - val chapterId = viewModel.historyInfo.value?.history?.chapterId + val chapterId = viewModel.historyInfo.value.history?.chapterId if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT) snackbar.show() } else { startActivity( - ReaderActivity.newIntent( - context = this, - manga = manga, - branch = viewModel.selectedBranchValue, - isIncognitoMode = isIncognitoMode, - ), + IntentBuilder(this) + .manga(manga) + .branch(viewModel.selectedBranchValue) + .incognito(isIncognitoMode) + .build(), ) if (isIncognitoMode) { Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show() @@ -279,8 +313,6 @@ class DetailsActivity : } } - private fun isTabletLayout() = viewBinding.layoutBottom == null - private fun showBottomSheet(isVisible: Boolean) { val view = viewBinding.layoutBottom ?: return if (view.isVisible == isVisible) return @@ -294,18 +326,18 @@ 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 } private class PrefetchObserver( private val context: Context, - ) : Observer?> { + ) : FlowCollector?> { private var isCalled = false - override fun onChanged(value: List?) { + override suspend fun emit(value: List?) { if (value.isNullOrEmpty()) { return } @@ -317,8 +349,16 @@ class DetailsActivity : } } + private fun showTip() { + val tip = ButtonTip(viewBinding.root as ViewGroup, insetsDelegate, viewModel) + tip.addToRoot() + buttonTip = WeakReference(tip) + } + companion object { + const val TIP_BUTTON = "btn_read" + fun newIntent(context: Context, manga: Manga): Intent { return Intent(context, DetailsActivity::class.java) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) 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 875e7fb7a..8272c3b2e 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 @@ -18,11 +18,11 @@ import coil.request.ImageRequest import coil.util.CoilUtils import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.filterNotNull import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.core.model.countChaptersByBranch -import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener @@ -33,7 +33,7 @@ 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 import org.koitharu.kotatsu.core.util.ext.textAndVisible @@ -42,8 +42,9 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.image.ui.ImageActivity +import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -66,7 +67,7 @@ class DetailsFragment : lateinit var coil: ImageLoader @Inject - lateinit var tagHighlighter: MangaTagHighlighter + lateinit var tagHighlighter: ListExtraProvider private val viewModel by activityViewModels() @@ -82,7 +83,8 @@ class DetailsFragment : binding.infoLayout.textViewSource.setOnClickListener(this) binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.chipsTags.onChipClickListener = this - viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) + TitleScrollCoordinator(binding.textViewTitle).attach(binding.scrollView) + viewModel.manga.filterNotNull().observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) @@ -94,8 +96,8 @@ class DetailsFragment : override fun onItemClick(item: Bookmark, view: View) { startActivity( - ReaderActivity.newIntent(view.context, item), - scaleUpActivityOptionsOf(view).toBundle(), + ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(), + scaleUpActivityOptionsOf(view), ) Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() } @@ -255,7 +257,7 @@ class DetailsFragment : manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }, manga.source, ), - scaleUpActivityOptionsOf(v).toBundle(), + scaleUpActivityOptionsOf(v), ) } } @@ -269,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, @@ -281,7 +283,8 @@ class DetailsFragment : manga.tags.map { tag -> ChipsView.ChipModel( title = tag.title, - tint = tagHighlighter.getTint(tag), + tint = tagHighlighter.getTagTint(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..a289f55c2 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 @@ -15,21 +15,21 @@ import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.BrowserActivity -import org.koitharu.kotatsu.core.os.ShortcutsUpdater +import org.koitharu.kotatsu.core.os.AppShortcutManager 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( private val activity: FragmentActivity, private val viewModel: DetailsViewModel, private val snackbarHost: View, - private val shortcutsUpdater: ShortcutsUpdater, + private val appShortcutManager: AppShortcutManager, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -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,14 +105,14 @@ class DetailsMenuProvider( R.id.action_scrobbling -> { viewModel.manga.value?.let { - ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it, null) + ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null) } } R.id.action_shortcut -> { viewModel.manga.value?.let { activity.lifecycleScope.launch { - if (!shortcutsUpdater.requestPinShortcut(it)) { + if (!appShortcutManager.requestPinShortcut(it)) { Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) .show() } 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 0d3101f28..2a7f0ae5d 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 @@ -7,9 +7,7 @@ import android.text.style.ForegroundColorSpan import androidx.core.net.toUri import androidx.core.text.getSpans import androidx.core.text.parseAsHtml -import androidx.lifecycle.LiveData -import androidx.lifecycle.asFlow -import androidx.lifecycle.asLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -17,190 +15,178 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository +import org.koitharu.kotatsu.core.model.getPreferredBranch +import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.computeSize +import org.koitharu.kotatsu.core.util.ext.requireValue +import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.details.domain.BranchComparator +import org.koitharu.kotatsu.details.domain.DetailsInteractor +import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase +import org.koitharu.kotatsu.details.domain.model.DoubleManga import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import org.koitharu.kotatsu.util.ext.printStackTraceDebug -import java.io.IOException import javax.inject.Inject @HiltViewModel class DetailsViewModel @Inject constructor( private val historyRepository: HistoryRepository, - favouritesRepository: FavouritesRepository, - private val localMangaRepository: LocalMangaRepository, - trackingRepository: TrackingRepository, private val bookmarksRepository: BookmarksRepository, private val settings: AppSettings, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val imageGetter: Html.ImageGetter, - private val delegate: MangaDetailsDelegate, @LocalStorageChanges private val localStorageChanges: SharedFlow, private val downloadScheduler: DownloadWorker.Scheduler, + private val interactor: DetailsInteractor, + savedStateHandle: SavedStateHandle, + private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase, ) : BaseViewModel() { + private val intent = MangaIntent(savedStateHandle) + private val mangaId = intent.mangaId + private val doubleManga: MutableStateFlow = MutableStateFlow(intent.manga?.let { DoubleManga(it) }) private var loadingJob: Job - val onShowToast = SingleLiveEvent() - val onDownloadStarted = SingleLiveEvent() + val onShowToast = MutableEventFlow() + val onShowTip = MutableEventFlow() + val onDownloadStarted = MutableEventFlow() - private val mangaData = combine( - delegate.onlineManga, - delegate.localManga, - ) { o, l -> - o ?: l - }.stateIn(viewModelScope, SharingStarted.Lazily, null) + val manga = doubleManga.map { it?.any } + .stateIn(viewModelScope, SharingStarted.Eagerly, doubleManga.value?.any) - private val history = historyRepository.observeOne(delegate.mangaId) + val history = historyRepository.observeOne(mangaId) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() } + val favouriteCategories = interactor.observeIsFavourite(mangaId) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - private val newChapters = settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled } - .flatMapLatest { isEnabled -> - if (isEnabled) { - trackingRepository.observeNewChaptersCount(delegate.mangaId) - } else { - flowOf(0) - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) + val newChaptersCount = interactor.observeNewChapters(mangaId) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) private val chaptersQuery = MutableStateFlow("") + val selectedBranch = MutableStateFlow(null) - private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - - val manga = mangaData.filterNotNull().asLiveData(viewModelScope.coroutineContext) - val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext) - val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext) - val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext) + val isChaptersReversed = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_REVERSE_CHAPTERS, + valueProducer = { chaptersReverse }, + ) - val historyInfo: LiveData = combine( - mangaData, - delegate.selectedBranch, + val historyInfo: StateFlow = combine( + manga, + selectedBranch, history, - historyRepository.observeShouldSkip(mangaData), + interactor.observeIncognitoMode(manga), ) { m, b, h, im -> HistoryInfo(m, b, h, im) - }.asFlowLiveData( - context = viewModelScope.coroutineContext + Dispatchers.Default, - defaultValue = HistoryInfo(null, null, null, false), + }.stateIn( + scope = viewModelScope + Dispatchers.Default, + started = SharingStarted.Eagerly, + initialValue = HistoryInfo(null, null, null, false), ) - val bookmarks = mangaData.flatMapLatest { + val bookmarks = manga.flatMapLatest { if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) - val localSize = delegate.localManga + val localSize = doubleManga .map { - if (it != null) { - val file = it.url.toUri().toFileOrNull() + val local = it?.local + if (local != null) { + val file = local.url.toUri().toFileOrNull() file?.computeSize() ?: 0L } else { 0L } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, 0) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0) - val description = mangaData + val description = manga .distinctUntilChangedBy { it?.description.orEmpty() } .transformLatest { val description = it?.description if (description.isNullOrEmpty()) { emit(null) } else { - emit(description.parseAsHtml().filterSpans()) + emit(description.parseAsHtml().filterSpans().sanitize()) emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans()) } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null) - val onMangaRemoved = SingleLiveEvent() + val onMangaRemoved = MutableEventFlow() val isScrobblingAvailable: Boolean get() = scrobblers.any { it.isAvailable } - val scrobblingInfo: LiveData> = combine( - scrobblers.map { it.observeScrobblingInfo(delegate.mangaId) }, - ) { scrobblingInfo -> - scrobblingInfo.filterNotNull() - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) - - val branches: LiveData> = combine( - delegate.onlineManga, - delegate.localManga, - delegate.selectedBranch, - ) { m, l, b -> - val chapters = concat(m?.chapters, l?.chapters) - if (chapters.isEmpty()) return@combine emptyList() + val scrobblingInfo: StateFlow> = interactor.observeScrobblingInfo(mangaId) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + + val branches: StateFlow> = combine( + doubleManga, + selectedBranch, + ) { m, b -> + val chapters = m?.chapters + if (chapters.isNullOrEmpty()) return@combine emptyList() chapters.groupBy { x -> x.branch } .map { x -> MangaBranch(x.key, x.value.size, x.key == b) } .sortedWith(BranchComparator()) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) - - val selectedBranchName = delegate.selectedBranch - .asFlowLiveData(viewModelScope.coroutineContext, null) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - val isChaptersEmpty: LiveData = combine( - delegate.onlineManga, - delegate.localManga, - isLoading.asFlow(), - ) { manga, local, loading -> - (manga != null && manga.chapters.isNullOrEmpty()) && - (local != null && local.chapters.isNullOrEmpty()) && - !loading - }.asFlowLiveData(viewModelScope.coroutineContext, false) + val isChaptersEmpty: StateFlow = combine( + doubleManga, + isLoading, + ) { manga, loading -> + manga?.any != null && manga.chapters.isNullOrEmpty() && !loading + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) val chapters = combine( combine( - delegate.onlineManga, - delegate.localManga, + doubleManga, history, - delegate.selectedBranch, - newChapters, - ) { manga, local, history, branch, news -> - mapChapters(manga, local, history, news, branch) + selectedBranch, + newChaptersCount, + bookmarks, + ) { manga, history, branch, news, bookmarks -> + mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks) }, - chaptersReversed, + isChaptersReversed, chaptersQuery, ) { list, reversed, query -> (if (reversed) list.asReversed() else list).filterSearch(query) - }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) val selectedBranchValue: String? - get() = delegate.selectedBranch.value + get() = selectedBranch.value init { loadingJob = doLoad() @@ -208,6 +194,12 @@ class DetailsViewModel @Inject constructor( localStorageChanges .collect { onDownloadComplete(it) } } + launchJob(Dispatchers.Default) { + if (settings.isTipEnabled(DetailsActivity.TIP_BUTTON)) { + manga.filterNot { it?.chapters.isNullOrEmpty() }.first() + onShowTip.call(Unit) + } + } } fun reload() { @@ -216,26 +208,20 @@ class DetailsViewModel @Inject constructor( } fun deleteLocal() { - val m = delegate.localManga.value + val m = doubleManga.value?.local if (m == null) { onShowToast.call(R.string.file_not_found) return } launchLoadingJob(Dispatchers.Default) { - val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)?.manga - checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } - val original = localMangaRepository.getRemoteManga(manga) - localMangaRepository.delete(manga) || throw IOException("Unable to delete file") - runCatchingCancellable { - historyRepository.deleteOrSwap(manga, original) - } - onMangaRemoved.emitCall(manga) + deleteLocalMangaUseCase(m) + onMangaRemoved.call(m) } } fun removeBookmark(bookmark: Bookmark) { - launchJob { - bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId) + launchJob(Dispatchers.Default) { + bookmarksRepository.removeBookmark(bookmark) onShowToast.call(R.string.bookmark_removed) } } @@ -245,11 +231,7 @@ class DetailsViewModel @Inject constructor( } fun setSelectedBranch(branch: String?) { - delegate.selectedBranch.value = branch - } - - fun getRemoteManga(): Manga? { - return delegate.onlineManga.value + selectedBranch.value = branch } fun performChapterSearch(query: String?) { @@ -260,7 +242,7 @@ class DetailsViewModel @Inject constructor( val scrobbler = getScrobbler(index) ?: return launchJob(Dispatchers.Default) { scrobbler.updateScrobblingInfo( - mangaId = delegate.mangaId, + mangaId = mangaId, rating = rating, status = status, comment = null, @@ -272,34 +254,49 @@ class DetailsViewModel @Inject constructor( val scrobbler = getScrobbler(index) ?: return launchJob(Dispatchers.Default) { scrobbler.unregisterScrobbling( - mangaId = delegate.mangaId, + mangaId = mangaId, ) } } fun markChapterAsCurrent(chapterId: Long) { launchJob(Dispatchers.Default) { - val manga = checkNotNull(mangaData.value) - val chapters = checkNotNull(manga.getChapters(selectedBranchValue)) + val manga = checkNotNull(doubleManga.value) + val chapters = checkNotNull(manga.filterChapters(selectedBranchValue).chapters) val chapterIndex = chapters.indexOfFirst { it.id == chapterId } check(chapterIndex in chapters.indices) { "Chapter not found" } val percent = chapterIndex / chapters.size.toFloat() - historyRepository.addOrUpdate(manga = manga, chapterId = chapterId, page = 0, scroll = 0, percent = percent) + historyRepository.addOrUpdate( + manga = manga.requireAny(), + chapterId = chapterId, + page = 0, + scroll = 0, + percent = percent, + ) } } fun download(chaptersIds: Set?) { launchJob(Dispatchers.Default) { downloadScheduler.schedule( - delegate.onlineManga.value ?: checkNotNull(manga.value), + doubleManga.requireValue().requireAny(), chaptersIds, ) - onDownloadStarted.emitCall(Unit) + onDownloadStarted.call(Unit) } } + fun onButtonTipClosed() { + settings.closeTip(DetailsActivity.TIP_BUTTON) + } + private fun doLoad() = launchLoadingJob(Dispatchers.Default) { - delegate.doLoad() + val result = doubleMangaLoadUseCase(intent) + val manga = result.requireAny() + // find default branch + val hist = historyRepository.getOne(manga) + selectedBranch.value = manga.getPreferredBranch(hist) + doubleManga.value = result } private fun List.filterSearch(query: String): List { @@ -313,21 +310,9 @@ class DetailsViewModel @Inject constructor( private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { downloadedManga ?: return - val currentManga = mangaData.value ?: return - if (currentManga.id != downloadedManga.manga.id) { - return - } - if (currentManga.source == MangaSource.LOCAL) { - reload() - } else { - viewModelScope.launch(Dispatchers.Default) { - runCatchingCancellable { - localMangaRepository.getDetails(downloadedManga.manga) - }.onSuccess { - delegate.publishManga(it) - }.onFailure { - it.printStackTraceDebug() - } + launchJob { + doubleManga.update { + interactor.updateLocal(it, downloadedManga) } } } @@ -342,7 +327,7 @@ class DetailsViewModel @Inject constructor( } private fun getScrobbler(index: Int): Scrobbler? { - val info = scrobblingInfo.value?.getOrNull(index) + val info = scrobblingInfo.value.getOrNull(index) val scrobbler = if (info != null) { scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable } } else { @@ -353,18 +338,4 @@ class DetailsViewModel @Inject constructor( } return scrobbler } - - private fun concat(a: List?, b: List?): List { - return when { - a == null && b == null -> emptyList() - a == null && b != null -> b - a != null && b == null -> a - a != null && b != null -> buildList(a.size + b.size) { - addAll(a) - addAll(b) - } - - else -> error("This shouldn't have happened") - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt deleted file mode 100644 index eee5e04e6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import androidx.lifecycle.SavedStateHandle -import dagger.hilt.android.ViewModelLifecycle -import dagger.hilt.android.scopes.ViewModelScoped -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import org.koitharu.kotatsu.core.model.getPreferredBranch -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope -import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.local.domain.LocalMangaRepository -import org.koitharu.kotatsu.parsers.exception.NotFoundException -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug -import javax.inject.Inject - -@ViewModelScoped -class MangaDetailsDelegate @Inject constructor( - savedStateHandle: SavedStateHandle, - lifecycle: ViewModelLifecycle, - private val mangaDataRepository: MangaDataRepository, - private val historyRepository: HistoryRepository, - private val localMangaRepository: LocalMangaRepository, - private val mangaRepositoryFactory: MangaRepository.Factory, - networkState: NetworkState, -) { - private val viewModelScope = RetainedLifecycleCoroutineScope(lifecycle) - - private val intent = MangaIntent(savedStateHandle) - private val onlineMangaStateFlow = MutableStateFlow(null) - private val localMangaStateFlow = MutableStateFlow(null) - - val onlineManga = combine( - onlineMangaStateFlow, - networkState, - ) { m, s -> m.takeIf { s } } - .stateIn(viewModelScope, SharingStarted.Lazily, null) - val localManga = localMangaStateFlow.asStateFlow() - - val selectedBranch = MutableStateFlow(null) - val mangaId = intent.manga?.id ?: intent.mangaId - - init { - intent.manga?.let { - publishManga(it) - } - } - - suspend fun doLoad() { - var manga = mangaDataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "") - publishManga(manga) - manga = mangaRepositoryFactory.create(manga.source).getDetails(manga) - // find default branch - val hist = historyRepository.getOne(manga) - selectedBranch.value = manga.getPreferredBranch(hist) - publishManga(manga) - runCatchingCancellable { - if (manga.source == MangaSource.LOCAL) { - val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null - mangaRepositoryFactory.create(m.source).getDetails(m) - } else { - localMangaRepository.findSavedManga(manga)?.manga - } - }.onFailure { error -> - error.printStackTraceDebug() - }.onSuccess { - if (it != null) { - publishManga(it) - } - } - } - - fun publishManga(manga: Manga) { - if (manga.source == MangaSource.LOCAL) { - localMangaStateFlow - } else { - onlineMangaStateFlow - }.value = manga - } -} 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 85% 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 96950f5ad..faa310f6b 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,10 +17,13 @@ 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 +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetScrobblingBinding @@ -28,12 +31,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, @@ -59,7 +62,7 @@ class ScrobblingInfoBottomSheet : override fun onViewBindingCreated(binding: SheetScrobblingBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) - viewModel.onError.observe(viewLifecycleOwner) { + viewModel.onError.observeEvent(viewLifecycleOwner) { Toast.makeText(binding.root.context, it.getDisplayMessage(binding.root.resources), Toast.LENGTH_SHORT) .show() } @@ -72,7 +75,7 @@ class ScrobblingInfoBottomSheet : menu = PopupMenu(binding.root.context, binding.buttonMenu).apply { inflate(R.menu.opt_scrobbling) - setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet) + setOnMenuItemClickListener(this@ScrobblingInfoSheet) } } @@ -105,9 +108,9 @@ class ScrobblingInfoBottomSheet : when (v.id) { R.id.button_menu -> menu?.show() R.id.imageView_cover -> { - val coverUrl = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.coverUrl ?: return + val coverUrl = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return val options = scaleUpActivityOptionsOf(v) - startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options.toBundle()) + startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options) } } } @@ -120,7 +123,7 @@ class ScrobblingInfoBottomSheet : } requireViewBinding().textViewTitle.text = scrobbling.title requireViewBinding().ratingBar.rating = scrobbling.rating * requireViewBinding().ratingBar.numStars - requireViewBinding().textViewDescription.text = scrobbling.description + requireViewBinding().textViewDescription.text = scrobbling.description?.sanitize() requireViewBinding().spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1) requireViewBinding().imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId) requireViewBinding().imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId) @@ -135,7 +138,7 @@ class ScrobblingInfoBottomSheet : override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_browser -> { - val url = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.externalUrl ?: return false + val url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.externalUrl ?: return false val intent = Intent(Intent.ACTION_VIEW, url.toUri()) startActivity( Intent.createChooser(intent, getString(R.string.open_in_browser)), @@ -149,8 +152,8 @@ 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) + val scrobblerService = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.scrobbler + ScrobblingSelectorSheet.show(parentFragmentManager, manga, scrobblerService) dismiss() } } @@ -162,7 +165,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/download/domain/DownloadState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt index fea793f6d..8adf4a953 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.download.domain import androidx.work.Data -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE -import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.history.data.PROGRESS_NONE +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import java.util.Date diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index 6fb8ecb9d..5a6128b75 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -11,14 +11,16 @@ import androidx.annotation.Px import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets import androidx.core.view.updatePadding -import androidx.lifecycle.Observer import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.ui.worker.PausingReceiver @@ -61,8 +63,8 @@ class DownloadsActivity : BaseActivity(), viewModel.items.observe(this) { downloadsAdapter.items = it } - viewModel.onActionDone.observe(this, ReversibleActionObserver(viewBinding.recyclerView)) - val menuObserver = Observer { _ -> invalidateOptionsMenu() } + viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView)) + val menuObserver = FlowCollector { _ -> invalidateOptionsMenu() } viewModel.hasActiveWorks.observe(this, menuObserver) viewModel.hasPausedWorks.observe(this, menuObserver) viewModel.hasCancellableWorks.observe(this, menuObserver) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt index 89428502e..3429ee2c9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt @@ -7,6 +7,7 @@ import android.view.MenuItem import androidx.core.view.MenuProvider import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.settings.SettingsActivity class DownloadsMenuProvider( private val context: Context, @@ -23,6 +24,10 @@ class DownloadsMenuProvider( R.id.action_resume -> viewModel.resumeAll() R.id.action_cancel_all -> confirmCancelAll() R.id.action_remove_completed -> confirmRemoveCompleted() + R.id.action_settings -> { + context.startActivity(SettingsActivity.newDownloadsSettingsIntent(context)) + } + else -> return false } return true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt index fb6e455ff..24b3dc68f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -20,8 +20,8 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.ui.worker.DownloadWorker @@ -47,23 +47,23 @@ class DownloadsViewModel @Inject constructor( .mapLatest { it.toDownloadsList() } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - val onActionDone = SingleLiveEvent() + val onActionDone = MutableEventFlow() val items = works.map { it?.toUiList() ?: listOf(LoadingState) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) val hasPausedWorks = works.map { it?.any { x -> x.canResume } == true - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false) val hasActiveWorks = works.map { it?.any { x -> x.canPause } == true - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false) val hasCancellableWorks = works.map { it?.any { x -> !x.workState.isFinished } == true - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false) fun cancel(id: UUID) { launchJob(Dispatchers.Default) { @@ -79,14 +79,14 @@ class DownloadsViewModel @Inject constructor( workScheduler.cancel(work.id) } } - onActionDone.emitCall(ReversibleAction(R.string.downloads_cancelled, null)) + onActionDone.call(ReversibleAction(R.string.downloads_cancelled, null)) } } fun cancelAll() { launchJob(Dispatchers.Default) { workScheduler.cancelAll() - onActionDone.emitCall(ReversibleAction(R.string.downloads_cancelled, null)) + onActionDone.call(ReversibleAction(R.string.downloads_cancelled, null)) } } @@ -146,14 +146,14 @@ class DownloadsViewModel @Inject constructor( workScheduler.delete(work.id) } } - onActionDone.emitCall(ReversibleAction(R.string.downloads_removed, null)) + onActionDone.call(ReversibleAction(R.string.downloads_removed, null)) } } fun removeCompleted() { launchJob(Dispatchers.Default) { workScheduler.removeCompleted() - onActionDone.emitCall(ReversibleAction(R.string.downloads_removed, null)) + onActionDone.call(ReversibleAction(R.string.downloads_removed, null)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index d7985849c..41568e832 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -32,7 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.util.UUID import com.google.android.material.R as materialR diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt index 10dbb2292..6c42b9275 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.download.ui.worker import android.view.View -import androidx.lifecycle.Observer import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.download.ui.list.DownloadsActivity @@ -10,9 +10,9 @@ import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner class DownloadStartedObserver( private val snackbarHost: View, -) : Observer { +) : FlowCollector { - override fun onChanged(value: Unit) { + override suspend fun emit(value: Unit) { val snackbar = Snackbar.make(snackbarHost, R.string.download_started, Snackbar.LENGTH_LONG) (snackbarHost.context.findActivity() as? BottomNavOwner)?.let { snackbar.anchorView = it.bottomNav diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 47cf46cf0..d1c18925a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -48,19 +48,19 @@ import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput -import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.io.File import java.util.UUID import java.util.concurrent.TimeUnit diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt index e3ea1c156..c91336c1c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt @@ -4,12 +4,12 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.almostEquals import org.koitharu.kotatsu.core.util.ext.asArrayList -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import javax.inject.Inject class ExploreRepository @Inject constructor( 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 d0159e851..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 @@ -25,6 +25,8 @@ import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter @@ -74,11 +76,11 @@ class ExploreFragment : viewModel.content.observe(viewLifecycleOwner) { exploreAdapter?.items = it } - viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) - viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) + 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/ExploreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 6ee8eb2b8..0f163d92f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -1,16 +1,17 @@ package org.koitharu.kotatsu.explore.ui -import androidx.lifecycle.LiveData -import androidx.lifecycle.asFlow 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.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings @@ -18,8 +19,8 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleHandle -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.parsers.model.Manga @@ -34,29 +35,28 @@ class ExploreViewModel @Inject constructor( private val exploreRepository: ExploreRepository, ) : BaseViewModel() { - private val gridMode = settings.observeAsStateFlow( + val isGrid = settings.observeAsStateFlow( key = AppSettings.KEY_SOURCES_GRID, scope = viewModelScope + Dispatchers.IO, valueProducer = { isSourcesGridMode }, ) - val onOpenManga = SingleLiveEvent() - val onActionDone = SingleLiveEvent() - val onShowSuggestionsTip = SingleLiveEvent() - val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext) + val onOpenManga = MutableEventFlow() + val onActionDone = MutableEventFlow() + val onShowSuggestionsTip = MutableEventFlow() - val content: LiveData> = isLoading.asFlow().flatMapLatest { loading -> + val content: StateFlow> = isLoading.flatMapLatest { loading -> if (loading) { flowOf(listOf(ExploreItem.Loading)) } else { createContentFlow() } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(ExploreItem.Loading)) init { launchJob(Dispatchers.Default) { if (!settings.isSuggestionsEnabled && settings.isTipEnabled(TIP_SUGGESTIONS)) { - onShowSuggestionsTip.emitCall(Unit) + onShowSuggestionsTip.call(Unit) } } } @@ -64,7 +64,7 @@ class ExploreViewModel @Inject constructor( fun openRandom() { launchLoadingJob(Dispatchers.Default) { val manga = exploreRepository.findRandomManga(tagsLimit = 8) - onOpenManga.emitCall(manga) + onOpenManga.call(manga) } } @@ -74,7 +74,7 @@ class ExploreViewModel @Inject constructor( val rollback = ReversibleHandle { settings.hiddenSources -= source.name } - onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback)) + onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) } } @@ -95,7 +95,7 @@ class ExploreViewModel @Inject constructor( } .onStart { emit("") } .map { settings.getMangaSources(includeHidden = false) } - .combine(gridMode) { content, grid -> buildList(content, grid) } + .combine(isGrid) { content, grid -> buildList(content, grid) } private fun buildList(sources: List, isGrid: Boolean): List { val result = ArrayList(sources.size + 3) 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/FavouriteCategoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt index 66901002d..e72e050fa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt @@ -24,6 +24,8 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.ListSelectionController +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.favourites.ui.FavouritesActivity @@ -71,7 +73,7 @@ class FavouriteCategoriesActivity : onBackPressedDispatcher.addCallback(exitReorderModeCallback) viewModel.detalizedCategories.observe(this, ::onCategoriesChanged) - viewModel.onError.observe(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) + viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.isInReorderMode.observe(this, ::onReorderModeChanged) } @@ -110,7 +112,7 @@ class FavouriteCategoriesActivity : } val intent = FavouritesActivity.newIntent(this, item) val options = scaleUpActivityOptionsOf(view) - startActivity(intent, options.toBundle()) + startActivity(intent, options) } override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt index e2057a28c..f7a397df0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt @@ -1,17 +1,17 @@ package org.koitharu.kotatsu.favourites.ui.categories -import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.asFlowLiveData -import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel @@ -27,23 +27,11 @@ class FavouritesCategoriesViewModel @Inject constructor( ) : BaseViewModel() { private var reorderJob: Job? = null - private val isReorder = MutableStateFlow(false) - - val isInReorderMode = isReorder.asLiveData(viewModelScope.coroutineContext) - - val allCategories = repository.observeCategories() - .mapItems { - CategoryListModel( - mangaCount = 0, - covers = listOf(), - category = it, - isReorderMode = false, - ) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + val isInReorderMode = MutableStateFlow(false) val detalizedCategories = combine( repository.observeCategoriesWithCovers(), - isReorder, + isInReorderMode, ) { list, reordering -> list.map { (category, covers) -> CategoryListModel( @@ -62,7 +50,7 @@ class FavouritesCategoriesViewModel @Inject constructor( ), ) } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) fun deleteCategory(id: Long) { launchJob { @@ -80,12 +68,12 @@ class FavouritesCategoriesViewModel @Inject constructor( settings.isAllFavouritesVisible = isVisible } - fun isInReorderMode(): Boolean = isReorder.value + fun isInReorderMode(): Boolean = isInReorderMode.value - fun isEmpty(): Boolean = detalizedCategories.value?.none { it is CategoryListModel } ?: true + fun isEmpty(): Boolean = detalizedCategories.value.none { it is CategoryListModel } fun setReorderMode(isReorderMode: Boolean) { - isReorder.value = isReorderMode + isInReorderMode.value = isReorderMode } fun reorderCategories(oldPos: Int, newPos: Int) { 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/edit/FavouritesCategoryEditActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt index d7980dfcb..3b055ddff 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt @@ -22,6 +22,8 @@ import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getSerializableCompat +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity @@ -50,10 +52,10 @@ class FavouritesCategoryEditActivity : viewBinding.editName.addTextChangedListener(this) afterTextChanged(viewBinding.editName.text) - viewModel.onSaved.observe(this) { finishAfterTransition() } + viewModel.onSaved.observeEvent(this) { finishAfterTransition() } viewModel.category.observe(this, ::onCategoryChanged) viewModel.isLoading.observe(this, ::onLoadingStateChanged) - viewModel.onError.observe(this, ::onError) + viewModel.onError.observeEvent(this, ::onError) viewModel.isTrackerEnabled.observe(this) { viewBinding.switchTracker.isVisible = it } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt index 2677f1cd0..2b32a3d5c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt @@ -1,16 +1,19 @@ package org.koitharu.kotatsu.favourites.ui.categories.edit -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.ext.emitValue +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.EXTRA_ID import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID @@ -26,22 +29,20 @@ class FavouritesCategoryEditViewModel @Inject constructor( private val categoryId = savedStateHandle[EXTRA_ID] ?: NO_ID - val onSaved = SingleLiveEvent() - val category = MutableLiveData() + val onSaved = MutableEventFlow() + val category = MutableStateFlow(null) - val isTrackerEnabled = liveData(viewModelScope.coroutineContext + Dispatchers.Default) { + val isTrackerEnabled = flow { emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources) - } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) init { launchLoadingJob(Dispatchers.Default) { - category.emitValue( - if (categoryId != NO_ID) { - repository.getCategory(categoryId) - } else { - null - }, - ) + category.value = if (categoryId != NO_ID) { + repository.getCategory(categoryId) + } else { + null + } } } @@ -58,7 +59,7 @@ class FavouritesCategoryEditViewModel @Inject constructor( } else { repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf) } - onSaved.emitCall(Unit) + onSaved.call(Unit) } } } 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 59% 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 2c9b5f4d5..102abab0d 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,32 +2,30 @@ 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.showDistinct 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() @@ -38,15 +36,15 @@ 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.observe(viewLifecycleOwner, ::onError) + viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) } override fun onDestroyView() { @@ -54,25 +52,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 } @@ -87,11 +71,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, + ) + }, + ) + }.showDistinct(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 32a7f0f11..5f6566ceb 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 @@ -4,14 +4,20 @@ import androidx.lifecycle.SavedStateHandle 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 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.core.util.asFlowLiveData +import org.koitharu.kotatsu.core.util.ext.require 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 @@ -20,20 +26,24 @@ class MangaCategoriesViewModel @Inject constructor( private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { - private val manga = requireNotNull(savedStateHandle.get>(KEY_MANGA_LIST)).map { it.manga } + private val manga = savedStateHandle.require>(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, + ) + } } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) fun setChecked(categoryId: Long, isChecked: Boolean) { launchJob(Dispatchers.Default) { 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/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt index 77c128fe5..9e22b6a62 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -12,6 +12,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index b8e310dcb..dea73f94f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -1,25 +1,25 @@ package org.koitharu.kotatsu.favourites.ui.list -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.asFlowLiveData +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID -import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState @@ -27,28 +27,25 @@ 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.parsers.model.SortOrder -import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject @HiltViewModel class FavouritesListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: FavouritesRepository, - private val trackingRepository: TrackingRepository, - private val historyRepository: HistoryRepository, - private val settings: AppSettings, - private val tagHighlighter: MangaTagHighlighter, + private val listExtraProvider: ListExtraProvider, + settings: AppSettings, downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider { +) : MangaListViewModel(settings, downloadScheduler) { val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID - val sortOrder: LiveData = if (categoryId == NO_ID) { - MutableLiveData(null) + val sortOrder: StateFlow = if (categoryId == NO_ID) { + MutableStateFlow(null) } else { repository.observeCategory(categoryId) .map { it?.order } - .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) } override val content = combine( @@ -57,7 +54,7 @@ class FavouritesListViewModel @Inject constructor( } else { repository.observeAll(categoryId) }, - listModeFlow, + listMode, ) { list, mode -> when { list.isEmpty() -> listOf( @@ -73,11 +70,11 @@ class FavouritesListViewModel @Inject constructor( ), ) - else -> list.toUi(mode, this, tagHighlighter) + else -> list.toUi(mode, listExtraProvider) } }.catch { emit(listOf(it.toErrorState(canRetry = false))) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) override fun onRefresh() = Unit @@ -93,7 +90,7 @@ class FavouritesListViewModel @Inject constructor( } else { repository.removeFromCategory(categoryId, ids) } - onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle)) + onActionDone.call(ReversibleAction(R.string.removed_from_favourites, handle)) } } @@ -105,20 +102,4 @@ class FavouritesListViewModel @Inject constructor( repository.setCategoryOrder(categoryId, order) } } - - 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/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 58% 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 b8c04d96d..bff70420b 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,48 +1,82 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui import androidx.annotation.WorkerThread -import androidx.lifecycle.LiveData -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 import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn 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.util.asFlowLiveData +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.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment +import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import org.koitharu.kotatsu.core.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 localTagsDeferred = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { + private val localTags = SuspendLazy { dataRepository.findTags(repository.source) } private var availableTagsDeferred = loadTagsAsync() - val items: LiveData> = getItemsFlow() - .asFlowLiveData(coroutineScope.coroutineContext + Dispatchers.Default, 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) @@ -88,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, @@ -97,7 +139,7 @@ class FilterCoordinator( } private fun getTagsAsFlow() = flow { - val localTags = localTagsDeferred.await() + val localTags = localTags.get() emit(TagsWrapper(localTags, isLoading = true, isError = false)) val remoteTags = tryLoadTags() if (remoteTags == null) { @@ -107,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) } @@ -132,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..7e9ef1405 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt @@ -0,0 +1,60 @@ +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.core.util.ext.showDistinct +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().showDistinct(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/history/data/HistoryDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt index be2c5f7e9..0e033ddd5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -1,6 +1,10 @@ package org.koitharu.kotatsu.history.data -import androidx.room.* +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.TagEntity @@ -20,6 +24,10 @@ abstract class HistoryDao { @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC") abstract fun observeAll(): Flow> + @Transaction + @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit") + abstract fun observeAll(limit: Int): Flow> + @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)") abstract suspend fun findAllManga(): List diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt similarity index 88% rename from app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryRepository.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index 784f74280..3cd576e87 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -1,12 +1,10 @@ -package org.koitharu.kotatsu.history.domain +package org.koitharu.kotatsu.history.data import androidx.room.withTransaction import dagger.Reusable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.core.db.MangaDatabase @@ -17,11 +15,9 @@ import org.koitharu.kotatsu.core.db.entity.toMangaTag import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.util.ext.mapItems -import org.koitharu.kotatsu.history.data.HistoryEntity -import org.koitharu.kotatsu.history.data.toMangaHistory +import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler @@ -55,6 +51,12 @@ class HistoryRepository @Inject constructor( } } + fun observeAll(limit: Int): Flow> { + return db.historyDao.observeAll(limit).mapItems { + it.manga.toManga(it.tags.toMangaTags()) + } + } + fun observeAllWithHistory(): Flow> { return db.historyDao.observeAll().mapItems { MangaWithHistory( @@ -161,18 +163,6 @@ class HistoryRepository @Inject constructor( .distinctUntilChanged() } - fun observeShouldSkip(mangaFlow: Flow): Flow { - return mangaFlow - .distinctUntilChangedBy { it?.isNsfw } - .flatMapLatest { m -> - if (m != null) { - observeShouldSkip(m) - } else { - settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled } - } - } - } - private suspend fun recover(ids: Collection) { db.withTransaction { for (id in ids) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt new file mode 100644 index 000000000..452d0368f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt @@ -0,0 +1,38 @@ +package org.koitharu.kotatsu.history.domain + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.core.util.ext.processLifecycleScope +import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import javax.inject.Inject + +class HistoryUpdateUseCase @Inject constructor( + private val historyRepository: HistoryRepository, +) { + + suspend operator fun invoke(manga: Manga, readerState: ReaderState, percent: Float) { + historyRepository.addOrUpdate( + manga = manga, + chapterId = readerState.chapterId, + page = readerState.page, + scroll = readerState.scroll, + percent = percent, + ) + } + + fun invokeAsync( + manga: Manga, + readerState: ReaderState, + percent: Float + ) = processLifecycleScope.launch(Dispatchers.Default) { + runCatchingCancellable { + invoke(manga, readerState, percent) + }.onFailure { + it.printStackTraceDebug() + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/MangaWithHistory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/model/MangaWithHistory.kt similarity index 77% rename from app/src/main/kotlin/org/koitharu/kotatsu/history/domain/MangaWithHistory.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/history/domain/model/MangaWithHistory.kt index 611dd1ded..5a7e26897 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/MangaWithHistory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/model/MangaWithHistory.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.history.domain +package org.koitharu.kotatsu.history.domain.model import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.parsers.model.Manga @@ -6,4 +6,4 @@ import org.koitharu.kotatsu.parsers.model.Manga data class MangaWithHistory( val manga: Manga, val history: MangaHistory -) \ No newline at end of file +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index de17fc3ed..47157c4a7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -9,6 +9,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.parsers.model.MangaSource diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index da09bea62..673fbc87d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -1,28 +1,27 @@ package org.koitharu.kotatsu.history.ui -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.asFlowLiveData +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.daysDiff -import org.koitharu.kotatsu.core.util.ext.emitValue import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.history.domain.MangaWithHistory -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.history.domain.model.MangaWithHistory +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.ListModel @@ -31,7 +30,6 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toGridModel import org.koitharu.kotatsu.list.ui.model.toListDetailedModel import org.koitharu.kotatsu.list.ui.model.toListModel -import org.koitharu.kotatsu.tracker.domain.TrackingRepository import java.util.Date import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -40,20 +38,20 @@ import javax.inject.Inject class HistoryListViewModel @Inject constructor( private val repository: HistoryRepository, private val settings: AppSettings, - private val trackingRepository: TrackingRepository, - private val tagHighlighter: MangaTagHighlighter, + private val extraProvider: ListExtraProvider, downloadScheduler: DownloadWorker.Scheduler, ) : MangaListViewModel(settings, downloadScheduler) { - val isGroupingEnabled = MutableLiveData() - - private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled } - .onEach { isGroupingEnabled.emitValue(it) } + val isGroupingEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_HISTORY_GROUPING, + valueProducer = { isHistoryGroupingEnabled }, + ) override val content = combine( repository.observeAllWithHistory(), - historyGrouping, - listModeFlow, + isGroupingEnabled, + listMode, ) { list, grouped, mode -> when { list.isEmpty() -> listOf( @@ -73,7 +71,7 @@ class HistoryListViewModel @Inject constructor( loadingCounter.decrement() }.catch { emit(listOf(it.toErrorState(canRetry = false))) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) override fun onRefresh() = Unit @@ -91,7 +89,7 @@ class HistoryListViewModel @Inject constructor( } launchJob(Dispatchers.Default) { val handle = repository.delete(ids) - onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle)) + onActionDone.call(ReversibleAction(R.string.removed_from_history, handle)) } } @@ -105,7 +103,6 @@ class HistoryListViewModel @Inject constructor( mode: ListMode, ): List { val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size + 1) - val showPercent = settings.isReadingIndicatorsEnabled var prevDate: DateTimeAgo? = null for ((manga, history) in list) { if (grouped) { @@ -115,16 +112,10 @@ class HistoryListViewModel @Inject constructor( } prevDate = date } - val counter = if (settings.isTrackerEnabled) { - trackingRepository.getNewChaptersCount(manga.id) - } else { - 0 - } - val percent = if (showPercent) history.percent else PROGRESS_NONE result += when (mode) { - ListMode.LIST -> manga.toListModel(counter, percent) - ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent, tagHighlighter) - ListMode.GRID -> manga.toGridModel(counter, percent) + ListMode.LIST -> manga.toListModel(extraProvider) + ListMode.DETAILED_LIST -> manga.toListDetailedModel(extraProvider) + ListMode.GRID -> manga.toGridModel(extraProvider) } } return result diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt index dc2bd9d8e..618b8d788 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt @@ -13,7 +13,7 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.core.graphics.ColorUtils import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.scale -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.history.data.PROGRESS_NONE class ReadingProgressDrawable( context: Context, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt index 49c05a7c5..744201215 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt @@ -12,7 +12,7 @@ import androidx.annotation.AttrRes import androidx.annotation.StyleRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.history.data.PROGRESS_NONE class ReadingProgressView @JvmOverloads constructor( context: Context, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt index 7c547e0ae..60cc18885 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt @@ -1,8 +1,60 @@ package org.koitharu.kotatsu.list.domain -interface ListExtraProvider { +import android.content.Context +import androidx.annotation.ColorRes +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import org.koitharu.kotatsu.R +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.parsers.model.MangaTag +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import javax.inject.Inject - suspend fun getCounter(mangaId: Long): Int +@Reusable +class ListExtraProvider @Inject constructor( + @ApplicationContext context: Context, + private val settings: AppSettings, + private val trackingRepository: TrackingRepository, + private val historyRepository: HistoryRepository, +) { - suspend fun getProgress(mangaId: Long): Float -} \ No newline at end of file + private val dict by lazy { + context.resources.openRawResource(R.raw.tags_redlist).use { + val set = HashSet() + it.bufferedReader().forEachLine { x -> + val line = x.trim() + if (line.isNotEmpty()) { + set.add(line) + } + } + set + } + } + + suspend fun getCounter(mangaId: Long): Int { + return if (settings.isTrackerEnabled) { + trackingRepository.getNewChaptersCount(mangaId) + } else { + 0 + } + } + + suspend fun getProgress(mangaId: Long): Float { + return if (settings.isReadingIndicatorsEnabled) { + historyRepository.getProgress(mangaId) + } else { + PROGRESS_NONE + } + } + + @ColorRes + fun getTagTint(tag: MangaTag): Int { + return if (tag.title.lowercase() in dict) { + R.color.warning + } else { + 0 + } + } +} 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..433ddbfb1 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,15 +11,16 @@ 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.ext.showDistinct import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.databinding.DialogListModeBinding import javax.inject.Inject @AndroidEntryPoint class ListModeBottomSheet : - BaseBottomSheet(), + BaseAdaptiveSheet(), Slider.OnChangeListener, MaterialButtonToggleGroup.OnButtonCheckedListener { @@ -72,6 +73,6 @@ class ListModeBottomSheet : private const val TAG = "ListModeSelectDialog" - fun show(fm: FragmentManager) = ListModeBottomSheet().show(fm, TAG) + fun show(fm: FragmentManager) = ListModeBottomSheet().showDistinct(fm, TAG) } } 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 c503087e3..021f4d83a 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 @@ -35,15 +35,16 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.clearItemDecorations -import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.measureHeight +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf 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 @@ -54,7 +55,7 @@ import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.search.ui.MangaListActivity import javax.inject.Inject @@ -112,8 +113,6 @@ abstract class MangaListFragment : fastScroller.setFastScrollListener(this@MangaListFragment) } with(binding.swipeRefreshLayout) { - setProgressBackgroundColorSchemeColor(context.getThemeColor(com.google.android.material.R.attr.colorPrimary)) - setColorSchemeColors(context.getThemeColor(com.google.android.material.R.attr.colorOnPrimary)) setOnRefreshListener(this@MangaListFragment) isEnabled = isSwipeRefreshEnabled } @@ -123,9 +122,9 @@ abstract class MangaListFragment : viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged) - viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) - viewModel.onDownloadStarted.observe(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView)) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) + viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) + viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView)) } override fun onDestroyView() { @@ -149,8 +148,8 @@ abstract class MangaListFragment : override fun onReadClick(manga: Manga, view: View) { if (selectionController?.onItemClick(manga.id) != true) { - val intent = ReaderActivity.newIntent(context ?: return, manga) - startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) + val intent = IntentBuilder(view.context).manga(manga).build() + startActivity(intent, scaleUpActivityOptionsOf(view)) } } @@ -294,7 +293,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/MangaListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index b123776dc..8c197fae9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -1,39 +1,38 @@ package org.koitharu.kotatsu.list.ui -import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.prefs.observeAsLiveData +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData +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.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag abstract class MangaListViewModel( - private val settings: AppSettings, + settings: AppSettings, private val downloadScheduler: DownloadWorker.Scheduler, ) : BaseViewModel() { - abstract val content: LiveData> - protected val listModeFlow = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, settings.listMode) - val listMode = listModeFlow.asFlowLiveData(viewModelScope.coroutineContext) - val onActionDone = SingleLiveEvent() - val gridScale = settings.observeAsLiveData( - context = viewModelScope.coroutineContext + Dispatchers.Default, + abstract val content: StateFlow> + val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.listMode) + val onActionDone = MutableEventFlow() + val gridScale = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_GRID_SIZE, valueProducer = { gridSize / 100f }, ) - val onDownloadStarted = SingleLiveEvent() + val onDownloadStarted = MutableEventFlow() open fun onUpdateFilter(tags: Set) = Unit @@ -44,7 +43,7 @@ abstract class MangaListViewModel( fun download(items: Set) { launchJob(Dispatchers.Default) { downloadScheduler.schedule(items) - onDownloadStarted.emitCall(Unit) + onDownloadStarted.call(Unit) } } } 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/MangaGridItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt index 900738327..e47650a25 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt @@ -12,7 +12,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemMangaGridBinding -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel 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/adapter/MangaListDetailedItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt index 645ecefa4..95396fca3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt @@ -16,7 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.parsers.model.MangaTag 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 f1dc651f7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt +++ /dev/null @@ -1,90 +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.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 89cd7eee1..aa6014626 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 @@ -3,46 +3,43 @@ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.ifZero -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.model.Manga import java.net.SocketTimeoutException import java.net.UnknownHostException -fun Manga.toListModel( - counter: Int, - progress: Float, +suspend fun Manga.toListModel( + extraProvider: ListExtraProvider? ) = MangaListModel( id = id, title = title, subtitle = tags.joinToString(", ") { it.title }, coverUrl = coverUrl, manga = this, - counter = counter, - progress = progress, + counter = extraProvider?.getCounter(id) ?: 0, + progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE, ) -fun Manga.toListDetailedModel( - counter: Int, - progress: Float, - tagHighlighter: MangaTagHighlighter?, +suspend fun Manga.toListDetailedModel( + extraProvider: ListExtraProvider?, ) = MangaListDetailedModel( id = id, title = title, subtitle = altTitle, coverUrl = coverUrl, manga = this, - counter = counter, - progress = progress, + counter = extraProvider?.getCounter(id) ?: 0, + progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE, tags = tags.map { ChipsView.ChipModel( - tint = tagHighlighter?.getTint(it) ?: 0, + tint = extraProvider?.getTagTint(it) ?: 0, title = it.title, + icon = 0, isCheckable = false, isChecked = false, data = it, @@ -50,53 +47,30 @@ fun Manga.toListDetailedModel( }, ) -fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel( +suspend fun Manga.toGridModel( + extraProvider: ListExtraProvider?, +) = MangaGridModel( id = id, title = title, coverUrl = coverUrl, manga = this, - counter = counter, - progress = progress, + counter = extraProvider?.getCounter(id) ?: 0, + progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE, ) suspend fun List.toUi( mode: ListMode, extraProvider: ListExtraProvider, - tagHighlighter: MangaTagHighlighter?, -): List = toUi(ArrayList(size), mode, extraProvider, tagHighlighter) - -fun List.toUi( - mode: ListMode, - tagHighlighter: MangaTagHighlighter?, -): List = toUi(ArrayList(size), mode, tagHighlighter) - -fun > List.toUi( - destination: C, - mode: ListMode, - tagHighlighter: MangaTagHighlighter?, -): C = when (mode) { - ListMode.LIST -> mapTo(destination) { it.toListModel(0, PROGRESS_NONE) } - ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0, PROGRESS_NONE, tagHighlighter) } - ListMode.GRID -> mapTo(destination) { it.toGridModel(0, PROGRESS_NONE) } -} +): List = toUi(ArrayList(size), mode, extraProvider) suspend fun > List.toUi( destination: C, mode: ListMode, extraProvider: ListExtraProvider, - tagHighlighter: MangaTagHighlighter?, ): C = when (mode) { - ListMode.LIST -> mapTo(destination) { - it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) - } - - ListMode.DETAILED_LIST -> mapTo(destination) { - it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id), tagHighlighter) - } - - ListMode.GRID -> mapTo(destination) { - it.toGridModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) - } + ListMode.LIST -> mapTo(destination) { it.toListModel(extraProvider) } + ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(extraProvider) } + ListMode.GRID -> mapTo(destination) { it.toGridModel(extraProvider) } } fun Throwable.toErrorState(canRetry: Boolean = true) = ErrorState( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt similarity index 86% rename from app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index 9763882a7..9405629cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.local.domain +package org.koitharu.kotatsu.local.data import android.net.Uri import androidx.core.net.toFile @@ -11,16 +11,15 @@ import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.firstOrNull 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.LocalManga -import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaUtil +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage @@ -28,8 +27,9 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.io.File +import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton @@ -39,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() @@ -99,8 +108,11 @@ class LocalMangaRepository @Inject constructor( suspend fun deleteChapters(manga: Manga, ids: Set) { lockManga(manga.id) try { - LocalMangaUtil(manga).deleteChapters(ids) - localStorageChanges.emit(LocalManga(manga)) + val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) { + "Manga is not stored on local storage" + }.manga + LocalMangaUtil(subject).deleteChapters(ids) + localStorageChanges.emit(LocalManga(subject)) } finally { unlockManga(manga.id) } @@ -136,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..ad52d2d26 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 @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.local.data import android.content.Context +import android.os.StatFs import com.tomclaw.cache.DiskLruCache import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -17,7 +18,7 @@ import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.io.File import javax.inject.Inject import javax.inject.Singleton @@ -33,7 +34,8 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { } private val lruCache = SuspendLazy { val dir = cacheDir.get() - val size = FileSize.MEGABYTES.convert(200, FileSize.BYTES) + val availableSize = (getAvailableSize() * 0.8).toLong() + val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN) runCatchingCancellable { DiskLruCache.create(dir, size) }.recoverCatching { error -> @@ -54,12 +56,29 @@ 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() } } + + private suspend fun getAvailableSize(): Long = runCatchingCancellable { + val statFs = StatFs(cacheDir.get().absolutePath) + statFs.availableBytes + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(SIZE_DEFAULT) + + private companion object { + + val SIZE_MIN + get() = FileSize.MEGABYTES.convert(20, FileSize.BYTES) + + val SIZE_DEFAULT + get() = FileSize.MEGABYTES.convert(200, FileSize.BYTES) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt index e5c9bf9e1..9221a4f08 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt @@ -16,10 +16,10 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.util.ext.resolveName import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.local.data.CbzFilter -import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.input.LocalMangaInput +import org.koitharu.kotatsu.local.domain.model.LocalManga import java.io.File import java.io.IOException import javax.inject.Inject 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 e383da294..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 @@ -9,9 +9,9 @@ import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.toListSorted import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.ImageFileFilter -import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.output.LocalMangaOutput +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage @@ -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/data/input/LocalMangaInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt index 0da957b8b..f203912e5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.local.data.input import android.net.Uri import androidx.core.net.toFile -import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt index e768e5b9b..f468c4647 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt @@ -10,9 +10,9 @@ import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.util.ext.toListSorted -import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.output.LocalMangaOutput +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt new file mode 100644 index 000000000..07a22f924 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.local.domain + +import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import java.io.IOException +import javax.inject.Inject + +class DeleteLocalMangaUseCase @Inject constructor( + private val localMangaRepository: LocalMangaRepository, + private val historyRepository: HistoryRepository, +) { + + suspend operator fun invoke(manga: Manga) { + val victim = if (manga.isLocal) manga else localMangaRepository.findSavedManga(manga)?.manga + checkNotNull(victim) { "Cannot find saved manga for ${manga.title}" } + val original = if (manga.isLocal) localMangaRepository.getRemoteManga(manga) else manga + localMangaRepository.delete(victim) || throw IOException("Unable to delete file") + runCatchingCancellable { + historyRepository.deleteOrSwap(victim, original) + }.onFailure { + it.printStackTraceDebug() + } + } + + suspend operator fun invoke(ids: Set) { + val list = localMangaRepository.getList(0, null, null) + var removed = 0 + for (manga in list) { + if (manga.id in ids) { + invoke(manga) + removed++ + } + } + check(removed == ids.size) { + "Removed $removed files but ${ids.size} requested" + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalManga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt similarity index 92% rename from app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalManga.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt index bebb4c12c..247d395d4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalManga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.local.data +package org.koitharu.kotatsu.local.domain.model import androidx.core.net.toFile import androidx.core.net.toUri @@ -38,9 +38,7 @@ class LocalManga( other as LocalManga if (manga != other.manga) return false - if (file != other.file) return false - - return true + return file == other.file } override fun hashCode(): Int { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt index 6ff59f3a3..7436ccc65 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt @@ -12,7 +12,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.databinding.DialogImportBinding -import org.koitharu.kotatsu.settings.backup.BackupDialogFragment import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment class ImportDialogFragment : AlertDialogFragment(), View.OnClickListener { @@ -64,9 +63,10 @@ class ImportDialogFragment : AlertDialogFragment(), View.On } private fun restoreBackup(uri: Uri?) { - RestoreDialogFragment.newInstance(uri ?: return) - .show(parentFragmentManager, BackupDialogFragment.TAG) - dismiss() + if (uri != null) { + RestoreDialogFragment.show(parentFragmentManager, uri) + dismiss() + } } companion object { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt index d63ea1100..754c8ffbb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt @@ -15,9 +15,9 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject 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 d451b3936..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 @@ -15,18 +14,22 @@ import org.koitharu.kotatsu.R 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() override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick)) - viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() } + viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() } } override fun onEmptyActionClick() { @@ -34,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 @@ -66,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) @@ -95,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 0439423eb..99723036a 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,194 +1,67 @@ package org.koitharu.kotatsu.local.ui -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.asFlow -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.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.update import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaTagHighlighter +import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData +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.domain.HistoryRepository -import org.koitharu.kotatsu.history.domain.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.LocalManga import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.domain.LocalMangaRepository -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.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import java.io.IOException -import java.util.LinkedList +import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase +import org.koitharu.kotatsu.local.domain.model.LocalManga +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, + savedStateHandle: SavedStateHandle, + mangaRepositoryFactory: MangaRepository.Factory, + filter: FilterCoordinator, + settings: AppSettings, downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider { - - val onMangaRemoved = SingleLiveEvent() - val sortOrder = MutableLiveData(settings.localListOrder) - private val listError = MutableStateFlow(null) - private val mangaList = MutableStateFlow?>(null) - private val selectedTags = MutableStateFlow>(emptySet()) - private var refreshJob: Job? = null + listExtraProvider: ListExtraProvider, + private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + @LocalStorageChanges private val localStorageChanges: SharedFlow, +) : RemoteListViewModel( + savedStateHandle, + mangaRepositoryFactory, + filter, + settings, + listExtraProvider, + downloadScheduler, +) { - override val content = combine( - mangaList, - listModeFlow, - sortOrder.asFlow(), - 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) - } - } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + val onMangaRemoved = MutableEventFlow() 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) { - val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids } - for (manga in itemsToRemove) { - val original = repository.getRemoteManga(manga) - repository.delete(manga) || throw IOException("Unable to delete file") - runCatchingCancellable { - historyRepository.deleteOrSwap(manga, original) - } - mangaList.update { list -> - list?.filterNot { it.id == manga.id } - } - } - onMangaRemoved.emitCall(Unit) + deleteLocalMangaUseCase(ids) + onMangaRemoved.call(Unit) } } - 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/local/ui/LocalStorageCleanupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt index fc2620b27..995ac48a7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt @@ -11,7 +11,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.local.data.LocalMangaRepository import java.util.concurrent.TimeUnit @HiltWorker 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 9f34e0c61..395cd5b63 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 @@ -45,6 +45,8 @@ import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.core.util.ext.drawableEnd import org.koitharu.kotatsu.core.util.ext.hideKeyboard +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.resolve import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat @@ -58,7 +60,7 @@ import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner 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.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment @@ -124,10 +126,12 @@ class MainActivity : viewBinding.navRail?.headerView?.setOnClickListener(this) viewBinding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null - navigationDelegate = - MainNavigationDelegate(checkNotNull(bottomNav ?: viewBinding.navRail), supportFragmentManager) + navigationDelegate = MainNavigationDelegate( + navBar = checkNotNull(bottomNav ?: viewBinding.navRail), + fragmentManager = supportFragmentManager, + ) navigationDelegate.addOnFragmentChangedListener(this) - navigationDelegate.onCreate(savedInstanceState) + navigationDelegate.onCreate() onBackPressedDispatcher.addCallback(ExitCallback(this, viewBinding.container)) onBackPressedDispatcher.addCallback(navigationDelegate) @@ -137,8 +141,8 @@ class MainActivity : onFirstStart() } - viewModel.onOpenReader.observe(this, this::onOpenReader) - viewModel.onError.observe(this, SnackbarErrorObserver(viewBinding.container, null)) + viewModel.onOpenReader.observeEvent(this, this::onOpenReader) + viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.container, null)) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.counters.observe(this, ::onCountersChanged) @@ -154,6 +158,8 @@ class MainActivity : override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { adjustFabVisibility(topFragment = fragment) if (fromUser) { + actionModeDelegate.finishActionMode() + closeSearchCallback.handleOnBackPressed() viewBinding.appbar.setExpanded(true) } } @@ -241,21 +247,21 @@ class MainActivity : override fun onSupportActionModeStarted(mode: ActionMode) { super.onSupportActionModeStarted(mode) adjustFabVisibility() - showNav(false) + bottomNav?.hide() } override fun onSupportActionModeFinished(mode: ActionMode) { super.onSupportActionModeFinished(mode) adjustFabVisibility() - showNav(true) + bottomNav?.show() } private fun onOpenReader(manga: Manga) { val fab = viewBinding.fab ?: viewBinding.navRail?.headerView val options = fab?.let { - scaleUpActivityOptionsOf(it).toBundle() + scaleUpActivityOptionsOf(it) } - startActivity(ReaderActivity.newIntent(this, manga), options) + startActivity(IntentBuilder(this).manga(manga).build(), options) } private fun onCountersChanged(counters: SparseIntArray) { @@ -299,17 +305,6 @@ class MainActivity : closeSearchCallback.isEnabled = false } - private fun showNav(visible: Boolean) { - bottomNav?.run { - if (visible) { - show() - } else { - hide() - } - } - viewBinding.navRail?.isVisible = visible - } - private fun isSearchOpened(): Boolean { return supportFragmentManager.findFragmentByTag(TAG_SEARCH) != null } @@ -338,7 +333,7 @@ class MainActivity : } private fun adjustFabVisibility( - isResumeEnabled: Boolean = viewModel.isResumeEnabled.value == true, + isResumeEnabled: Boolean = viewModel.isResumeEnabled.value, topFragment: Fragment? = navigationDelegate.primaryFragment, isSearchOpened: Boolean = isSearchOpened(), ) { @@ -381,7 +376,7 @@ class MainActivity : supportActionBar?.setHomeAsUpIndicator( if (isOpened) materialR.drawable.abc_ic_ab_back_material else materialR.drawable.abc_ic_search_api_material, ) - showNav(!isOpened) + bottomNav?.showOrHide(!isOpened) } private fun requestNotificationsPermission() { 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/MainViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt index f6482dce8..b983b7cbd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -5,17 +5,20 @@ import androidx.core.util.set import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.prefs.observeAsLiveData +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject @@ -24,21 +27,25 @@ import javax.inject.Inject class MainViewModel @Inject constructor( private val historyRepository: HistoryRepository, private val appUpdateRepository: AppUpdateRepository, - private val trackingRepository: TrackingRepository, - private val settings: AppSettings, + trackingRepository: TrackingRepository, + settings: AppSettings, ) : BaseViewModel() { - val onOpenReader = SingleLiveEvent() + val onOpenReader = MutableEventFlow() val isResumeEnabled = combine( historyRepository.observeHasItems(), settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }, ) { hasItems, incognito -> hasItems && !incognito - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) + }.stateIn( + scope = viewModelScope + Dispatchers.Default, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false, + ) - val isFeedAvailable = settings.observeAsLiveData( - context = viewModelScope.coroutineContext + Dispatchers.Default, + val isFeedAvailable = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_TRACKER_ENABLED, valueProducer = { isTrackerEnabled }, ) @@ -51,7 +58,11 @@ class MainViewModel @Inject constructor( a[R.id.nav_tools] = if (appUpdate != null) 1 else 0 a[R.id.nav_feed] = tracks a - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0)) + }.stateIn( + scope = viewModelScope + Dispatchers.Default, + started = SharingStarted.WhileSubscribed(5000), + initialValue = SparseIntArray(0), + ) init { launchJob { @@ -62,7 +73,7 @@ class MainViewModel @Inject constructor( fun openLastReader() { launchLoadingJob(Dispatchers.Default) { val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException() - onOpenReader.emitCall(manga) + onOpenReader.call(manga) } } } 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/main/ui/protect/ProtectActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt index a4a693cf5..ff6bf07bf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt @@ -22,6 +22,8 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivityProtectBinding @AndroidEntryPoint @@ -42,9 +44,9 @@ class ProtectActivity : viewBinding.buttonNext.setOnClickListener(this) viewBinding.buttonCancel.setOnClickListener(this) - viewModel.onError.observe(this, this::onError) + viewModel.onError.observeEvent(this, this::onError) viewModel.isLoading.observe(this, this::onLoadingStateChanged) - viewModel.onUnlockSuccess.observe(this) { + viewModel.onUnlockSuccess.observeEvent(this) { val intent = intent.getParcelableExtraCompat(EXTRA_INTENT) startActivity(intent) finishAfterTransition() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt index e530a2a7d..7f0d8f5b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt @@ -6,7 +6,8 @@ import kotlinx.coroutines.delay import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.parsers.util.md5 import javax.inject.Inject @@ -20,7 +21,7 @@ class ProtectViewModel @Inject constructor( private var job: Job? = null - val onUnlockSuccess = SingleLiveEvent() + val onUnlockSuccess = MutableEventFlow() val isBiometricEnabled get() = settings.isBiometricProtectionEnabled diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt index 334efab8a..1c75d5702 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt @@ -5,7 +5,7 @@ import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.details.domain.model.DoubleManga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import javax.inject.Inject @@ -17,17 +17,27 @@ class ChaptersLoader @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, ) { - val chapters = LongSparseArray() + private val chapters = LongSparseArray() private val chapterPages = ChapterPages() private val mutex = Mutex() - suspend fun loadPrevNextChapter(manga: Manga, currentId: Long, isNext: Boolean) { + val size: Int + get() = chapters.size() + + suspend fun init(manga: DoubleManga) = mutex.withLock { + chapters.clear() + manga.chapters?.forEach { + chapters.put(it.id, it) + } + } + + suspend fun loadPrevNextChapter(manga: DoubleManga, currentId: Long, isNext: Boolean) { val chapters = manga.chapters ?: return val predicate: (MangaChapter) -> Boolean = { it.id == currentId } val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate) if (index == -1) return val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return - val newPages = loadChapter(manga, newChapter.id) + val newPages = loadChapter(newChapter.id) mutex.withLock { if (chapterPages.chaptersSize > 1) { // trim pages @@ -47,14 +57,16 @@ class ChaptersLoader @Inject constructor( } } - suspend fun loadSingleChapter(manga: Manga, chapterId: Long) { - val pages = loadChapter(manga, chapterId) + suspend fun loadSingleChapter(chapterId: Long) { + val pages = loadChapter(chapterId) mutex.withLock { chapterPages.clear() chapterPages.addLast(chapterId, pages) } } + fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId] + fun getPages(chapterId: Long): List { return chapterPages.subList(chapterId) } @@ -69,9 +81,9 @@ class ChaptersLoader @Inject constructor( fun snapshot() = chapterPages.toList() - private suspend fun loadChapter(manga: Manga, chapterId: Long): List { + private suspend fun loadChapter(chapterId: Long): List { val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } - val repo = mangaRepositoryFactory.create(manga.source) + val repo = mangaRepositoryFactory.create(chapter.source) return repo.getPages(chapter).mapIndexed { index, page -> ReaderPage(page, index, chapterId) } 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 new file mode 100644 index 000000000..049914d3c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt @@ -0,0 +1,98 @@ +package org.koitharu.kotatsu.reader.domain + +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Size +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okhttp3.OkHttpClient +import org.koitharu.kotatsu.core.model.findChapter +import org.koitharu.kotatsu.core.network.ImageProxyInterceptor +import org.koitharu.kotatsu.core.network.MangaHttpClient +import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ReaderMode +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import java.io.InputStream +import java.util.zip.ZipFile +import javax.inject.Inject +import kotlin.math.roundToInt + +class DetectReaderModeUseCase @Inject constructor( + private val dataRepository: MangaDataRepository, + private val settings: AppSettings, + private val mangaRepositoryFactory: MangaRepository.Factory, + @MangaHttpClient private val okHttpClient: OkHttpClient, + private val imageProxyInterceptor: ImageProxyInterceptor, +) { + + suspend operator fun invoke(manga: Manga, state: ReaderState?): ReaderMode { + dataRepository.getReaderMode(manga.id)?.let { return it } + val defaultMode = settings.defaultReaderMode + if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) { + return defaultMode + } + val chapter = state?.let { manga.findChapter(it.chapterId) } + ?: manga.chapters?.firstOrNull() + ?: error("There are no chapters in this manga") + val repo = mangaRepositoryFactory.create(manga.source) + val pages = repo.getPages(chapter) + return runCatchingCancellable { + val isWebtoon = guessMangaIsWebtoon(repo, pages) + if (isWebtoon) ReaderMode.WEBTOON else defaultMode + }.onSuccess { + dataRepository.saveReaderMode(manga, it) + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(defaultMode) + } + + /** + * Automatic determine type of manga by page size + * @return ReaderMode.WEBTOON if page is wide + */ + private suspend fun guessMangaIsWebtoon(repository: MangaRepository, pages: List): Boolean { + val pageIndex = (pages.size * 0.3).roundToInt() + val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" } + val url = repository.getPageUrl(page) + val uri = Uri.parse(url) + val size = if (uri.scheme == "cbz") { + runInterruptible(Dispatchers.IO) { + val zip = ZipFile(uri.schemeSpecificPart) + val entry = zip.getEntry(uri.fragment) + zip.getInputStream(entry).use { + getBitmapSize(it) + } + } + } else { + val request = PageLoader.createPageRequest(page, url) + imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { + runInterruptible(Dispatchers.IO) { + getBitmapSize(it.body?.byteStream()) + } + } + } + return size.width * MIN_WEBTOON_RATIO < size.height + } + + companion object { + + private const val MIN_WEBTOON_RATIO = 1.8 + + private fun getBitmapSize(input: InputStream?): Size { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeStream(input, null, options)?.recycle() + val imageHeight: Int = options.outHeight + val imageWidth: Int = options.outWidth + check(imageHeight > 0 && imageWidth > 0) + return Size(imageWidth, imageHeight) + } + } +} 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 1fef8621f..48e5faf0e 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,8 +9,10 @@ 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.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -22,20 +25,24 @@ 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.isNotEmpty +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 org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.io.File import java.util.LinkedList import java.util.concurrent.atomic.AtomicInteger @@ -46,11 +53,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 +83,7 @@ class PageLoader @Inject constructor( } fun isPrefetchApplicable(): Boolean { - return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled() + return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled && !isLowRam() } @AnyThread @@ -96,7 +105,7 @@ class PageLoader @Inject constructor( } fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred { - var task = tasks[page.id] + var task = tasks[page.id]?.takeIf { it.isValid() } if (force) { task?.cancel() } else if (task?.isCancelled == false) { @@ -115,6 +124,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 +203,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 +214,19 @@ class PageLoader @Inject constructor( } } + private fun isLowRam(): Boolean { + return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) + } + + private fun Deferred.isValid(): Boolean { + return if (isCompleted) { + val file = getCompleted() + file.exists() && file.isNotEmpty() + } else { + true + } + } + private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { @@ -217,6 +239,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 90% 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..1d30aebb9 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,10 +9,11 @@ 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.showDistinct import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter @@ -23,7 +24,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 +48,7 @@ class ChaptersBottomSheet : BaseBottomSheet(), OnListItemC isUnread = index > currentPosition, isNew = false, isDownloaded = false, + isBookmarked = false, ) } binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter -> @@ -83,9 +85,9 @@ 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) + }.showDistinct(fm, TAG) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index d8382a543..b312c9b12 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.reader.ui import android.content.Context +import android.graphics.BitmapFactory import android.net.Uri import android.webkit.MimeTypeMap import androidx.activity.result.ActivityResultLauncher @@ -15,7 +16,6 @@ import okio.IOException import okio.buffer import okio.sink import okio.source -import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.toFileNameSafe @@ -74,7 +74,7 @@ class PageSaveHelper @Inject constructor( var extension = name.substringAfterLast('.', "") name = name.substringBeforeLast('.') if (extension.length !in 2..4) { - val mimeType = MangaDataRepository.getImageMimeType(file) + val mimeType = getImageMimeType(file) extension = if (mimeType != null) { MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK } else { @@ -83,4 +83,12 @@ class PageSaveHelper @Inject constructor( } return name.toFileNameSafe().take(MAX_FILENAME_LENGTH) + "." + extension } + + private suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.path, options)?.recycle() + options.outMimeType + } } 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 3fff9ab9a..c33f1b076 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 @@ -42,13 +44,15 @@ import org.koitharu.kotatsu.core.util.IdlingDetector import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.hasGlobalPoint import org.koitharu.kotatsu.core.util.ext.isRtl -import org.koitharu.kotatsu.core.util.ext.observeWithPrevious +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.postDelayed import org.koitharu.kotatsu.core.util.ext.setValueRounded +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 @@ -60,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 { @@ -108,7 +112,7 @@ class ReaderActivity : insetsDelegate.interceptingWindowInsetsListener = this idlingDetector.bindToLifecycle(this) - viewModel.onError.observe( + viewModel.onError.observeEvent( this, DialogErrorObserver( host = viewBinding.container, @@ -117,23 +121,23 @@ class ReaderActivity : onResolved = { isResolved -> if (isResolved) { viewModel.reload() - } else if (viewModel.content.value?.pages.isNullOrEmpty()) { + } else if (viewModel.content.value.pages.isEmpty()) { finishAfterTransition() } }, ), ) viewModel.readerMode.observe(this, this::onInitReader) - viewModel.onPageSaved.observe(this, this::onPageSaved) - viewModel.uiState.observeWithPrevious(this, this::onUiStateChanged) + viewModel.onPageSaved.observeEvent(this, this::onPageSaved) + viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.content.observe(this) { - onLoadingStateChanged(viewModel.isLoading.value == true) + onLoadingStateChanged(viewModel.isLoading.value) } viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure) viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged) viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged) - viewModel.onShowToast.observe(this) { msgId -> + viewModel.onShowToast.observeEvent(this) { msgId -> Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT) .setAnchorView(viewBinding.appbarBottom) .show() @@ -150,7 +154,10 @@ class ReaderActivity : viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) } - private fun onInitReader(mode: ReaderMode) { + private fun onInitReader(mode: ReaderMode?) { + if (mode == null) { + return + } if (readerManager.currentMode != mode) { readerManager.replace(mode) } @@ -172,7 +179,7 @@ class ReaderActivity : } R.id.action_chapters -> { - ChaptersBottomSheet.show( + ChaptersSheet.show( supportFragmentManager, viewModel.manga?.chapters.orEmpty(), viewModel.getCurrentState()?.chapterId ?: 0L, @@ -183,14 +190,14 @@ class ReaderActivity : val state = viewModel.getCurrentState() ?: return false PagesThumbnailsSheet.show( supportFragmentManager, - viewModel.manga ?: return false, + viewModel.manga?.any ?: return false, state.chapterId, state.page, ) } R.id.action_bookmark -> { - if (viewModel.isBookmarkAdded.value == true) { + if (viewModel.isBookmarkAdded.value) { viewModel.removeBookmark() } else { viewModel.addBookmark() @@ -200,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) @@ -209,7 +216,7 @@ class ReaderActivity : } private fun onLoadingStateChanged(isLoading: Boolean) { - val hasPages = !viewModel.content.value?.pages.isNullOrEmpty() + val hasPages = viewModel.content.value.pages.isNotEmpty() viewBinding.layoutLoading.isVisible = isLoading && !hasPages if (isLoading && hasPages) { viewBinding.toastView.show(R.string.loading_) @@ -260,7 +267,7 @@ class ReaderActivity : override fun onPageSelected(page: ReaderPage) { lifecycleScope.launch(Dispatchers.Default) { - val pages = viewModel.content.value?.pages ?: return@launch + val pages = viewModel.content.value.pages val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id } if (index != -1) { withContext(Dispatchers.Main) { @@ -311,7 +318,7 @@ class ReaderActivity : TransitionManager.beginDelayedTransition(viewBinding.root, transition) viewBinding.appbarTop.isVisible = isUiVisible viewBinding.appbarBottom?.isVisible = isUiVisible - viewBinding.infoBar.isGone = isUiVisible || (viewModel.isInfoBarEnabled.value == false) + viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value) if (isUiVisible) { showSystemUI() } else { @@ -328,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() @@ -367,19 +374,16 @@ class ReaderActivity : menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark) } - private fun onUiStateChanged(uiState: ReaderUiState?, previous: ReaderUiState?) { - title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_) + private fun onUiStateChanged(pair: Pair) { + 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) @@ -394,45 +398,50 @@ class ReaderActivity : } } - companion object { + class IntentBuilder(context: Context) { - const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA" - const val EXTRA_STATE = "state" - const val EXTRA_BRANCH = "branch" - const val EXTRA_INCOGNITO = "incognito" - private const val TOAST_DURATION = 1500L + private val intent = Intent(context, ReaderActivity::class.java) + .setAction(ACTION_MANGA_READ) + + fun manga(manga: Manga) = apply { + intent.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) + } - fun newIntent(context: Context, manga: Manga): Intent { - return Intent(context, ReaderActivity::class.java) - .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) + fun mangaId(mangaId: Long) = apply { + intent.putExtra(MangaIntent.KEY_ID, mangaId) } - fun newIntent(context: Context, manga: Manga, branch: String?, isIncognitoMode: Boolean): Intent { - return Intent(context, ReaderActivity::class.java) - .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) - .putExtra(EXTRA_BRANCH, branch) - .putExtra(EXTRA_INCOGNITO, isIncognitoMode) + fun incognito(incognito: Boolean) = apply { + intent.putExtra(EXTRA_INCOGNITO, incognito) } - fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent { - return Intent(context, ReaderActivity::class.java) - .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) - .putExtra(EXTRA_STATE, state) + fun branch(branch: String?) = apply { + intent.putExtra(EXTRA_BRANCH, branch) } - fun newIntent(context: Context, bookmark: Bookmark): Intent { - val state = ReaderState( + fun state(state: ReaderState?) = apply { + intent.putExtra(EXTRA_STATE, state) + } + + fun bookmark(bookmark: Bookmark) = manga( + bookmark.manga, + ).state( + ReaderState( chapterId = bookmark.chapterId, page = bookmark.page, scroll = bookmark.scroll, - ) - return newIntent(context, bookmark.manga, state) - .putExtra(EXTRA_INCOGNITO, true) - } + ), + ) - fun newIntent(context: Context, mangaId: Long): Intent { - return Intent(context, ReaderActivity::class.java) - .putExtra(MangaIntent.KEY_ID, mangaId) - } + fun build() = intent + } + + companion object { + + const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA" + const val EXTRA_STATE = "state" + const val EXTRA_BRANCH = "branch" + const val EXTRA_INCOGNITO = "incognito" + private const val TOAST_DURATION = 1500L } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt index 7c2eb733a..e0306c6fb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt @@ -22,7 +22,7 @@ import org.koitharu.kotatsu.core.util.ext.measureDimension import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.text.SimpleDateFormat import java.util.Date import com.google.android.material.R as materialR 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 f092a5c06..0bae31cc5 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 @@ -1,13 +1,10 @@ package org.koitharu.kotatsu.reader.ui import android.net.Uri -import android.util.LongSparseArray import androidx.activity.result.ActivityResultLauncher import androidx.annotation.AnyThread import androidx.annotation.MainThread import androidx.annotation.WorkerThread -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -16,6 +13,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -28,39 +26,37 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository -import org.koitharu.kotatsu.core.os.ShortcutsUpdater +import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.prefs.observeAsLiveData +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData -import org.koitharu.kotatsu.core.util.ext.emitValue -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope +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.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.requireValue -import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase +import org.koitharu.kotatsu.details.domain.model.DoubleManga +import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.history.data.PROGRESS_NONE +import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.reader.data.filterChapters import org.koitharu.kotatsu.reader.domain.ChaptersLoader +import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState -import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.util.Date import javax.inject.Inject @@ -70,7 +66,6 @@ private const val PREFETCH_LIMIT = 10 @HiltViewModel class ReaderViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val mangaRepositoryFactory: MangaRepository.Factory, private val dataRepository: MangaDataRepository, private val historyRepository: HistoryRepository, private val bookmarksRepository: BookmarksRepository, @@ -78,7 +73,10 @@ class ReaderViewModel @Inject constructor( private val pageSaveHelper: PageSaveHelper, private val pageLoader: PageLoader, private val chaptersLoader: ChaptersLoader, - private val shortcutsUpdater: ShortcutsUpdater, + private val appShortcutManager: AppShortcutManager, + private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase, + private val historyUpdateUseCase: HistoryUpdateUseCase, + private val detectReaderModeUseCase: DetectReaderModeUseCase, ) : BaseViewModel() { private val intent = MangaIntent(savedStateHandle) @@ -90,33 +88,33 @@ class ReaderViewModel @Inject constructor( private var bookmarkJob: Job? = null private var stateChangeJob: Job? = null private val currentState = MutableStateFlow(savedStateHandle[ReaderActivity.EXTRA_STATE]) - private val mangaData = MutableStateFlow(intent.manga) - private val chapters: LongSparseArray - get() = chaptersLoader.chapters + private val mangaData = MutableStateFlow(intent.manga?.let { DoubleManga(it) }) + private val mangaFlow: Flow + get() = mangaData.map { it?.any } - val readerMode = MutableLiveData() - val onPageSaved = SingleLiveEvent() - val onShowToast = SingleLiveEvent() - val uiState = MutableLiveData(null) + val readerMode = MutableStateFlow(null) + val onPageSaved = MutableEventFlow() + val onShowToast = MutableEventFlow() + val uiState = MutableStateFlow(null) - val content = MutableLiveData(ReaderContent(emptyList(), null)) - val manga: Manga? + val content = MutableStateFlow(ReaderContent(emptyList(), null)) + val manga: DoubleManga? get() = mangaData.value - val readerAnimation = settings.observeAsLiveData( - context = viewModelScope.coroutineContext + Dispatchers.Default, + val readerAnimation = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_READER_ANIMATION, valueProducer = { readerAnimation }, ) - val isInfoBarEnabled = settings.observeAsLiveData( - context = viewModelScope.coroutineContext + Dispatchers.Default, + val isInfoBarEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_READER_BAR, valueProducer = { isReaderBarEnabled }, ) - val isWebtoonZoomEnabled = settings.observeAsLiveData( - context = viewModelScope.coroutineContext + Dispatchers.Default, + val isWebtoonZoomEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_WEBTOON_ZOOM, valueProducer = { isWebtoonZoomEnable }, ) @@ -124,28 +122,30 @@ class ReaderViewModel @Inject constructor( val readerSettings = ReaderSettings( parentScope = viewModelScope, settings = settings, - colorFilterFlow = mangaData.flatMapLatest { + colorFilterFlow = mangaFlow.flatMapLatest { if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null), ) val isScreenshotsBlockEnabled = combine( - mangaData, + mangaFlow, settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy }, ) { manga, policy -> policy == ScreenshotsPolicy.BLOCK_ALL || (policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - val isBookmarkAdded: LiveData = currentState.flatMapLatest { state -> - val manga = mangaData.value + val isBookmarkAdded = currentState.flatMapLatest { state -> + val manga = mangaData.value?.any if (state == null || manga == null) { flowOf(false) } else { bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) - .map { it != null } + .map { + it != null && it.chapterId == state.chapterId && it.page == state.page + } } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) init { loadImpl() @@ -154,8 +154,8 @@ class ReaderViewModel @Inject constructor( if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged() }.launchIn(viewModelScope + Dispatchers.Default) launchJob(Dispatchers.Default) { - val mangaId = mangaData.filterNotNull().first().id - shortcutsUpdater.notifyMangaOpened(mangaId) + val mangaId = mangaFlow.filterNotNull().first().id + appShortcutManager.notifyMangaOpened(mangaId) } } @@ -166,16 +166,14 @@ class ReaderViewModel @Inject constructor( fun switchMode(newMode: ReaderMode) { launchJob { - val manga = checkNotNull(mangaData.value) + val manga = checkNotNull(mangaData.value?.any) dataRepository.saveReaderMode( manga = manga, mode = newMode, ) readerMode.value = newMode - content.value?.run { - content.value = copy( - state = getCurrentState(), - ) + content.update { + it.copy(state = getCurrentState()) } } } @@ -188,9 +186,9 @@ class ReaderViewModel @Inject constructor( return } val readerState = state ?: currentState.value ?: return - historyRepository.saveStateAsync( - manga = mangaData.value ?: return, - state = readerState, + historyUpdateUseCase.invokeAsync( + manga = mangaData.value?.any ?: return, + readerState = readerState, percent = computePercent(readerState.chapterId, readerState.page), ) } @@ -211,12 +209,12 @@ class ReaderViewModel @Inject constructor( prevJob?.cancelAndJoin() try { val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher) - onPageSaved.emitCall(dest) + onPageSaved.call(dest) } catch (e: CancellationException) { throw e } catch (e: Exception) { e.printStackTraceDebug() - onPageSaved.emitCall(null) + onPageSaved.call(null) } } } @@ -232,7 +230,7 @@ class ReaderViewModel @Inject constructor( fun getCurrentPage(): MangaPage? { val state = currentState.value ?: return null - return content.value?.pages?.find { + return content.value.pages.find { it.chapterId == state.chapterId && it.index == state.page }?.toMangaPage() } @@ -241,9 +239,9 @@ class ReaderViewModel @Inject constructor( val prevJob = loadingJob loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() - content.postValue(ReaderContent(emptyList(), null)) - chaptersLoader.loadSingleChapter(mangaData.requireValue(), id) - content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0))) + content.value = ReaderContent(emptyList(), null) + chaptersLoader.loadSingleChapter(id) + content.value = ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0)) } } @@ -253,7 +251,7 @@ class ReaderViewModel @Inject constructor( stateChangeJob = launchJob(Dispatchers.Default) { prevJob?.cancelAndJoin() loadingJob?.join() - val pages = content.value?.pages ?: return@launchJob + val pages = content.value.pages pages.getOrNull(position)?.let { page -> currentState.update { cs -> cs?.copy(chapterId = page.chapterId, page = page.index) @@ -285,17 +283,17 @@ class ReaderViewModel @Inject constructor( val state = checkNotNull(currentState.value) val page = checkNotNull(getCurrentPage()) { "Page not found" } val bookmark = Bookmark( - manga = checkNotNull(mangaData.value), + manga = checkNotNull(mangaData.value?.any), pageId = page.id, chapterId = state.chapterId, page = state.page, scroll = state.scroll, - imageUrl = page.preview ?: pageLoader.getPageUrl(page), + imageUrl = page.preview.ifNullOrEmpty { page.url }, createdAt = Date(), percent = computePercent(state.chapterId, state.page), ) bookmarksRepository.addBookmark(bookmark) - onShowToast.emitCall(R.string.bookmark_added) + onShowToast.call(R.string.bookmark_added) } } @@ -305,45 +303,45 @@ class ReaderViewModel @Inject constructor( } bookmarkJob = launchJob { loadingJob?.join() - val manga = checkNotNull(mangaData.value) - val page = checkNotNull(getCurrentPage()) { "Page not found" } - bookmarksRepository.removeBookmark(manga.id, page.id) + val manga = checkNotNull(mangaData.value?.any) + val state = checkNotNull(getCurrentState()) + bookmarksRepository.removeBookmark(manga.id, state.chapterId, state.page) onShowToast.call(R.string.bookmark_removed) } } private fun loadImpl() { loadingJob = launchLoadingJob(Dispatchers.Default) { - var manga = dataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "") + var manga = DoubleManga( + dataRepository.resolveIntent(intent) + ?: throw NotFoundException("Cannot find manga", ""), + ) mangaData.value = manga - val repo = mangaRepositoryFactory.create(manga.source) - manga = repo.getDetails(manga) - manga.chapters?.forEach { - chapters.put(it.id, it) - } + manga = doubleMangaLoadUseCase(intent) + chaptersLoader.init(manga) // determine mode - val mode = detectReaderMode(manga, repo) + val singleManga = manga.requireAny() // obtain state if (currentState.value == null) { - currentState.value = historyRepository.getOne(manga)?.let { + currentState.value = historyRepository.getOne(singleManga)?.let { ReaderState(it) - } ?: ReaderState(manga, preselectedBranch) + } ?: ReaderState(singleManga, preselectedBranch) } - - val branch = chapters[currentState.value?.chapterId ?: 0L]?.branch + val mode = detectReaderModeUseCase.invoke(singleManga, currentState.value) + val branch = chaptersLoader.peekChapter(currentState.value?.chapterId ?: 0L)?.branch mangaData.value = manga.filterChapters(branch) - readerMode.emitValue(mode) + readerMode.value = mode - chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId) + chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId) // save state if (!isIncognito) { currentState.value?.let { val percent = computePercent(it.chapterId, it.page) - historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent) + historyUpdateUseCase.invoke(singleManga, it, percent) } } notifyStateChanged() - content.emitValue(ReaderContent(chaptersLoader.snapshot(), currentState.value)) + content.value = ReaderContent(chaptersLoader.snapshot(), currentState.value) } } @@ -353,7 +351,7 @@ class ReaderViewModel @Inject constructor( loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.join() chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext) - content.emitValue(ReaderContent(chaptersLoader.snapshot(), null)) + content.value = ReaderContent(chaptersLoader.snapshot(), null) } } @@ -367,46 +365,27 @@ class ReaderViewModel @Inject constructor( } } - private suspend fun detectReaderMode(manga: Manga, repo: MangaRepository): ReaderMode { - dataRepository.getReaderMode(manga.id)?.let { return it } - val defaultMode = settings.defaultReaderMode - if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) { - return defaultMode - } - val chapter = currentState.value?.chapterId?.let(chapters::get) - ?: manga.chapters?.randomOrNull() - ?: error("There are no chapters in this manga") - val pages = repo.getPages(chapter) - return runCatchingCancellable { - val isWebtoon = dataRepository.determineMangaIsWebtoon(repo, pages) - if (isWebtoon) ReaderMode.WEBTOON else defaultMode - }.onSuccess { - dataRepository.saveReaderMode(manga, it) - }.onFailure { - it.printStackTraceDebug() - }.getOrDefault(defaultMode) - } - @WorkerThread private fun notifyStateChanged() { val state = getCurrentState() - val chapter = state?.chapterId?.let(chapters::get) + val chapter = state?.chapterId?.let { chaptersLoader.peekChapter(it) } val newState = ReaderUiState( - mangaName = manga?.title, + mangaName = manga?.any?.title, + branch = chapter?.branch, chapterName = chapter?.name, chapterNumber = chapter?.number ?: 0, - chaptersTotal = manga?.getChapters(chapter?.branch)?.size ?: 0, + chaptersTotal = manga?.any?.getChapters(chapter?.branch)?.size ?: 0, totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0, currentPage = state?.page ?: 0, isSliderEnabled = settings.isReaderSliderEnabled, percent = if (state != null) computePercent(state.chapterId, state.page) else PROGRESS_NONE, ) - uiState.postValue(newState) + uiState.value = newState } private fun computePercent(chapterId: Long, pageIndex: Int): Float { - val branch = chapters[chapterId]?.branch - val chapters = manga?.getChapters(branch) ?: return PROGRESS_NONE + val branch = chaptersLoader.peekChapter(chapterId)?.branch + val chapters = manga?.any?.getChapters(branch) ?: return PROGRESS_NONE val chaptersCount = chapters.size val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } val pagesCount = chaptersLoader.getPagesCount(chapterId) @@ -418,23 +397,3 @@ class ReaderViewModel @Inject constructor( return ppc * chapterIndex + ppc * pagePercent } } - -/** - * This function is not a member of the ReaderViewModel - * because it should work independently of the ViewModel's lifecycle. - */ -private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job { - return processLifecycleScope.launch(Dispatchers.Default) { - runCatchingCancellable { - addOrUpdate( - manga = manga, - chapterId = state.chapterId, - page = state.page, - scroll = state.scroll, - percent = percent, - ) - }.onFailure { - it.printStackTraceDebug() - } - } -} 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 02a6b00ea..8bbe5f972 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 @@ -24,6 +25,9 @@ import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.decodeRegion 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 @@ -37,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 @@ -56,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) @@ -63,10 +68,10 @@ class ColorFilterConfigActivity : viewModel.colorFilter.observe(this, this::onColorFilterChanged) viewModel.isLoading.observe(this, this::onLoadingChanged) - viewModel.preview.observe(this, this::onPreviewChanged) - viewModel.onDismiss.observe(this) { + viewModel.onDismiss.observeEvent(this) { finishAfterTransition() } + loadPreview(viewModel.preview) } override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { @@ -78,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() @@ -101,21 +110,22 @@ 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 + private fun loadPreview(page: MangaPage) { + val data: Any = page.preview?.takeUnless { it.isEmpty() } ?: page ImageRequest.Builder(this@ColorFilterConfigActivity) - .data(preview.url) + .data(data) .scale(Scale.FILL) .decodeRegion() - .tag(preview.source) + .tag(page.source) .indicator(listOf(viewBinding.progressBefore, viewBinding.progressAfter)) .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/ColorFilterConfigBackPressedDispatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt index a7d017c15..97527946c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt @@ -5,6 +5,7 @@ import android.content.DialogInterface import androidx.activity.OnBackPressedCallback import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.call class ColorFilterConfigBackPressedDispatcher( private val context: Context, 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 22ef8497b..00ac1a23d 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 @@ -1,17 +1,16 @@ package org.koitharu.kotatsu.reader.ui.colorfilter -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.ext.emitValue -import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA import javax.inject.Inject @@ -19,50 +18,54 @@ import javax.inject.Inject @HiltViewModel class ColorFilterConfigViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaDataRepository: MangaDataRepository, ) : BaseViewModel() { - private val manga = checkNotNull(savedStateHandle.get(EXTRA_MANGA)?.manga) + private val manga = savedStateHandle.require(EXTRA_MANGA).manga private var initialColorFilter: ReaderColorFilter? = null - val colorFilter = MutableLiveData(null) - val onDismiss = SingleLiveEvent() - val preview = MutableLiveData(null) + val colorFilter = MutableStateFlow(null) + val onDismiss = MutableEventFlow() + val preview = savedStateHandle.require(ColorFilterConfigActivity.EXTRA_PAGES).pages.first() val isChanged: Boolean get() = colorFilter.value != initialColorFilter init { - val page = checkNotNull( - savedStateHandle.get(ColorFilterConfigActivity.EXTRA_PAGES)?.pages?.firstOrNull(), - ) launchLoadingJob { initialColorFilter = mangaDataRepository.getColorFilter(manga.id) colorFilter.value = initialColorFilter } - launchLoadingJob(Dispatchers.Default) { - val repository = mangaRepositoryFactory.create(page.source) - val url = repository.getPageUrl(page) - preview.emitValue( - MangaPage( - id = page.id, - url = url, - preview = page.preview, - source = page.source, - ), - ) - } } 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() { @@ -72,7 +75,7 @@ class ColorFilterConfigViewModel @Inject constructor( fun save() { launchLoadingJob(Dispatchers.Default) { mangaDataRepository.saveColorFilter(manga, colorFilter.value) - onDismiss.emitCall(Unit) + onDismiss.call(Unit) } } } 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 85% 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 d83552079..109fc58f3 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 @@ -18,12 +18,15 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus 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.observeAsLiveData -import org.koitharu.kotatsu.core.ui.BaseBottomSheet +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow +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.showDistinct import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding @@ -34,12 +37,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) @@ -56,11 +60,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 @@ -75,8 +85,8 @@ class ReaderConfigBottomSheet : binding.sliderTimer.addOnChangeListener(this) binding.switchScrollTimer.setOnCheckedChangeListener(this) - settings.observeAsLiveData( - context = lifecycleScope.coroutineContext + Dispatchers.Default, + settings.observeAsStateFlow( + scope = lifecycleScope + Dispatchers.Default, key = AppSettings.KEY_READER_AUTOSCROLL_SPEED, valueProducer = { readerAutoscrollSpeed }, ).observe(viewLifecycleOwner) { @@ -108,7 +118,7 @@ class ReaderConfigBottomSheet : R.id.button_color_filter -> { val page = viewModel.getCurrentPage() ?: return - val manga = viewModel.manga ?: return + val manga = viewModel.manga?.any ?: return startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page)) } } @@ -124,7 +134,11 @@ class ReaderConfigBottomSheet : } } - override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { + override fun onButtonChecked( + group: MaterialButtonToggleGroup?, + checkedId: Int, + isChecked: Boolean, + ) { if (!isChecked) { return } @@ -177,8 +191,8 @@ 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) + }.showDistinct(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/BaseReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt index 7570a9f5c..293928fb5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt @@ -6,6 +6,7 @@ import androidx.fragment.app.activityViewModels import androidx.viewbinding.ViewBinding import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.util.ext.getParcelableCompat +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderViewModel @@ -16,9 +17,13 @@ abstract class BaseReaderFragment : BaseFragment() { protected val viewModel by activityViewModels() private var stateToSave: ReaderState? = null + protected var readerAdapter: BaseReaderAdapter<*>? = null + private set + override fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) var restoredState = savedInstanceState?.getParcelableCompat(KEY_STATE) + readerAdapter = onCreateAdapter() viewModel.content.observe(viewLifecycleOwner) { onPagesChanged(it.pages, restoredState ?: it.state) @@ -33,6 +38,7 @@ abstract class BaseReaderFragment : BaseFragment() { override fun onDestroyView() { stateToSave = getCurrentState() + readerAdapter = null super.onDestroyView() } @@ -44,6 +50,10 @@ abstract class BaseReaderFragment : BaseFragment() { outState.putParcelable(KEY_STATE, stateToSave) } + protected fun requireAdapter() = checkNotNull(readerAdapter) { + "Adapter was not created or already destroyed" + } + override fun onWindowInsetsChanged(insets: Insets) = Unit abstract fun switchPageBy(delta: Int) @@ -54,5 +64,7 @@ abstract class BaseReaderFragment : BaseFragment() { abstract fun getCurrentState(): ReaderState? - protected abstract fun onPagesChanged(pages: List, pendingState: ReaderState?) + protected abstract fun onCreateAdapter(): BaseReaderAdapter<*> + + protected abstract suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index 21a0f4f41..617e79839 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.io.File import java.io.IOException 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/pager/reversed/ReversedReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt index 016a7436f..beed525cd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt @@ -4,15 +4,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.children +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.async -import kotlinx.coroutines.launch +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.yield +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.ext.doOnPageChanged import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.resetTransformations -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderState @@ -32,8 +35,6 @@ class ReversedReaderFragment : BaseReaderFragment @Inject lateinit var pageLoader: PageLoader - private var pagerAdapter: ReversedPagesAdapter? = null - override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -41,15 +42,8 @@ class ReversedReaderFragment : BaseReaderFragment override fun onViewBindingCreated(binding: FragmentReaderStandardBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) - pagerAdapter = ReversedPagesAdapter( - lifecycleOwner = viewLifecycleOwner, - loader = pageLoader, - settings = viewModel.readerSettings, - networkState = networkState, - exceptionResolver = exceptionResolver, - ) with(binding.pager) { - adapter = pagerAdapter + adapter = readerAdapter offscreenPageLimit = 2 doOnPageChanged(::notifyPageChanged) } @@ -66,11 +60,18 @@ class ReversedReaderFragment : BaseReaderFragment } override fun onDestroyView() { - pagerAdapter = null requireViewBinding().pager.adapter = null super.onDestroyView() } + override fun onCreateAdapter() = ReversedPagesAdapter( + lifecycleOwner = viewLifecycleOwner, + loader = pageLoader, + settings = viewModel.readerSettings, + networkState = networkState, + exceptionResolver = exceptionResolver, + ) + override fun switchPageBy(delta: Int) { with(requireViewBinding().pager) { setCurrentItem(currentItem - delta, context.isAnimationsEnabled) @@ -86,24 +87,26 @@ class ReversedReaderFragment : BaseReaderFragment } } - override fun onPagesChanged(pages: List, pendingState: ReaderState?) { + override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = coroutineScope { val reversedPages = pages.asReversed() - viewLifecycleScope.launch { - val items = async { - pagerAdapter?.setItems(reversedPages) + val items = async { + requireAdapter().setItems(reversedPages) + yield() + } + if (pendingState != null) { + val position = reversedPages.indexOfLast { + it.chapterId == pendingState.chapterId && it.index == pendingState.page } - if (pendingState != null) { - val position = reversedPages.indexOfLast { - it.chapterId == pendingState.chapterId && it.index == pendingState.page - } - items.await() ?: return@launch - if (position != -1) { - requireViewBinding().pager.setCurrentItem(position, false) - notifyPageChanged(position) - } + items.await() + if (position != -1) { + requireViewBinding().pager.setCurrentItem(position, false) + notifyPageChanged(position) } else { - items.await() + Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) + .show() } + } else { + items.await() } } @@ -122,6 +125,6 @@ class ReversedReaderFragment : BaseReaderFragment } private fun reversed(position: Int): Int { - return ((pagerAdapter?.itemCount ?: 0) - position - 1).coerceAtLeast(0) + return ((readerAdapter?.itemCount ?: 0) - position - 1).coerceAtLeast(0) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index eb5927fb1..2cacd4eea 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -20,7 +20,6 @@ import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import org.koitharu.kotatsu.util.ext.* open class PageHolder( owner: LifecycleOwner, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt index 19815597b..a9c656113 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt @@ -4,15 +4,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.children +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.async -import kotlinx.coroutines.launch +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.yield +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.ext.doOnPageChanged import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.resetTransformations -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderState @@ -31,8 +34,6 @@ class PagerReaderFragment : BaseReaderFragment() @Inject lateinit var pageLoader: PageLoader - private var pagesAdapter: PagesAdapter? = null - override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -40,15 +41,8 @@ class PagerReaderFragment : BaseReaderFragment() override fun onViewBindingCreated(binding: FragmentReaderStandardBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) - pagesAdapter = PagesAdapter( - lifecycleOwner = viewLifecycleOwner, - loader = pageLoader, - settings = viewModel.readerSettings, - networkState = networkState, - exceptionResolver = exceptionResolver, - ) with(binding.pager) { - adapter = pagesAdapter + adapter = readerAdapter offscreenPageLimit = 2 doOnPageChanged(::notifyPageChanged) } @@ -65,31 +59,40 @@ class PagerReaderFragment : BaseReaderFragment() } override fun onDestroyView() { - pagesAdapter = null requireViewBinding().pager.adapter = null super.onDestroyView() } - override fun onPagesChanged(pages: List, pendingState: ReaderState?) { - viewLifecycleScope.launch { - val items = async { - pagesAdapter?.setItems(pages) + override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = coroutineScope { + val items = async { + requireAdapter().setItems(pages) + yield() + } + if (pendingState != null) { + val position = pages.indexOfFirst { + it.chapterId == pendingState.chapterId && it.index == pendingState.page } - if (pendingState != null) { - val position = pages.indexOfFirst { - it.chapterId == pendingState.chapterId && it.index == pendingState.page - } - items.await() ?: return@launch - if (position != -1) { - requireViewBinding().pager.setCurrentItem(position, false) - notifyPageChanged(position) - } + items.await() + if (position != -1) { + requireViewBinding().pager.setCurrentItem(position, false) + notifyPageChanged(position) } else { - items.await() + Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) + .show() } + } else { + items.await() } } + override fun onCreateAdapter() = PagesAdapter( + lifecycleOwner = viewLifecycleOwner, + loader = pageLoader, + settings = viewModel.readerSettings, + networkState = networkState, + exceptionResolver = exceptionResolver, + ) + override fun switchPageBy(delta: Int) { with(requireViewBinding().pager) { setCurrentItem(currentItem + delta, context.isAnimationsEnabled) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt index b8f2eac37..78836411a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt @@ -4,14 +4,17 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.async -import kotlinx.coroutines.launch +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.yield +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderState @@ -30,7 +33,6 @@ class WebtoonReaderFragment : BaseReaderFragment() lateinit var pageLoader: PageLoader private val scrollInterpolator = AccelerateDecelerateInterpolator() - private var webtoonAdapter: WebtoonAdapter? = null override fun onCreateViewBinding( inflater: LayoutInflater, @@ -39,16 +41,9 @@ class WebtoonReaderFragment : BaseReaderFragment() override fun onViewBindingCreated(binding: FragmentReaderWebtoonBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) - webtoonAdapter = WebtoonAdapter( - lifecycleOwner = viewLifecycleOwner, - loader = pageLoader, - settings = viewModel.readerSettings, - networkState = networkState, - exceptionResolver = exceptionResolver, - ) with(binding.recyclerView) { setHasFixedSize(true) - adapter = webtoonAdapter + adapter = readerAdapter addOnPageScrollListener(PageScrollListener()) } @@ -58,32 +53,43 @@ class WebtoonReaderFragment : BaseReaderFragment() } override fun onDestroyView() { - webtoonAdapter = null requireViewBinding().recyclerView.adapter = null super.onDestroyView() } - override fun onPagesChanged(pages: List, pendingState: ReaderState?) { - viewLifecycleScope.launch { - val setItems = async { webtoonAdapter?.setItems(pages) } - if (pendingState != null) { - val position = pages.indexOfFirst { - it.chapterId == pendingState.chapterId && it.index == pendingState.page - } - setItems.await() ?: return@launch - if (position != -1) { - with(requireViewBinding().recyclerView) { - firstVisibleItemPosition = position - post { - (findViewHolderForAdapterPosition(position) as? WebtoonHolder) - ?.restoreScroll(pendingState.scroll) - } + override fun onCreateAdapter() = WebtoonAdapter( + lifecycleOwner = viewLifecycleOwner, + loader = pageLoader, + settings = viewModel.readerSettings, + networkState = networkState, + exceptionResolver = exceptionResolver, + ) + + override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = coroutineScope { + val setItems = async { + requireAdapter().setItems(pages) + yield() + } + if (pendingState != null) { + val position = pages.indexOfFirst { + it.chapterId == pendingState.chapterId && it.index == pendingState.page + } + setItems.await() + if (position != -1) { + with(requireViewBinding().recyclerView) { + firstVisibleItemPosition = position + post { + (findViewHolderForAdapterPosition(position) as? WebtoonHolder) + ?.restoreScroll(pendingState.scroll) } - notifyPageChanged(position) } + notifyPageChanged(position) } else { - setItems.await() + Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) + .show() } + } else { + setItems.await() } } 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 b4fe13b2f..0658e52f0 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,29 +14,34 @@ 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.showDistinct 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.ReaderActivity.IntentBuilder 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() @@ -61,12 +66,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, @@ -81,19 +82,10 @@ 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.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) + viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) + viewModel.branch.observe(viewLifecycleOwner, ::updateTitle) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) } override fun onDestroyView() { @@ -110,17 +102,44 @@ class PagesThumbnailsSheet : listener.onPageSelected(item.page) } else { val state = ReaderState(item.page.chapterId, item.page.index, 0) - val intent = ReaderActivity.newIntent(view.context, viewModel.manga, state) - startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) + val intent = IntentBuilder(view.context).manga(viewModel.manga).state(state).build() + startActivity(intent, scaleUpActivityOptionsOf(view)) } 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) } } @@ -133,6 +152,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() { @@ -169,7 +195,7 @@ class PagesThumbnailsSheet : putParcelable(ARG_MANGA, ParcelableManga(manga, true)) putLong(ARG_CHAPTER_ID, chapterId) putInt(ARG_CURRENT_PAGE, currentPage) - }.show(fm, TAG) + }.showDistinct(fm, TAG) } } } 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 bf9e86070..7fee3bf49 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 @@ -1,19 +1,19 @@ package org.koitharu.kotatsu.reader.ui.thumbnails -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.emitValue +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase 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.parsers.util.SuspendLazy -import org.koitharu.kotatsu.reader.data.filterChapters import org.koitharu.kotatsu.reader.domain.ChaptersLoader import javax.inject.Inject @@ -22,41 +22,48 @@ class PagesThumbnailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, private val chaptersLoader: ChaptersLoader, + private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase, ) : BaseViewModel() { private val currentPageIndex: Int = savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1 private val initialChapterId: Long = savedStateHandle[PagesThumbnailsSheet.ARG_CHAPTER_ID] ?: 0L - val manga = requireNotNull(savedStateHandle.get(PagesThumbnailsSheet.ARG_MANGA)).manga + val manga = savedStateHandle.require(PagesThumbnailsSheet.ARG_MANGA).manga private val repository = mangaRepositoryFactory.create(manga.source) private val mangaDetails = SuspendLazy { - repository.getDetails(manga).let { - chaptersLoader.chapters.clear() + doubleMangaLoadUseCase(manga).let { val b = manga.chapters?.find { ch -> ch.id == initialChapterId }?.branch - branch.emitValue(b) - it.getChapters(b)?.forEach { ch -> - chaptersLoader.chapters.put(ch.id, ch) - } + branch.value = b it.filterChapters(b) } } private var loadingJob: Job? = null private var loadingPrevJob: Job? = null private var loadingNextJob: Job? = null + private var isLoadAboveAllowed = false - val thumbnails = MutableLiveData>() - val branch = MutableLiveData() - val title = manga.title + val thumbnails = MutableStateFlow>(emptyList()) + val branch = MutableStateFlow(null) init { loadingJob = launchJob(Dispatchers.Default) { - chaptersLoader.loadSingleChapter(mangaDetails.get(), initialChapterId) + chaptersLoader.init(mangaDetails.get()) + chaptersLoader.loadSingleChapter(initialChapterId) updateList() } } + 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) @@ -78,16 +85,16 @@ 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.chapters.size() + 2) { + val pages = buildList(snapshot.size + chaptersLoader.size + 2) { if (hasPrevChapter) { add(LoadingFooter(-1)) } var previousChapterId = 0L for (page in snapshot) { if (page.chapterId != previousChapterId) { - chaptersLoader.chapters[page.chapterId]?.let { + chaptersLoader.peekChapter(page.chapterId)?.let { add(ListHeader(it.name, 0, null)) } previousChapterId = page.chapterId @@ -102,6 +109,6 @@ class PagesThumbnailsViewModel @Inject constructor( add(LoadingFooter(1)) } } - thumbnails.emitValue(pages) + thumbnails.value = pages } } 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 a7a1fb73d..b89e4788c 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 @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.remotelist.ui -import androidx.lifecycle.LiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -9,28 +8,26 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn 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.asFlowLiveData +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 @@ -39,50 +36,41 @@ 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 org.koitharu.kotatsu.core.util.ext.printStackTraceDebug 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, + private val filter: FilterCoordinator, settings: AppSettings, - dataRepository: MangaDataRepository, - private val tagHighlighter: MangaTagHighlighter, + 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: LiveData> - get() = filter.items - override val content = combine( mangaList, - listModeFlow, - createHeaderFlow(), + listMode, 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) when { error != null -> add(error.toErrorFooter()) hasNext -> add(LoadingFooter()) @@ -90,7 +78,7 @@ class RemoteListViewModel @Inject constructor( } } } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { filter.observeState() @@ -113,37 +101,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( @@ -163,64 +137,16 @@ class RemoteListViewModel @Inject constructor( e.printStackTraceDebug() listError.value = e if (!mangaList.value.isNullOrEmpty()) { - errorEvent.emitCall(e) + 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/anilist/data/ScoreFormat.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt index 1d4e04c41..47b7bcadf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt @@ -1,6 +1,6 @@ package org.koitharu.kotatsu.scrobbling.anilist.data -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug enum class ScoreFormat { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/Scrobbler.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/Scrobbler.kt index 3cd124561..52cb7a107 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/Scrobbler.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/Scrobbler.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.util.ext.findKeyByValue +import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity @@ -20,7 +21,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.util.EnumMap abstract class Scrobbler( @@ -123,7 +124,7 @@ abstract class Scrobbler( rating = rating, title = mangaInfo.name, coverUrl = mangaInfo.cover, - description = mangaInfo.descriptionHtml.parseAsHtml(), + description = mangaInfo.descriptionHtml.parseAsHtml().sanitize(), externalUrl = mangaInfo.url, ) } 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 4cc7c0963..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 @@ -19,6 +18,8 @@ import org.koitharu.kotatsu.core.ui.list.decor.TypedSpacingItemDecoration import org.koitharu.kotatsu.core.util.ext.disposeImageRequest import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService @@ -27,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(), @@ -64,8 +66,8 @@ class ScrobblerConfigActivity : BaseActivity(), viewModel.content.observe(this, listAdapter::setItems) viewModel.user.observe(this, this::onUserChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged) - viewModel.onError.observe(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - viewModel.onLoggedOut.observe(this) { + viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) + viewModel.onLoggedOut.observeEvent(this) { finishAfterTransition() } @@ -113,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/config/ScrobblerConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt index 1096123eb..029d7ce1e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt @@ -1,23 +1,24 @@ package org.koitharu.kotatsu.scrobbling.common.ui.config import android.net.Uri -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData -import org.koitharu.kotatsu.core.util.ext.emitValue +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.list.ui.model.EmptyState @@ -40,34 +41,34 @@ class ScrobblerConfigViewModel @Inject constructor( val titleResId = scrobbler.scrobblerService.titleResId - val user = MutableLiveData(null) - val onLoggedOut = SingleLiveEvent() + val user = MutableStateFlow(null) + val onLoggedOut = MutableEventFlow() val content = scrobbler.observeAllScrobblingInfo() .onStart { loadingCounter.increment() } .onFirst { loadingCounter.decrement() } - .catch { errorEvent.postCall(it) } + .catch { errorEvent.call(it) } .map { buildContentList(it) } - .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) init { scrobbler.user - .onEach { user.emitValue(it) } + .onEach { user.value = it } .launchIn(viewModelScope + Dispatchers.Default) } fun onAuthCodeReceived(authCode: String) { launchLoadingJob(Dispatchers.Default) { val newUser = scrobbler.authorize(authCode) - user.emitValue(newUser) + user.value = newUser } } fun logout() { launchLoadingJob(Dispatchers.Default) { scrobbler.logout() - user.emitValue(null) - onLoggedOut.emitCall(Unit) + user.value = null + onLoggedOut.call(Unit) } } 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 87% 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 37502d42a..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,12 +15,14 @@ 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 +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.SheetScrobblingSelectorBinding import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener @@ -33,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, @@ -56,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() @@ -72,8 +74,8 @@ class ScrobblingSelectorBottomSheet : decoration.checkedItemId = it binding.recyclerView.invalidateItemDecorations() } - viewModel.onError.observe(viewLifecycleOwner, ::onError) - viewModel.onClose.observe(viewLifecycleOwner) { + viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) + viewModel.onClose.observeEvent(viewLifecycleOwner) { dismiss() } viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index -> @@ -82,9 +84,7 @@ class ScrobblingSelectorBottomSheet : tab.select() } } - viewModel.searchQuery.observe(viewLifecycleOwner) { - binding.headerBar.subtitle = it - } + viewModel.searchQuery.observe(viewLifecycleOwner, ::onSearchQueryChanged) } override fun onDestroyView() { @@ -133,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 } @@ -153,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) { @@ -165,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) @@ -180,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() @@ -198,7 +198,6 @@ class ScrobblingSelectorBottomSheet : tab.select() } } - tabs.isVisible = true } companion object { @@ -207,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/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index c27419e5d..b965cdf9c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.scrobbling.common.ui.selector -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.RecyclerView.NO_ID @@ -9,14 +7,17 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus 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.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData -import org.koitharu.kotatsu.core.util.ext.emitValue +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.list.ui.model.ListModel @@ -26,7 +27,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import javax.inject.Inject @HiltViewModel @@ -39,7 +40,7 @@ class ScrobblingSelectorViewModel @Inject constructor( val availableScrobblers = scrobblers.filter { it.isAvailable } - val selectedScrobblerIndex = MutableLiveData(0) + val selectedScrobblerIndex = MutableStateFlow(0) private val scrobblerMangaList = MutableStateFlow>(emptyList()) private val hasNextPage = MutableStateFlow(true) @@ -51,7 +52,7 @@ class ScrobblingSelectorViewModel @Inject constructor( private val currentScrobbler: Scrobbler get() = availableScrobblers[selectedScrobblerIndex.requireValue()] - val content: LiveData> = combine( + val content: StateFlow> = combine( scrobblerMangaList, listError, hasNextPage, @@ -71,11 +72,11 @@ class ScrobblingSelectorViewModel @Inject constructor( }, ) } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - val selectedItemId = MutableLiveData(NO_ID) - val searchQuery = MutableLiveData(manga.title) - val onClose = SingleLiveEvent() + val selectedItemId = MutableStateFlow(NO_ID) + val searchQuery = MutableStateFlow(manga.title) + val onClose = MutableEventFlow() val isEmpty: Boolean get() = scrobblerMangaList.value.isEmpty() @@ -130,13 +131,13 @@ class ScrobblingSelectorViewModel @Inject constructor( if (doneJob?.isActive == true) { return } - val targetId = selectedItemId.value ?: NO_ID + val targetId = selectedItemId.value if (targetId == NO_ID) { onClose.call(Unit) } doneJob = launchJob(Dispatchers.Default) { currentScrobbler.linkManga(manga.id, targetId) - onClose.emitCall(Unit) + onClose.call(Unit) } } @@ -155,7 +156,7 @@ class ScrobblingSelectorViewModel @Inject constructor( try { val info = currentScrobbler.getScrobblingInfoOrNull(manga.id) if (info != null) { - selectedItemId.emitValue(info.targetId) + selectedItemId.value = info.targetId } } finally { loadList(append = false) 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/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt index f36639dec..cb89e0860 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt @@ -13,6 +13,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.getSerializableExtraCompat +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.showKeyboard import org.koitharu.kotatsu.databinding.ActivitySearchBinding import org.koitharu.kotatsu.parsers.model.MangaSource diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index 7c442d0b1..a1ad1f52a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -7,14 +7,16 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted 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.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.download.ui.worker.DownloadWorker +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.ListModel @@ -31,7 +33,7 @@ class SearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, repositoryFactory: MangaRepository.Factory, settings: AppSettings, - private val tagHighlighter: MangaTagHighlighter, + private val extraProvider: ListExtraProvider, downloadScheduler: DownloadWorker.Scheduler, ) : MangaListViewModel(settings, downloadScheduler) { @@ -44,7 +46,7 @@ class SearchViewModel @Inject constructor( override val content = combine( mangaList, - listModeFlow, + listMode, listError, hasNextPage, ) { list, mode, error, hasNext -> @@ -62,7 +64,7 @@ class SearchViewModel @Inject constructor( else -> { val result = ArrayList(list.size + 1) - list.toUi(result, mode, tagHighlighter) + list.toUi(result, mode, extraProvider) when { error != null -> result += error.toErrorFooter() hasNext -> result += LoadingFooter() @@ -70,7 +72,7 @@ class SearchViewModel @Inject constructor( result } } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { loadList(append = false) 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 0e0c59362..3871a0cfc 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 @@ -21,18 +21,20 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.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 import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter @@ -59,10 +61,8 @@ class MultiSearchActivity : setContentView(ActivitySearchMultiBinding.inflate(layoutInflater)) window.statusBarColor = ContextCompat.getColor(this, R.color.dim_statusbar) - val itemCLickListener = object : OnListItemClickListener { - override fun onItemClick(item: MultiSearchListModel, view: View) { - startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query.value)) - } + val itemCLickListener = OnListItemClickListener { item, view -> + startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query.value)) } val sizeResolver = ItemSizeResolver(resources, settings) val selectionDecoration = MangaSelectionDecoration(this) @@ -90,8 +90,8 @@ class MultiSearchActivity : viewModel.query.observe(this) { title = it } viewModel.list.observe(this) { adapter.items = it } - viewModel.onError.observe(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(viewBinding.recyclerView)) + viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) + viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.recyclerView)) } override fun onWindowInsetsChanged(insets: Insets) { @@ -117,8 +117,8 @@ class MultiSearchActivity : override fun onReadClick(manga: Manga, view: View) { if (!selectionController.onItemClick(manga.id)) { - val intent = ReaderActivity.newIntent(this, manga) - startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) + val intent = IntentBuilder(this).manga(manga).build() + startActivity(intent, scaleUpActivityOptionsOf(view)) } } @@ -130,7 +130,7 @@ class MultiSearchActivity : } override fun onRetryClick(error: Throwable) { - viewModel.doSearch(viewModel.query.value.orEmpty()) + viewModel.doSearch(viewModel.query.value) } override fun onUpdateFilter(tags: Set) = Unit @@ -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/multi/MultiSearchListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt index f8801ca11..8f6ed856e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt @@ -8,6 +8,7 @@ class MultiSearchListModel( val source: MangaSource, val hasMore: Boolean, val list: List, + val error: Throwable?, ) : ListModel { override fun equals(other: Any?): Boolean { @@ -19,14 +20,14 @@ class MultiSearchListModel( if (source != other.source) return false if (hasMore != other.hasMore) return false if (list != other.list) return false - - return true + return error == other.error } override fun hashCode(): Int { var result = source.hashCode() result = 31 * result + hasMore.hashCode() result = 31 * result + list.hashCode() + result = 31 * result + (error?.hashCode() ?: 0) return result } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt index 1a420e11f..06d28f85a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.search.ui.multi -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -12,27 +10,30 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus +import kotlinx.coroutines.withTimeout import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.CompositeException import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData -import org.koitharu.kotatsu.core.util.ext.emitValue +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.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.ui.model.EmptyState 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.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import javax.inject.Inject private const val MAX_PARALLELISM = 4 @@ -41,6 +42,7 @@ private const val MIN_HAS_MORE_ITEMS = 8 @HiltViewModel class MultiSearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val extraProvider: ListExtraProvider, private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, private val downloadScheduler: DownloadWorker.Scheduler, @@ -49,20 +51,17 @@ class MultiSearchViewModel @Inject constructor( private var searchJob: Job? = null private val listData = MutableStateFlow>(emptyList()) private val loadingData = MutableStateFlow(false) - private var listError = MutableStateFlow(null) - val onDownloadStarted = SingleLiveEvent() + val onDownloadStarted = MutableEventFlow() - val query = MutableLiveData(savedStateHandle.get(MultiSearchActivity.EXTRA_QUERY).orEmpty()) - val list: LiveData> = combine( + val query = MutableStateFlow(savedStateHandle.get(MultiSearchActivity.EXTRA_QUERY).orEmpty()) + val list: StateFlow> = combine( listData, loadingData, - listError, - ) { list, loading, error -> + ) { list, loading -> when { list.isEmpty() -> listOf( when { loading -> LoadingState - error != null -> error.toErrorState(canRetry = true) else -> EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, @@ -75,10 +74,10 @@ class MultiSearchViewModel @Inject constructor( loading -> list + LoadingFooter() else -> list } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { - doSearch(query.value.orEmpty()) + doSearch(query.value) } fun getItems(ids: Set): Set { @@ -98,15 +97,12 @@ class MultiSearchViewModel @Inject constructor( searchJob = launchJob(Dispatchers.Default) { prevJob?.cancelAndJoin() try { - listError.value = null listData.value = emptyList() loadingData.value = true - query.emitValue(q) + query.value = q searchImpl(q) } catch (e: CancellationException) { throw e - } catch (e: Throwable) { - listError.value = e } finally { loadingData.value = false } @@ -116,7 +112,7 @@ class MultiSearchViewModel @Inject constructor( fun download(items: Set) { launchJob(Dispatchers.Default) { downloadScheduler.schedule(items) - onDownloadStarted.emitCall(Unit) + onDownloadStarted.call(Unit) } } @@ -126,35 +122,28 @@ class MultiSearchViewModel @Inject constructor( val deferredList = sources.map { source -> async(dispatcher) { runCatchingCancellable { - val list = mangaRepositoryFactory.create(source).getList(offset = 0, query = q) - .toUi(ListMode.GRID, null) - if (list.isNotEmpty()) { - MultiSearchListModel(source, list.size > MIN_HAS_MORE_ITEMS, list) - } else { - null + withTimeout(8_000) { + mangaRepositoryFactory.create(source).getList(offset = 0, query = q) + .toUi(ListMode.GRID, extraProvider) } - }.onFailure { - it.printStackTraceDebug() - } + }.fold( + onSuccess = { list -> + if (list.isEmpty()) { + null + } else { + MultiSearchListModel(source, list.size > MIN_HAS_MORE_ITEMS, list, null) + } + }, + onFailure = { error -> + error.printStackTraceDebug() + MultiSearchListModel(source, true, emptyList(), error) + }, + ) } } - - val errors = ArrayList() for (deferred in deferredList) { - deferred.await() - .onSuccess { item -> - if (item != null) { - listData.update { x -> x + item } - } - }.onFailure { - errors.add(it) - } - } - if (listData.value.isEmpty()) { - when (errors.size) { - 0 -> Unit - 1 -> throw errors[0] - else -> throw CompositeException(errors) + deferred.await()?.let { item -> + listData.update { x -> x + item } } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt index 2fdcf1b90..9bd049052 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.search.ui.multi.adapter +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView.RecycledViewPool @@ -10,6 +11,8 @@ 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.ui.list.decor.SpacingItemDecoration +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemListGroupBinding import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration @@ -46,5 +49,7 @@ fun searchResultsAD( binding.buttonMore.isVisible = item.hasMore adapter.notifyDataSetChanged() adapter.items = item.list + binding.recyclerView.isGone = item.list.isEmpty() + binding.textViewError.textAndVisible = item.error?.getDisplayMessage(context.resources) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt index bc77594e9..804e00f19 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -12,6 +12,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter import javax.inject.Inject 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 9862e195f..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 @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.search.ui.suggestion -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -17,10 +16,9 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.prefs.observeAsLiveData +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.core.util.ext.emitValue import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.domain.MangaSearchRepository @@ -42,13 +40,13 @@ class SearchSuggestionViewModel @Inject constructor( private val query = MutableStateFlow("") private var suggestionJob: Job? = null - val isIncognitoModeEnabled = settings.observeAsLiveData( - context = viewModelScope.coroutineContext + Dispatchers.Default, + val isIncognitoModeEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_INCOGNITO_MODE, valueProducer = { isIncognitoModeEnabled }, ) - val suggestion = MutableLiveData>() + val suggestion = MutableStateFlow>(emptyList()) init { setupSuggestion() @@ -98,7 +96,7 @@ class SearchSuggestionViewModel @Inject constructor( buildSearchSuggestion(searchQuery, hiddenSources) }.distinctUntilChanged() .onEach { - suggestion.emitValue(it) + suggestion.value = it }.launchIn(viewModelScope + Dispatchers.Default) } @@ -137,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/AppearanceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt index 468ffb0fa..5905e7f17 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt @@ -13,7 +13,6 @@ import androidx.core.app.LocaleManagerCompat import androidx.core.view.postDelayed import androidx.preference.ListPreference import androidx.preference.Preference -import androidx.preference.TwoStatePreference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings @@ -26,7 +25,6 @@ import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.parsers.util.toTitleCase -import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.utils.ActivityListPreference import org.koitharu.kotatsu.settings.utils.SliderPreference import java.util.Locale @@ -50,12 +48,10 @@ class AppearanceSettingsFragment : true } } - preferenceScreen?.findPreference(AppSettings.KEY_LIST_MODE)?.run { + findPreference(AppSettings.KEY_LIST_MODE)?.run { entryValues = ListMode.values().names() setDefaultValueCompat(ListMode.GRID.name) } - findPreference(AppSettings.KEY_PROTECT_APP) - ?.isChecked = !settings.appPassword.isNullOrEmpty() findPreference(AppSettings.KEY_APP_LOCALE)?.run { initLocalePicker(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -93,34 +89,12 @@ class AppearanceSettingsFragment : postRestart() } - AppSettings.KEY_APP_PASSWORD -> { - findPreference(AppSettings.KEY_PROTECT_APP) - ?.isChecked = !settings.appPassword.isNullOrEmpty() - } - AppSettings.KEY_APP_LOCALE -> { AppCompatDelegate.setApplicationLocales(settings.appLocales) } } } - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - AppSettings.KEY_PROTECT_APP -> { - val pref = (preference as? TwoStatePreference ?: return false) - if (pref.isChecked) { - pref.isChecked = false - startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) - } else { - settings.appPassword = null - } - true - } - - else -> super.onPreferenceTreeClick(preference) - } - } - private fun postRestart() { view?.postDelayed(400) { activityRecreationHandle.recreateAll() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt similarity index 55% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt index e062e6800..0bcd68106 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt @@ -3,67 +3,42 @@ package org.koitharu.kotatsu.settings import android.content.SharedPreferences import android.os.Bundle import android.view.View -import androidx.preference.ListPreference import androidx.preference.Preference -import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.cache.ContentCache -import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.dialog.StorageSelectDialog import org.koitharu.kotatsu.core.util.ext.getStorageName -import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.io.File -import java.net.Proxy import javax.inject.Inject @AndroidEntryPoint -class ContentSettingsFragment : - BasePreferenceFragment(R.string.content), +class DownloadsSettingsFragment : + BasePreferenceFragment(R.string.downloads), SharedPreferences.OnSharedPreferenceChangeListener, StorageSelectDialog.OnStorageSelectListener { @Inject lateinit var storageManager: LocalStorageManager - @Inject - lateinit var contentCache: ContentCache - @Inject lateinit var downloadsScheduler: DownloadWorker.Scheduler override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_content) - findPreference(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled - findPreference(AppSettings.KEY_DOH)?.run { - entryValues = arrayOf( - DoHProvider.NONE, - DoHProvider.GOOGLE, - DoHProvider.CLOUDFLARE, - DoHProvider.ADGUARD, - ).names() - setDefaultValueCompat(DoHProvider.NONE.name) - } + addPreferencesFromResource(R.xml.pref_downloads) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() - findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( - if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled, - ) - bindRemoteSourcesSummary() - bindProxySummary() settings.subscribe(this) } @@ -78,29 +53,9 @@ class ContentSettingsFragment : findPreference(key)?.bindStorageName() } - AppSettings.KEY_SUGGESTIONS -> { - findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( - if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled, - ) - } - - AppSettings.KEY_SOURCES_HIDDEN -> { - bindRemoteSourcesSummary() - } - AppSettings.KEY_DOWNLOADS_WIFI -> { updateDownloadsConstraints() } - - AppSettings.KEY_SSL_BYPASS -> { - Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show() - } - - AppSettings.KEY_PROXY_TYPE, - AppSettings.KEY_PROXY_ADDRESS, - AppSettings.KEY_PROXY_PORT -> { - bindProxySummary() - } } } @@ -131,26 +86,6 @@ class ContentSettingsFragment : } } - private fun bindRemoteSourcesSummary() { - findPreference(AppSettings.KEY_REMOTE_SOURCES)?.run { - val total = settings.remoteMangaSources.size - summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total) - } - } - - private fun bindProxySummary() { - findPreference(AppSettings.KEY_PROXY)?.run { - val type = settings.proxyType - val address = settings.proxyAddress - val port = settings.proxyPort - summary = if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) { - context.getString(R.string.disabled) - } else { - "$type $address:$port" - } - } - } - private fun updateDownloadsConstraints() { val preference = findPreference(AppSettings.KEY_DOWNLOADS_WIFI) viewLifecycleScope.launch { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/NetworkSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/NetworkSettingsFragment.kt new file mode 100644 index 000000000..48d6f6407 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/NetworkSettingsFragment.kt @@ -0,0 +1,79 @@ +package org.koitharu.kotatsu.settings + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.View +import androidx.preference.ListPreference +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.cache.ContentCache +import org.koitharu.kotatsu.core.network.DoHProvider +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat +import org.koitharu.kotatsu.parsers.util.names +import java.net.Proxy +import javax.inject.Inject + +@AndroidEntryPoint +class NetworkSettingsFragment : + BasePreferenceFragment(R.string.network), + SharedPreferences.OnSharedPreferenceChangeListener { + + @Inject + lateinit var contentCache: ContentCache + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_network) + findPreference(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled + findPreference(AppSettings.KEY_DOH)?.run { + entryValues = arrayOf( + DoHProvider.NONE, + DoHProvider.GOOGLE, + DoHProvider.CLOUDFLARE, + DoHProvider.ADGUARD, + ).names() + setDefaultValueCompat(DoHProvider.NONE.name) + } + bindProxySummary() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_SSL_BYPASS -> { + Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show() + } + + AppSettings.KEY_PROXY_TYPE, + AppSettings.KEY_PROXY_ADDRESS, + AppSettings.KEY_PROXY_PORT -> { + bindProxySummary() + } + } + } + + private fun bindProxySummary() { + findPreference(AppSettings.KEY_PROXY)?.run { + val type = settings.proxyType + val address = settings.proxyAddress + val port = settings.proxyPort + summary = if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) { + context.getString(R.string.disabled) + } else { + "$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/RootSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt index 7d737ee50..63c2a1d75 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt @@ -1,12 +1,56 @@ package org.koitharu.kotatsu.settings +import android.content.SharedPreferences import android.os.Bundle +import android.view.View +import androidx.annotation.StringRes +import androidx.preference.Preference +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -class RootSettingsFragment : BasePreferenceFragment(R.string.settings) { +class RootSettingsFragment : BasePreferenceFragment(0), SharedPreferences.OnSharedPreferenceChangeListener { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_root) + bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language) + bindPreferenceSummary("reader", R.string.read_mode, R.string.scale_mode, R.string.switch_pages) + bindPreferenceSummary("network", R.string.proxy, R.string.dns_over_https, R.string.prefetch_content) + bindPreferenceSummary("userdata", R.string.protect_application, R.string.backup_restore, R.string.data_deletion) + bindPreferenceSummary("downloads", R.string.manga_save_location, R.string.downloads_wifi_only) + bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings) + bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking) + findPreference("about")?.summary = getString(R.string.app_version, BuildConfig.VERSION_NAME) + bindRemoteSourcesSummary() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_SOURCES_HIDDEN -> { + bindRemoteSourcesSummary() + } + } + } + + private fun bindPreferenceSummary(key: String, @StringRes vararg items: Int) { + findPreference(key)?.summary = items.joinToString { getString(it) } + } + + private fun bindRemoteSourcesSummary() { + findPreference(AppSettings.KEY_REMOTE_SOURCES)?.run { + val total = settings.remoteMangaSources.size + summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total) + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt index 04bbd96ad..74e04a2a2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt @@ -3,8 +3,10 @@ package org.koitharu.kotatsu.settings import android.accounts.AccountManager import android.content.ActivityNotFoundException import android.content.Intent +import android.content.SharedPreferences import android.net.Uri import android.os.Bundle +import android.view.View import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -23,11 +25,12 @@ import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import javax.inject.Inject @AndroidEntryPoint -class ServicesSettingsFragment : BasePreferenceFragment(R.string.services) { +class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), + SharedPreferences.OnSharedPreferenceChangeListener { @Inject lateinit var shikimoriRepository: ShikimoriRepository @@ -43,6 +46,17 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_services) + bindSuggestionsSummary() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() } override fun onResume() { @@ -53,6 +67,13 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services) { bindSyncSummary() } + override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_SUGGESTIONS -> bindSuggestionsSummary() + } + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_SHIKIMORI -> { @@ -156,4 +177,10 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services) { findPreference(AppSettings.KEY_SYNC_SETTINGS)?.isEnabled = account != null } } + + private fun bindSuggestionsSummary() { + findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( + if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled, + ) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt index 9b608aa96..7af612d70 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -6,7 +6,9 @@ import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem +import android.view.ViewGroup.MarginLayoutParams import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -22,10 +24,12 @@ import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.core.util.ext.isScrolledToTop +import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.settings.about.AboutSettingsFragment +import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesListFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment @@ -43,10 +47,18 @@ class SettingsActivity : super.onCreate(savedInstanceState) setContentView(ActivitySettingsBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - - if (supportFragmentManager.findFragmentById(R.id.container) == null) { + val isMasterDetails = viewBinding.containerMaster != null + val fm = supportFragmentManager + val currentFragment = fm.findFragmentById(R.id.container) + if (currentFragment == null || (isMasterDetails && currentFragment is RootSettingsFragment)) { openDefaultFragment() } + if (isMasterDetails && fm.findFragmentById(R.id.container_master) == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.container_master, RootSettingsFragment()) + } + } } override fun onTitleChanged(title: CharSequence?, color: Int) { @@ -90,7 +102,6 @@ class SettingsActivity : } } - @Suppress("DEPRECATION") override fun onPreferenceStartFragment( caller: PreferenceFragmentCompat, pref: Preference, @@ -98,37 +109,47 @@ class SettingsActivity : val fm = supportFragmentManager val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false) fragment.arguments = pref.extras - fragment.setTargetFragment(caller, 0) - openFragment(fragment) + openFragment(fragment, isFromRoot = caller is RootSettingsFragment) return true } override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.appbar.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.container.updatePadding( + viewBinding.root.updatePadding( left = insets.left, right = insets.right, ) + viewBinding.cardDetails?.updateLayoutParams { + bottomMargin = marginStart + insets.bottom + } + } + + fun setSectionTitle(title: CharSequence?) { + viewBinding.textViewHeader?.apply { + textAndVisible = title + } ?: setTitle(title ?: getString(R.string.settings)) } - fun openFragment(fragment: Fragment) { + fun openFragment(fragment: Fragment, isFromRoot: Boolean) { + val hasFragment = supportFragmentManager.findFragmentById(R.id.container) != null + val isMasterDetail = viewBinding.containerMaster != null supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container, fragment) - setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - addToBackStack(null) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_MATCH_ACTIVITY_OPEN) + if (!isMasterDetail || (hasFragment && !isFromRoot)) { + addToBackStack(null) + } } } private fun openDefaultFragment() { + val hasMaster = viewBinding.containerMaster != null val fragment = when (intent?.action) { ACTION_READER -> ReaderSettingsFragment() ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() - ACTION_HISTORY -> HistorySettingsFragment() + ACTION_HISTORY -> UserDataSettingsFragment() ACTION_TRACKER -> TrackerSettingsFragment() + ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment() ACTION_SOURCE -> SourceSettingsFragment.newInstance( intent.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL, ) @@ -138,12 +159,12 @@ class SettingsActivity : when (intent.data?.host) { HOST_ABOUT -> AboutSettingsFragment() HOST_SYNC_SETTINGS -> SyncSettingsFragment() - else -> SettingsHeadersFragment() + else -> null } } - else -> SettingsHeadersFragment() - } + else -> null + } ?: if (hasMaster) AppearanceSettingsFragment() else RootSettingsFragment() supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container, fragment) @@ -158,6 +179,7 @@ class SettingsActivity : private const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY" private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST" + private const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS" private const val EXTRA_SOURCE = "source" private const val HOST_ABOUT = "about" private const val HOST_SYNC_SETTINGS = "sync-settings" @@ -184,6 +206,10 @@ class SettingsActivity : Intent(context, SettingsActivity::class.java) .setAction(ACTION_MANAGE_SOURCES) + fun newDownloadsSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_MANAGE_DOWNLOADS) + fun newSourceSettingsIntent(context: Context, source: MangaSource) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_SOURCE) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt deleted file mode 100644 index 53e1af007..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentTransaction -import androidx.fragment.app.commit -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceHeaderFragmentCompat -import androidx.slidingpanelayout.widget.SlidingPaneLayout -import org.koitharu.kotatsu.R - -class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLayout.PanelSlideListener { - - private var currentTitle: CharSequence? = null - - override fun onCreatePreferenceHeader(): PreferenceFragmentCompat = RootSettingsFragment() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - slidingPaneLayout.addPanelSlideListener(this) - } - - override fun onPanelSlide(panel: View, slideOffset: Float) = Unit - - override fun onPanelOpened(panel: View) { - activity?.title = currentTitle ?: getString(R.string.settings) - } - - override fun onPanelClosed(panel: View) { - activity?.setTitle(R.string.settings) - } - - fun setTitle(title: CharSequence?) { - currentTitle = title - if (slidingPaneLayout.width != 0 && slidingPaneLayout.isOpen) { - activity?.title = title - } - } - - fun openFragment(fragment: Fragment) { - childFragmentManager.commit { - setReorderingAllowed(true) - replace(androidx.preference.R.id.preferences_detail, fragment) - setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - addToBackStack(null) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt deleted file mode 100644 index 565b8395d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt +++ /dev/null @@ -1,133 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.os.Bundle -import android.view.View -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.preference.Preference -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.awaitViewLifecycle -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.requireSerializable -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.parsers.exception.AuthRequiredException -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity -import org.koitharu.kotatsu.util.ext.printStackTraceDebug -import javax.inject.Inject - -@AndroidEntryPoint -class SourceSettingsFragment : BasePreferenceFragment(0) { - - @Inject - lateinit var mangaRepositoryFactory: MangaRepository.Factory - - private lateinit var source: MangaSource - private var repository: RemoteMangaRepository? = null - private val exceptionResolver = ExceptionResolver(this) - - override fun onCreate(savedInstanceState: Bundle?) { - source = requireArguments().requireSerializable(EXTRA_SOURCE) - repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository - super.onCreate(savedInstanceState) - } - - override fun onResume() { - super.onResume() - setTitle(source.title) - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.sharedPreferencesName = source.name - val repo = repository ?: return - addPreferencesFromResource(R.xml.pref_source) - addPreferencesFromRepository(repo) - - findPreference(KEY_AUTH)?.run { - val authProvider = repo.getAuthProvider() - isVisible = authProvider != null - isEnabled = authProvider?.isAuthorized == false - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - findPreference(KEY_AUTH)?.run { - if (isVisible) { - loadUsername(viewLifecycleOwner, this) - } - } - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - KEY_AUTH -> { - startActivity(SourceAuthActivity.newIntent(preference.context, source)) - true - } - - else -> super.onPreferenceTreeClick(preference) - } - } - - private fun loadUsername(owner: LifecycleOwner, preference: Preference) = owner.lifecycleScope.launch { - runCatchingCancellable { - preference.summary = null - withContext(Dispatchers.Default) { - requireNotNull(repository?.getAuthProvider()?.getUsername()) - } - }.onSuccess { username -> - preference.title = getString(R.string.logged_in_as, username) - }.onFailure { error -> - when { - error is AuthRequiredException -> Unit - ExceptionResolver.canResolve(error) -> { - ensureActive() - Snackbar.make( - listView ?: return@onFailure, - error.getDisplayMessage(preference.context.resources), - Snackbar.LENGTH_INDEFINITE, - ).setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) } - .show() - } - - else -> preference.summary = error.getDisplayMessage(preference.context.resources) - } - error.printStackTraceDebug() - } - } - - private fun resolveError(error: Throwable) { - view ?: return - viewLifecycleScope.launch { - if (exceptionResolver.resolve(error)) { - val pref = findPreference(KEY_AUTH) ?: return@launch - val lifecycleOwner = awaitViewLifecycle() - loadUsername(lifecycleOwner, pref) - } - } - } - - companion object { - - private const val KEY_AUTH = "auth" - - private const val EXTRA_SOURCE = "source" - - fun newInstance(source: MangaSource) = SourceSettingsFragment().withArgs(1) { - putSerializable(EXTRA_SOURCE, source) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/UserDataSettingsFragment.kt similarity index 72% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/UserDataSettingsFragment.kt index 34b2fdb37..d280e2bd0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/UserDataSettingsFragment.kt @@ -1,9 +1,16 @@ package org.koitharu.kotatsu.settings +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri import android.os.Bundle import android.view.View +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.Lifecycle import androidx.preference.Preference +import androidx.preference.TwoStatePreference import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -14,21 +21,27 @@ import kotlinx.coroutines.runInterruptible import okhttp3.Cache import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar -import org.koitharu.kotatsu.core.os.ShortcutsUpdater +import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.awaitStateAtLeast import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import org.koitharu.kotatsu.settings.backup.BackupDialogFragment +import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment +import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject @AndroidEntryPoint -class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) { +class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privacy), + SharedPreferences.OnSharedPreferenceChangeListener, + ActivityResultCallback { @Inject lateinit var trackerRepo: TrackingRepository @@ -46,12 +59,19 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach lateinit var cache: Cache @Inject - lateinit var shortcutsUpdater: ShortcutsUpdater + lateinit var appShortcutManager: AppShortcutManager + + private val backupSelectCall = registerForActivityResult( + ActivityResultContracts.OpenDocument(), + this, + ) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_history) + addPreferencesFromResource(R.xml.pref_user_data) findPreference(AppSettings.KEY_SHORTCUTS)?.isVisible = - shortcutsUpdater.isDynamicShortcutsAvailable() + appShortcutManager.isDynamicShortcutsAvailable() + findPreference(AppSettings.KEY_PROTECT_APP) + ?.isChecked = !settings.appPassword.isNullOrEmpty() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -73,6 +93,12 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach pref.summary = pref.context.resources.getQuantityString(R.plurals.items, items, items) } } + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() } override fun onPreferenceTreeClick(preference: Preference): Boolean { @@ -116,10 +142,53 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach true } + AppSettings.KEY_BACKUP -> { + BackupDialogFragment.show(childFragmentManager) + true + } + + AppSettings.KEY_RESTORE -> { + try { + backupSelectCall.launch(arrayOf("*/*")) + } catch (e: ActivityNotFoundException) { + e.printStackTraceDebug() + Snackbar.make( + listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, + ).show() + } + true + } + + AppSettings.KEY_PROTECT_APP -> { + val pref = (preference as? TwoStatePreference ?: return false) + if (pref.isChecked) { + pref.isChecked = false + startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) + } else { + settings.appPassword = null + } + true + } + else -> super.onPreferenceTreeClick(preference) } } + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_APP_PASSWORD -> { + findPreference(AppSettings.KEY_PROTECT_APP) + ?.isChecked = !settings.appPassword.isNullOrEmpty() + } + } + } + + override fun onActivityResult(result: Uri?) { + if (result != null) { + RestoreDialogFragment.show(childFragmentManager, result) + } + } + private fun clearCache(preference: Preference, cache: CacheDir) { val ctx = preference.context.applicationContext viewLifecycleScope.launch { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt index 94051d98b..cda8c9fdd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt @@ -18,6 +18,8 @@ import org.koitharu.kotatsu.core.logs.FileLogger import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ShareHelper +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import javax.inject.Inject @AndroidEntryPoint @@ -45,7 +47,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { viewModel.isLoading.observe(viewLifecycleOwner) { findPreference(AppSettings.KEY_APP_UPDATE)?.isEnabled = !it } - viewModel.onUpdateAvailable.observe(viewLifecycleOwner, ::onUpdateAvailable) + viewModel.onUpdateAvailable.observeEvent(viewLifecycleOwner, ::onUpdateAvailable) } override fun onPreferenceTreeClick(preference: Preference): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsViewModel.kt index 3ee1ea9e3..638ce1e8e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsViewModel.kt @@ -4,7 +4,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import javax.inject.Inject @HiltViewModel @@ -13,7 +14,7 @@ class AboutSettingsViewModel @Inject constructor( ) : BaseViewModel() { val isUpdateSupported = appUpdateRepository.isUpdateSupported() - val onUpdateAvailable = SingleLiveEvent() + val onUpdateAvailable = MutableEventFlow() fun checkForUpdates() { launchLoadingJob { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt index 19362ebb2..caf25f180 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt @@ -7,12 +7,15 @@ import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.AlertDialogFragment 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.databinding.DialogProgressBinding import java.io.File import java.io.FileOutputStream @@ -46,8 +49,8 @@ class BackupDialogFragment : AlertDialogFragment() { binding.textViewSubtitle.setText(R.string.processing_) viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) - viewModel.onBackupDone.observe(viewLifecycleOwner, this::onBackupDone) - viewModel.onError.observe(viewLifecycleOwner, this::onError) + viewModel.onBackupDone.observeEvent(viewLifecycleOwner, this::onBackupDone) + viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) } override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { @@ -99,6 +102,10 @@ class BackupDialogFragment : AlertDialogFragment() { companion object { - const val TAG = "BackupDialogFragment" + private const val TAG = "BackupDialogFragment" + + fun show(fm: FragmentManager) { + BackupDialogFragment().show(fm, TAG) + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt deleted file mode 100644 index fbeb28594..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.content.ActivityNotFoundException -import android.net.Uri -import android.os.Bundle -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.preference.Preference -import com.google.android.material.snackbar.Snackbar -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.util.ext.printStackTraceDebug - -class BackupSettingsFragment : - BasePreferenceFragment(R.string.backup_restore), - ActivityResultCallback { - - private val backupSelectCall = registerForActivityResult( - ActivityResultContracts.OpenDocument(), - this, - ) - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_backup) - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - AppSettings.KEY_BACKUP -> { - BackupDialogFragment().show(childFragmentManager, BackupDialogFragment.TAG) - true - } - - AppSettings.KEY_RESTORE -> { - try { - backupSelectCall.launch(arrayOf("*/*")) - } catch (e: ActivityNotFoundException) { - e.printStackTraceDebug() - Snackbar.make( - listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, - ).show() - } - true - } - - else -> super.onPreferenceTreeClick(preference) - } - } - - override fun onActivityResult(result: Uri?) { - RestoreDialogFragment.newInstance(result ?: return) - .show(childFragmentManager, BackupDialogFragment.TAG) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt index 80d9705e3..8c60b385f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -1,13 +1,14 @@ package org.koitharu.kotatsu.settings.backup import android.content.Context -import androidx.lifecycle.MutableLiveData import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import java.io.File import javax.inject.Inject @@ -17,8 +18,8 @@ class BackupViewModel @Inject constructor( @ApplicationContext context: Context, ) : BaseViewModel() { - val progress = MutableLiveData(-1f) - val onBackupDone = SingleLiveEvent() + val progress = MutableStateFlow(-1f) + val onBackupDone = MutableEventFlow() init { launchLoadingJob { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt index c1c7c7817..c697a63fa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint @@ -12,6 +13,8 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.core.ui.AlertDialogFragment 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.DialogProgressBinding import kotlin.math.roundToInt @@ -32,8 +35,8 @@ class RestoreDialogFragment : AlertDialogFragment() { binding.textViewSubtitle.setText(R.string.preparing_) viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) - viewModel.onRestoreDone.observe(viewLifecycleOwner, this::onRestoreDone) - viewModel.onError.observe(viewLifecycleOwner, this::onError) + viewModel.onRestoreDone.observeEvent(viewLifecycleOwner, this::onRestoreDone) + viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) } override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { @@ -85,10 +88,12 @@ class RestoreDialogFragment : AlertDialogFragment() { companion object { const val ARG_FILE = "file" - const val TAG = "RestoreDialogFragment" + private const val TAG = "RestoreDialogFragment" - fun newInstance(uri: Uri) = RestoreDialogFragment().withArgs(1) { - putString(ARG_FILE, uri.toString()) + fun show(fm: FragmentManager, uri: Uri) { + RestoreDialogFragment().withArgs(1) { + putString(ARG_FILE, uri.toString()) + }.show(fm, TAG) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index cea8a7dfa..01b55c951 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -1,18 +1,19 @@ package org.koitharu.kotatsu.settings.backup import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.backup.BackupEntry import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipInput import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.toUriOrNull import java.io.File import java.io.FileNotFoundException @@ -25,8 +26,8 @@ class RestoreViewModel @Inject constructor( @ApplicationContext context: Context, ) : BaseViewModel() { - val progress = MutableLiveData(-1f) - val onRestoreDone = SingleLiveEvent() + val progress = MutableStateFlow(-1f) + val onRestoreDone = MutableEventFlow() init { launchLoadingJob { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt index ac16391c6..bec4ad77f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt @@ -11,6 +11,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.DialogOnboardBinding import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt index 3f631be7b..1a2a935a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.settings.newsources import androidx.core.os.LocaleListCompat -import androidx.lifecycle.MutableLiveData import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.getLocaleTitle import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -15,13 +15,9 @@ class NewSourcesViewModel @Inject constructor( private val settings: AppSettings, ) : BaseViewModel() { - val sources = MutableLiveData>() + val sources = MutableStateFlow>(buildList()) private val initialList = settings.newSources - init { - buildList() - } - fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { if (isEnabled) { settings.hiddenSources -= item.source.name @@ -34,10 +30,10 @@ class NewSourcesViewModel @Inject constructor( settings.markKnownSources(initialList) } - private fun buildList() { + private fun buildList(): List { val locales = LocaleListCompat.getDefault().mapToSet { it.language } val pendingHidden = HashSet() - sources.value = initialList.map { + return initialList.map { val locale = it.locale val isEnabledByLocale = locale == null || locale in locales if (!isEnabledByLocale) { @@ -49,9 +45,10 @@ class NewSourcesViewModel @Inject constructor( isEnabled = isEnabledByLocale, isDraggable = false, ) - } - if (pendingHidden.isNotEmpty()) { - settings.hiddenSources += pendingHidden + }.also { + if (pendingHidden.isNotEmpty()) { + settings.hiddenSources += pendingHidden + } } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt index 295a08a90..14ac39e54 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt @@ -10,6 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.showAllowStateLoss import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.DialogOnboardBinding diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt index 4380d0163..4707da3cc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.settings.onboard import androidx.core.os.LocaleListCompat -import androidx.lifecycle.MutableLiveData import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -26,7 +26,7 @@ class OnboardViewModel @Inject constructor( private val selectedLocales = locales.keys.toMutableSet() - val list = MutableLiveData?>() + val list = MutableStateFlow?>(null) init { if (settings.isSourcesSelected) { 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 955158643..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 @@ -18,6 +18,8 @@ import androidx.core.view.isVisible 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 @@ -45,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/protect/ProtectSetupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt index 387c34848..73b5597b3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt @@ -2,12 +2,16 @@ package org.koitharu.kotatsu.settings.protect import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.parsers.util.md5 import javax.inject.Inject @@ -20,10 +24,10 @@ class ProtectSetupViewModel @Inject constructor( val isSecondStep = firstPassword.map { it != null - }.asFlowLiveData(viewModelScope.coroutineContext, false) - val onPasswordSet = SingleLiveEvent() - val onPasswordMismatch = SingleLiveEvent() - val onClearText = SingleLiveEvent() + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) + val onPasswordSet = MutableEventFlow() + val onPasswordMismatch = MutableEventFlow() + val onClearText = MutableEventFlow() val isBiometricEnabled get() = settings.isBiometricProtectionEnabled diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsExt.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt similarity index 80% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsExt.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt index 5137fe95e..f0c239c23 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SourceSettingsExt.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings +package org.koitharu.kotatsu.settings.sources import android.view.inputmethod.EditorInfo import androidx.preference.EditTextPreference @@ -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( @@ -44,7 +48,7 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_TEXT, hint = key.defaultValue, - validator = null, + validator = HeaderValidator(), ), ) setTitle(R.string.user_agent) @@ -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/sources/SourceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt new file mode 100644 index 000000000..2fbae0771 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt @@ -0,0 +1,100 @@ +package org.koitharu.kotatsu.settings.sources + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +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.viewLifecycleScope +import org.koitharu.kotatsu.core.util.ext.withArgs +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity + +@AndroidEntryPoint +class SourceSettingsFragment : BasePreferenceFragment(0) { + + private val viewModel: SourceSettingsViewModel by viewModels() + private val exceptionResolver = ExceptionResolver(this) + + override fun onResume() { + super.onResume() + setTitle(viewModel.source.title) + viewModel.onResume() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.sharedPreferencesName = viewModel.source.name + addPreferencesFromResource(R.xml.pref_source) + addPreferencesFromRepository(viewModel.repository) + + findPreference(KEY_AUTH)?.run { + val authProvider = viewModel.repository.getAuthProvider() + isVisible = authProvider != null + isEnabled = authProvider?.isAuthorized == false + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.username.observe(viewLifecycleOwner) { username -> + findPreference(KEY_AUTH)?.summary = username?.let { + getString(R.string.logged_in_as, it) + } + } + viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) + viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + findPreference(KEY_AUTH)?.isEnabled = !isLoading + } + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + KEY_AUTH -> { + startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source)) + true + } + + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun onError(error: Throwable) { + val snackbar = Snackbar.make( + listView ?: return, + error.getDisplayMessage(resources), + Snackbar.LENGTH_INDEFINITE, + ) + if (ExceptionResolver.canResolve(error)) { + snackbar.setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) } + } + snackbar.show() + } + + private fun resolveError(error: Throwable) { + view ?: return + viewLifecycleScope.launch { + if (exceptionResolver.resolve(error)) { + viewModel.onResume() + } + } + } + + companion object { + + private const val KEY_AUTH = "auth" + + const val EXTRA_SOURCE = "source" + + fun newInstance(source: MangaSource) = SourceSettingsFragment().withArgs(1) { + putSerializable(EXTRA_SOURCE, source) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt new file mode 100644 index 000000000..173c53b69 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt @@ -0,0 +1,47 @@ +package org.koitharu.kotatsu.settings.sources + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.parsers.exception.AuthRequiredException +import org.koitharu.kotatsu.parsers.model.MangaSource +import javax.inject.Inject + +@HiltViewModel +class SourceSettingsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + mangaRepositoryFactory: MangaRepository.Factory, +) : BaseViewModel() { + + val source = savedStateHandle.require(SourceSettingsFragment.EXTRA_SOURCE) + val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository + + val username = MutableStateFlow(null) + private var usernameLoadJob: Job? = null + + init { + loadUsername() + } + + fun onResume() { + if (usernameLoadJob?.isActive != true) { + loadUsername() + } + } + + private fun loadUsername() { + launchLoadingJob(Dispatchers.Default) { + try { + username.value = null + username.value = repository.getAuthProvider()?.getUsername() + } catch (_: AuthRequiredException) { + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListFragment.kt index d34733efa..413bea9a2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListFragment.kt @@ -21,11 +21,11 @@ import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.getItem +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.settings.SettingsActivity -import org.koitharu.kotatsu.settings.SettingsHeadersFragment -import org.koitharu.kotatsu.settings.SourceSettingsFragment import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem @@ -64,7 +64,7 @@ class SourcesListFragment : viewModel.items.observe(viewLifecycleOwner) { sourcesAdapter.items = it } - viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) + viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) addMenuProvider(SourcesMenuProvider()) } @@ -88,8 +88,7 @@ class SourcesListFragment : override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) { val fragment = SourceSettingsFragment.newInstance(item.source) - (parentFragment as? SettingsHeadersFragment)?.openFragment(fragment) - ?: (activity as? SettingsActivity)?.openFragment(fragment) + (activity as? SettingsActivity)?.openFragment(fragment, false) } override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt index 9eaf346c2..5ba8cd767 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesListViewModel.kt @@ -1,10 +1,10 @@ package org.koitharu.kotatsu.settings.sources import androidx.core.os.LocaleListCompat -import androidx.lifecycle.MutableLiveData import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -14,11 +14,12 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleHandle -import org.koitharu.kotatsu.core.util.SingleLiveEvent +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.map -import org.koitharu.kotatsu.core.util.ext.move import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.parsers.util.move import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import java.util.Locale @@ -35,8 +36,8 @@ class SourcesListViewModel @Inject constructor( private val settings: AppSettings, ) : BaseViewModel() { - val items = MutableLiveData>(emptyList()) - val onActionDone = SingleLiveEvent() + val items = MutableStateFlow>(emptyList()) + val onActionDone = MutableEventFlow() private val mutex = Mutex() private val expandedGroups = HashSet() @@ -49,7 +50,7 @@ class SourcesListViewModel @Inject constructor( } fun reorderSources(oldPos: Int, newPos: Int): Boolean { - val snapshot = items.value?.toMutableList() ?: return false + val snapshot = items.value.toMutableList() if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false launchAtomicJob(Dispatchers.Default) { @@ -63,7 +64,7 @@ class SourcesListViewModel @Inject constructor( } fun canReorder(oldPos: Int, newPos: Int): Boolean { - val snapshot = items.value?.toMutableList() ?: return false + val snapshot = items.value.toMutableList() if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false return (snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled == true } @@ -81,7 +82,7 @@ class SourcesListViewModel @Inject constructor( val rollback = ReversibleHandle { setEnabled(source, true) } - onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback)) + onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) } buildList() } @@ -126,21 +127,19 @@ class SourcesListViewModel @Inject constructor( val hiddenSources = settings.hiddenSources val query = searchQuery if (!query.isNullOrEmpty()) { - items.postValue( - sources.mapNotNull { - if (!it.title.contains(query, ignoreCase = true)) { - return@mapNotNull null - } - SourceConfigItem.SourceItem( - source = it, - summary = it.getLocaleTitle(), - isEnabled = it.name !in hiddenSources, - isDraggable = false, - ) - }.ifEmpty { - listOf(SourceConfigItem.EmptySearchResult) - }, - ) + items.value = sources.mapNotNull { + if (!it.title.contains(query, ignoreCase = true)) { + return@mapNotNull null + } + SourceConfigItem.SourceItem( + source = it, + summary = it.getLocaleTitle(), + isEnabled = it.name !in hiddenSources, + isDraggable = false, + ) + }.ifEmpty { + listOf(SourceConfigItem.EmptySearchResult) + } return@runInterruptible } val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) { @@ -188,7 +187,7 @@ class SourcesListViewModel @Inject constructor( } } } - items.postValue(result) + items.value = result } private fun getLocaleTitle(localeKey: String?): String? { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt index 51ea765ef..f0ecacc4d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt @@ -15,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.ui.BaseFragment +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.databinding.FragmentToolsBinding import org.koitharu.kotatsu.download.ui.list.DownloadsActivity diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsViewModel.kt index b977b7996..9d0289bb0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsViewModel.kt @@ -1,14 +1,16 @@ package org.koitharu.kotatsu.settings.tools -import androidx.lifecycle.LiveData -import androidx.lifecycle.asLiveData -import androidx.lifecycle.liveData 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.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsLiveData +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalStorageManager @@ -18,21 +20,18 @@ import javax.inject.Inject @HiltViewModel class ToolsViewModel @Inject constructor( private val storageManager: LocalStorageManager, - private val appUpdateRepository: AppUpdateRepository, private val settings: AppSettings, + appUpdateRepository: AppUpdateRepository, ) : BaseViewModel() { val appUpdate = appUpdateRepository.observeAvailableUpdate() - .asLiveData(viewModelScope.coroutineContext) - val storageUsage: LiveData = liveData( - context = viewModelScope.coroutineContext + Dispatchers.Default, - ) { + val storageUsage: StateFlow = flow { emit(collectStorageUsage()) - } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - val isIncognitoModeEnabled = settings.observeAsLiveData( - context = viewModelScope.coroutineContext + Dispatchers.Default, + val isIncognitoModeEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_INCOGNITO_MODE, valueProducer = { isIncognitoModeEnabled }, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt index d60d185ad..fb369ee01 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt @@ -23,6 +23,7 @@ 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.core.util.ext.observe import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels @@ -55,14 +56,13 @@ class TrackerSettingsFragment : } } } + updateDozePreference() updateCategoriesEnabled() } override fun onResume() { super.onResume() - findPreference(KEY_IGNORE_DOZE)?.run { - isVisible = isDozeIgnoreAvailable(context) - } + updateDozePreference() updateNotificationsSummary() } @@ -81,8 +81,7 @@ class TrackerSettingsFragment : when (key) { AppSettings.KEY_TRACKER_NOTIFICATIONS -> updateNotificationsSummary() AppSettings.KEY_TRACK_SOURCES, - AppSettings.KEY_TRACKER_ENABLED, - -> updateCategoriesEnabled() + AppSettings.KEY_TRACKER_ENABLED -> updateCategoriesEnabled() } } @@ -103,9 +102,7 @@ class TrackerSettingsFragment : true } - else -> { - super.onPreferenceTreeClick(preference) - } + else -> super.onPreferenceTreeClick(preference) } AppSettings.KEY_TRACK_CATEGORIES -> { @@ -122,6 +119,12 @@ class TrackerSettingsFragment : } } + private fun updateDozePreference() { + findPreference(KEY_IGNORE_DOZE)?.run { + isVisible = isDozeIgnoreAvailable(context) + } + } + private fun updateNotificationsSummary() { val pref = findPreference(AppSettings.KEY_NOTIFICATIONS_SETTINGS) ?: return pref.setSummary( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt index b6d0f8106..cd6713272 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt @@ -1,15 +1,14 @@ package org.koitharu.kotatsu.settings.tracker -import androidx.lifecycle.MutableLiveData import androidx.room.InvalidationTracker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import okio.Closeable import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES import org.koitharu.kotatsu.core.db.removeObserverAsync import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.emitValue import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject @@ -19,7 +18,7 @@ class TrackerSettingsViewModel @Inject constructor( private val database: MangaDatabase, ) : BaseViewModel() { - val categoriesCount = MutableLiveData(null) + val categoriesCount = MutableStateFlow(null) init { updateCategoriesCount() @@ -32,7 +31,7 @@ class TrackerSettingsViewModel @Inject constructor( private fun updateCategoriesCount() { launchJob(Dispatchers.Default) { - categoriesCount.emitValue(repository.getCategoriesCount()) + categoriesCount.value = repository.getCategoriesCount() } } 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 028a22020..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,21 +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() @@ -29,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 @@ -41,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/tracker/categories/TrackerCategoriesConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigViewModel.kt index 8ec72c44c..51daf79b1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigViewModel.kt @@ -4,9 +4,11 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import javax.inject.Inject @@ -16,7 +18,7 @@ class TrackerCategoriesConfigViewModel @Inject constructor( ) : BaseViewModel() { val content = favouritesRepository.observeCategories() - .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) private var updateJob: Job? = null diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ActivityListPreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ActivityListPreference.kt index 950c69c2c..43e38d720 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ActivityListPreference.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ActivityListPreference.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.util.AttributeSet import androidx.preference.ListPreference -import org.koitharu.kotatsu.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug class ActivityListPreference : ListPreference { 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/utils/validation/HeaderValidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/HeaderValidator.kt new file mode 100644 index 000000000..36891f980 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/HeaderValidator.kt @@ -0,0 +1,27 @@ +package org.koitharu.kotatsu.settings.utils.validation + +import okhttp3.Headers +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.util.EditTextValidator + +class HeaderValidator : EditTextValidator() { + + private val headers = Headers.Builder() + + override fun validate(text: String): ValidationResult { + val trimmed = text.trim() + if (trimmed.isEmpty()) { + return ValidationResult.Success + } + return if (!validateImpl(trimmed)) { + ValidationResult.Failed(context.getString(R.string.invalid_value_message)) + } else { + ValidationResult.Success + } + } + + private fun validateImpl(value: String): Boolean = runCatching { + headers[CommonHeaders.USER_AGENT] = value + }.isSuccess +} 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/domain/ShelfRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfContentObserveUseCase.kt similarity index 67% rename from app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfContentObserveUseCase.kt index a534c90cb..b2104c199 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfContentObserveUseCase.kt @@ -1,9 +1,5 @@ package org.koitharu.kotatsu.shelf.domain -import dagger.Reusable -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.combine @@ -18,19 +14,19 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toMangaList -import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.shelf.domain.model.ShelfContent import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject -@Reusable -class ShelfRepository @Inject constructor( +@Suppress("SameParameterValue") +class ShelfContentObserveUseCase @Inject constructor( private val localMangaRepository: LocalMangaRepository, private val historyRepository: HistoryRepository, private val trackingRepository: TrackingRepository, @@ -39,21 +35,21 @@ class ShelfRepository @Inject constructor( @LocalStorageChanges private val localStorageChanges: SharedFlow, ) { - fun observeShelfContent(): Flow = combine( - historyRepository.observeAllWithHistory(), - observeLocalManga(SortOrder.UPDATED), + operator fun invoke(): Flow = combine( + historyRepository.observeAll(20), + observeLocalManga(SortOrder.UPDATED, 20), observeFavourites(), trackingRepository.observeUpdatedManga(), - suggestionRepository.observeAll(16), + suggestionRepository.observeAll(20), ) { history, local, favorites, updated, suggestions -> ShelfContent(history, favorites, updated, local, suggestions) } - private fun observeLocalManga(sortOrder: SortOrder): Flow> { + private fun observeLocalManga(sortOrder: SortOrder, limit: Int): Flow> { return localStorageChanges .onStart { emit(null) } .mapLatest { - localMangaRepository.getList(0, null, sortOrder) + localMangaRepository.getList(0, null, sortOrder).take(limit) }.distinctUntilChanged() } @@ -69,23 +65,6 @@ class ShelfRepository @Inject constructor( } } - suspend fun deleteLocalManga(ids: Set) { - val list = localMangaRepository.getList(0, null, null) - .filter { x -> x.id in ids } - coroutineScope { - list.map { manga -> - async { - val original = localMangaRepository.getRemoteManga(manga) - if (localMangaRepository.delete(manga)) { - runCatchingCancellable { - historyRepository.deleteOrSwap(manga, original) - } - } - } - }.awaitAll() - } - } - private fun observeCategoriesContent( categories: List, ) = combine>, Map>>( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfContent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/model/ShelfContent.kt similarity index 83% rename from app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfContent.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/model/ShelfContent.kt index 1319cf991..39c88010f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfContent.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/model/ShelfContent.kt @@ -1,13 +1,12 @@ -package org.koitharu.kotatsu.shelf.domain +package org.koitharu.kotatsu.shelf.domain.model import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.parsers.model.Manga class ShelfContent( - val history: List, + val history: List, val favourites: Map>, - val updated: Map, + val updated: List, val local: List, val suggestions: List, ) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfSection.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/model/ShelfSection.kt similarity index 62% rename from app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfSection.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/model/ShelfSection.kt index d798c09e5..ecdd14b7d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/ShelfSection.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/domain/model/ShelfSection.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.shelf.domain +package org.koitharu.kotatsu.shelf.domain.model enum class ShelfSection { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt index cb499a611..50e2073af 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt @@ -21,6 +21,8 @@ import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentShelfBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver @@ -84,9 +86,9 @@ class ShelfFragment : addMenuProvider(ShelfMenuProvider(binding.root.context, childFragmentManager, viewModel)) viewModel.content.observe(viewLifecycleOwner, ::onListChanged) - viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) - viewModel.onDownloadStarted.observe(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView)) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) + viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) + viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView)) } override fun onSaveInstanceState(outState: Bundle) { 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/shelf/ui/ShelfViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt index e48113db3..16ea86002 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt @@ -1,12 +1,15 @@ package org.koitharu.kotatsu.shelf.ui import androidx.collection.ArraySet -import androidx.lifecycle.LiveData 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.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.os.NetworkState @@ -15,13 +18,11 @@ import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData +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.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.history.domain.MangaWithHistory -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.EmptyState @@ -30,42 +31,43 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toGridModel import org.koitharu.kotatsu.list.ui.model.toUi +import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.shelf.domain.ShelfContent -import org.koitharu.kotatsu.shelf.domain.ShelfRepository -import org.koitharu.kotatsu.shelf.domain.ShelfSection +import org.koitharu.kotatsu.shelf.domain.ShelfContentObserveUseCase +import org.koitharu.kotatsu.shelf.domain.model.ShelfContent +import org.koitharu.kotatsu.shelf.domain.model.ShelfSection import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel import org.koitharu.kotatsu.sync.domain.SyncController -import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject @HiltViewModel class ShelfViewModel @Inject constructor( - private val repository: ShelfRepository, private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, - private val trackingRepository: TrackingRepository, private val settings: AppSettings, private val downloadScheduler: DownloadWorker.Scheduler, + private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + private val listExtraProvider: ListExtraProvider, + shelfContentObserveUseCase: ShelfContentObserveUseCase, syncController: SyncController, networkState: NetworkState, -) : BaseViewModel(), ListExtraProvider { +) : BaseViewModel() { - val onActionDone = SingleLiveEvent() - val onDownloadStarted = SingleLiveEvent() + val onActionDone = MutableEventFlow() + val onDownloadStarted = MutableEventFlow() - val content: LiveData> = combine( + val content: StateFlow> = combine( settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections }, settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }, settings.observeAsFlow(AppSettings.KEY_SUGGESTIONS) { isSuggestionsEnabled }, networkState, - repository.observeShelfContent(), + shelfContentObserveUseCase(), ) { sections, isTrackerEnabled, isSuggestionsEnabled, isConnected, content -> mapList(content, isTrackerEnabled, isSuggestionsEnabled, sections, isConnected) }.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { launchJob(Dispatchers.Default) { @@ -73,29 +75,13 @@ class ShelfViewModel @Inject constructor( } } - 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 - } - } - fun removeFromFavourites(category: FavouriteCategory, ids: Set) { if (ids.isEmpty()) { return } launchJob(Dispatchers.Default) { val handle = favouritesRepository.removeFromCategory(category.id, ids) - onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle)) + onActionDone.call(ReversibleAction(R.string.removed_from_favourites, handle)) } } @@ -105,14 +91,14 @@ class ShelfViewModel @Inject constructor( } launchJob(Dispatchers.Default) { val handle = historyRepository.delete(ids) - onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle)) + onActionDone.call(ReversibleAction(R.string.removed_from_history, handle)) } } fun deleteLocal(ids: Set) { launchLoadingJob(Dispatchers.Default) { - repository.deleteLocalManga(ids) - onActionDone.emitCall(ReversibleAction(R.string.removal_completed, null)) + deleteLocalMangaUseCase(ids) + onActionDone.call(ReversibleAction(R.string.removal_completed, null)) } } @@ -125,12 +111,12 @@ class ShelfViewModel @Inject constructor( historyRepository.deleteAfter(minDate) R.string.removed_from_history } - onActionDone.emitCall(ReversibleAction(stringRes, null)) + onActionDone.call(ReversibleAction(stringRes, null)) } } fun getManga(ids: Set): Set { - val snapshot = content.value ?: return emptySet() + val snapshot = content.value val result = ArraySet(ids.size) for (section in snapshot) { if (section !is ShelfSectionModel) { @@ -151,7 +137,7 @@ class ShelfViewModel @Inject constructor( fun download(items: Set) { launchJob(Dispatchers.Default) { downloadScheduler.schedule(items) - onDownloadStarted.emitCall(Unit) + onDownloadStarted.call(Unit) } } @@ -189,7 +175,7 @@ class ShelfViewModel @Inject constructor( when (section) { ShelfSection.HISTORY -> mapHistory( result, - content.history.filter { it.manga.source == MangaSource.LOCAL }, + content.history.filter { it.source == MangaSource.LOCAL }, ) ShelfSection.LOCAL -> mapLocal(result, content.local) @@ -217,17 +203,14 @@ class ShelfViewModel @Inject constructor( private suspend fun mapHistory( destination: MutableList, - list: List, + list: List, ) { if (list.isEmpty()) { return } - val showPercent = settings.isReadingIndicatorsEnabled destination += ShelfSectionModel.History( - items = list.map { (manga, history) -> - val counter = getCounter(manga.id) - val percent = if (showPercent) history.percent else PROGRESS_NONE - manga.toGridModel(counter, percent) + items = list.map { manga -> + manga.toGridModel(listExtraProvider) }, showAllButtonText = R.string.show_all, ) @@ -235,16 +218,15 @@ class ShelfViewModel @Inject constructor( private suspend fun mapUpdated( destination: MutableList, - updated: Map, + updated: List, ) { if (updated.isEmpty()) { return } - val showPercent = settings.isReadingIndicatorsEnabled + settings.isReadingIndicatorsEnabled destination += ShelfSectionModel.Updated( - items = updated.map { (manga, counter) -> - val percent = if (showPercent) getProgress(manga.id) else PROGRESS_NONE - manga.toGridModel(counter, percent) + items = updated.map { manga -> + manga.toGridModel(listExtraProvider) }, showAllButtonText = R.string.show_all, ) @@ -258,7 +240,7 @@ class ShelfViewModel @Inject constructor( return } destination += ShelfSectionModel.Local( - items = local.toUi(ListMode.GRID, this, null), + items = local.toUi(ListMode.GRID, listExtraProvider), showAllButtonText = R.string.show_all, ) } @@ -271,7 +253,7 @@ class ShelfViewModel @Inject constructor( return } destination += ShelfSectionModel.Suggestions( - items = suggestions.toUi(ListMode.GRID, this, null), + items = suggestions.toUi(ListMode.GRID, listExtraProvider), showAllButtonText = R.string.show_all, ) } @@ -286,7 +268,7 @@ class ShelfViewModel @Inject constructor( for ((category, list) in favourites) { if (list.isNotEmpty()) { destination += ShelfSectionModel.Favourites( - items = list.toUi(ListMode.GRID, this, null), + items = list.toUi(ListMode.GRID, listExtraProvider), category = category, showAllButtonText = R.string.show_all, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt index 6697d0367..e8c8f346f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt @@ -13,6 +13,7 @@ import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.ActivityShelfSettingsBinding import com.google.android.material.R as materialR @@ -40,8 +41,6 @@ class ShelfSettingsActivity : it.attachToRecyclerView(this) } } - - viewModel.content.observe(this) { settingsAdapter.items = it } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt index 2897e3eb6..736aa125f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt @@ -10,7 +10,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding import org.koitharu.kotatsu.databinding.ItemShelfSectionDraggableBinding -import org.koitharu.kotatsu.shelf.domain.ShelfSection +import org.koitharu.kotatsu.shelf.domain.model.ShelfSection @SuppressLint("ClickableViewAccessibility") fun shelfSectionAD( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsItemModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsItemModel.kt index e75f329de..c45d6bee3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsItemModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsItemModel.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.shelf.ui.config import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.shelf.domain.ShelfSection +import org.koitharu.kotatsu.shelf.domain.model.ShelfSection sealed interface ShelfSettingsItemModel : ListModel { @@ -19,9 +19,7 @@ sealed interface ShelfSettingsItemModel : ListModel { other as Section if (section != other.section) return false - if (isChecked != other.isChecked) return false - - return true + return isChecked == other.isChecked } override fun hashCode(): Int { @@ -45,9 +43,7 @@ sealed interface ShelfSettingsItemModel : ListModel { if (id != other.id) return false if (title != other.title) return false - if (isChecked != other.isChecked) return false - - return true + return isChecked == other.isChecked } override fun hashCode(): Int { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsViewModel.kt index 6d9ac2bde..4df55a1d1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsViewModel.kt @@ -4,15 +4,17 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.parsers.util.move -import org.koitharu.kotatsu.shelf.domain.ShelfSection +import org.koitharu.kotatsu.shelf.domain.model.ShelfSection import javax.inject.Inject @HiltViewModel @@ -27,7 +29,7 @@ class ShelfSettingsViewModel @Inject constructor( favouritesRepository.observeCategories(), ) { sections, isTrackerEnabled, categories -> buildList(sections, isTrackerEnabled, categories) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) private var updateJob: Job? = null @@ -57,7 +59,7 @@ class ShelfSettingsViewModel @Inject constructor( } fun reorderSections(oldPos: Int, newPos: Int): Boolean { - val snapshot = content.value?.toMutableList() ?: return false + val snapshot = content.value.toMutableList() snapshot.move(oldPos, newPos) settings.shelfSections = snapshot.sections() return true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt index acf3fe5d1..40338a217 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt @@ -3,15 +3,17 @@ package org.koitharu.kotatsu.suggestions.ui import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.download.ui.worker.DownloadWorker +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.LoadingState @@ -24,13 +26,13 @@ import javax.inject.Inject class SuggestionsViewModel @Inject constructor( repository: SuggestionRepository, settings: AppSettings, - private val tagHighlighter: MangaTagHighlighter, + private val extraProvider: ListExtraProvider, downloadScheduler: DownloadWorker.Scheduler, ) : MangaListViewModel(settings, downloadScheduler) { override val content = combine( repository.observeAll(), - listModeFlow, + listMode, ) { list, mode -> when { list.isEmpty() -> listOf( @@ -42,7 +44,7 @@ class SuggestionsViewModel @Inject constructor( ), ) - else -> list.toUi(mode, tagHighlighter) + else -> list.toUi(mode, extraProvider) } }.onStart { loadingCounter.increment() @@ -50,10 +52,7 @@ class SuggestionsViewModel @Inject constructor( loadingCounter.decrement() }.catch { emit(listOf(it.toErrorState(canRetry = false))) - }.asFlowLiveData( - viewModelScope.coroutineContext + Dispatchers.Default, - listOf(LoadingState), - ) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) override fun onRefresh() = Unit diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index e96b56930..f4b6a24fa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -41,22 +41,23 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.almostEquals import org.koitharu.kotatsu.core.util.ext.asArrayList import org.koitharu.kotatsu.core.util.ext.flatten +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.takeMostFrequent import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.trySetForeground import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist -import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.util.concurrent.TimeUnit import kotlin.math.pow import kotlin.random.Random @@ -227,7 +228,7 @@ class SuggestionsWorker @AssistedInject constructor( ).toBitmapOrNull(), ) setSmallIcon(R.drawable.ic_stat_suggestion) - val description = manga.description?.parseAsHtml(HtmlCompat.FROM_HTML_MODE_COMPACT) + val description = manga.description?.parseAsHtml(HtmlCompat.FROM_HTML_MODE_COMPACT)?.sanitize() if (!description.isNullOrBlank()) { val style = NotificationCompat.BigTextStyle() style.bigText( @@ -262,7 +263,7 @@ class SuggestionsWorker @AssistedInject constructor( PendingIntentCompat.getActivity( applicationContext, id + 2, - ReaderActivity.newIntent(applicationContext, manga), + IntentBuilder(applicationContext).manga(manga).build(), 0, false, ), 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 1ef83da4a..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 @@ -22,6 +22,8 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding import org.koitharu.kotatsu.sync.data.SyncSettings import org.koitharu.kotatsu.sync.domain.SyncAuthResult @@ -52,10 +54,10 @@ class SyncAuthActivity : BaseActivity(), View.OnClickLi onBackPressedDispatcher.addCallback(pageBackCallback) - viewModel.onTokenObtained.observe(this, ::onTokenReceived) - viewModel.onError.observe(this, ::onError) + 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/SyncAuthViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt index 6aea529a5..fd02134fa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt @@ -2,13 +2,14 @@ package org.koitharu.kotatsu.sync.ui import android.accounts.AccountManager import android.content.Context -import androidx.lifecycle.MutableLiveData import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.SingleLiveEvent +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.domain.SyncAuthResult @@ -20,9 +21,9 @@ class SyncAuthViewModel @Inject constructor( private val api: SyncAuthApi, ) : BaseViewModel() { - val onAccountAlreadyExists = SingleLiveEvent() - val onTokenObtained = SingleLiveEvent() - val host = MutableLiveData("") + val onAccountAlreadyExists = MutableEventFlow() + val onTokenObtained = MutableEventFlow() + val host = MutableStateFlow("") private val defaultHost = context.getString(R.string.sync_host_default) @@ -31,7 +32,7 @@ class SyncAuthViewModel @Inject constructor( val am = AccountManager.get(context) val accounts = am.getAccountsByType(context.getString(R.string.account_type_sync)) if (accounts.isNotEmpty()) { - onAccountAlreadyExists.emitCall(Unit) + onAccountAlreadyExists.call(Unit) } } } @@ -40,8 +41,8 @@ class SyncAuthViewModel @Inject constructor( val hostValue = host.value.ifNullOrEmpty { defaultHost } launchLoadingJob(Dispatchers.Default) { val token = api.authenticate(hostValue, email, password) - val result = SyncAuthResult(host.value.orEmpty(), email, password, token) - onTokenObtained.emitCall(result) + val result = SyncAuthResult(host.value, email, password, token) + onTokenObtained.call(result) } } } 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/data/TracksDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TracksDao.kt index 0915582a1..bd9b03a83 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TracksDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TracksDao.kt @@ -34,9 +34,8 @@ abstract class TracksDao { abstract fun observeNewChapters(mangaId: Long): Flow @Transaction - @MapInfo(valueColumn = "chapters_new") - @Query("SELECT manga.*, chapters_new FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC") - abstract fun observeUpdatedManga(): Flow> + @Query("SELECT manga.* FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC") + abstract fun observeUpdatedManga(): Flow> @Query("DELETE FROM tracks") abstract suspend fun clear() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt index 31df0a6be..fcc8f996a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt @@ -1,16 +1,16 @@ package org.koitharu.kotatsu.tracker.domain import androidx.annotation.VisibleForTesting -import javax.inject.Inject import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackingItem +import javax.inject.Inject class Tracker @Inject constructor( private val settings: AppSettings, @@ -114,9 +114,11 @@ class Tracker @Inject constructor( newChapters.isEmpty() -> { MangaUpdates(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId) } + newChapters.size == chapters.size -> { MangaUpdates(manga, emptyList(), isValid = false) } + else -> { MangaUpdates(manga, newChapters, isValid = true) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index a6b3b8518..482b25fa1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -44,9 +45,9 @@ class TrackingRepository @Inject constructor( return db.tracksDao.observeNewChapters().map { list -> list.count { it > 0 } } } - fun observeUpdatedManga(): Flow> { + fun observeUpdatedManga(): Flow> { return db.tracksDao.observeUpdatedManga() - .map { x -> x.mapKeys { it.key.toManga() } } + .mapItems { it.toManga() } .distinctUntilChanged() } 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 b06b063be..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 @@ -18,6 +18,8 @@ import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.list.decor.TypedSpacingItemDecoration import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentFeedBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.MangaListListener @@ -76,11 +78,11 @@ class FeedFragment : ) viewModel.content.observe(viewLifecycleOwner, this::onListChanged) - viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onFeedCleared.observe(viewLifecycleOwner) { + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) + viewModel.onFeedCleared.observeEvent(viewLifecycleOwner) { onFeedCleared() } - TrackWorker.getIsRunningLiveData(binding.root.context.applicationContext) + TrackWorker.observeIsRunning(binding.root.context.applicationContext) .observe(viewLifecycleOwner, this::onIsTrackerRunningChanged) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt index 5c80f4a09..ce1c89b3a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt @@ -4,12 +4,15 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.DateTimeAgo -import org.koitharu.kotatsu.core.util.SingleLiveEvent -import org.koitharu.kotatsu.core.util.asFlowLiveData +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel @@ -32,7 +35,7 @@ class FeedViewModel @Inject constructor( private val limit = MutableStateFlow(PAGE_SIZE) private val isReady = AtomicBoolean(false) - val onFeedCleared = SingleLiveEvent() + val onFeedCleared = MutableEventFlow() val content = repository.observeTrackingLog(limit) .map { list -> if (list.isEmpty()) { @@ -48,7 +51,7 @@ class FeedViewModel @Inject constructor( isReady.set(true) list.mapList() } - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) fun clearFeed(clearCounters: Boolean) { launchLoadingJob(Dispatchers.Default) { @@ -56,7 +59,7 @@ class FeedViewModel @Inject constructor( if (clearCounters) { repository.clearCounters() } - onFeedCleared.emitCall(Unit) + onFeedCleared.call(Unit) } } 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/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt index bb46a503f..a8c437c6b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt @@ -3,27 +3,23 @@ package org.koitharu.kotatsu.tracker.ui.updates import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.history.data.HistoryRepository +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.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toGridModel -import org.koitharu.kotatsu.list.ui.model.toListDetailedModel -import org.koitharu.kotatsu.list.ui.model.toListModel -import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.tracker.domain.TrackingRepository import javax.inject.Inject @@ -32,16 +28,16 @@ class UpdatesViewModel @Inject constructor( private val repository: TrackingRepository, private val settings: AppSettings, private val historyRepository: HistoryRepository, - private val tagHighlighter: MangaTagHighlighter, + private val extraProvider: ListExtraProvider, downloadScheduler: DownloadWorker.Scheduler, ) : MangaListViewModel(settings, downloadScheduler) { override val content = combine( repository.observeUpdatedManga(), - listModeFlow, - ) { mangaMap, mode -> + listMode, + ) { mangaList, mode -> when { - mangaMap.isEmpty() -> listOf( + mangaList.isEmpty() -> listOf( EmptyState( icon = R.drawable.ic_empty_history, textPrimary = R.string.text_history_holder_primary, @@ -50,7 +46,7 @@ class UpdatesViewModel @Inject constructor( ), ) - else -> mapList(mangaMap, mode) + else -> mangaList.toUi(mode, extraProvider) } }.onStart { loadingCounter.increment() @@ -58,24 +54,9 @@ class UpdatesViewModel @Inject constructor( loadingCounter.decrement() }.catch { emit(listOf(it.toErrorState(canRetry = false))) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) override fun onRefresh() = Unit override fun onRetry() = Unit - - private suspend fun mapList( - mangaMap: Map, - mode: ListMode, - ): List { - val showPercent = settings.isReadingIndicatorsEnabled - return mangaMap.map { (manga, counter) -> - val percent = if (showPercent) historyRepository.getProgress(manga.id) else PROGRESS_NONE - when (mode) { - ListMode.LIST -> manga.toListModel(counter, percent) - ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent, tagHighlighter) - ListMode.GRID -> manga.toGridModel(counter, percent) - } - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 06e8d25c7..934a19ac0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -11,8 +11,7 @@ import androidx.core.app.NotificationCompat.VISIBILITY_SECRET import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.hilt.work.HiltWorker -import androidx.lifecycle.LiveData -import androidx.lifecycle.map +import androidx.lifecycle.asFlow import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker @@ -35,6 +34,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R @@ -263,11 +264,13 @@ class TrackWorker @AssistedInject constructor( WorkManager.getInstance(context).enqueue(request) } - fun getIsRunningLiveData(context: Context): LiveData { + fun observeIsRunning(context: Context): Flow { val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build() - return WorkManager.getInstance(context).getWorkInfosLiveData(query).map { works -> - works.any { x -> x.state == WorkInfo.State.RUNNING } - } + return WorkManager.getInstance(context).getWorkInfosLiveData(query) + .asFlow() + .map { works -> + works.any { x -> x.state == WorkInfo.State.RUNNING } + } } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt index fc263b5d7..83266be1e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.replaceWith diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt index 250892e28..a5052477a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt @@ -4,7 +4,7 @@ import android.content.Intent import android.widget.RemoteViewsService import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.data.HistoryRepository import javax.inject.Inject @AndroidEntryPoint diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt index 15e453361..e51c3c912 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt @@ -17,6 +17,8 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.widget.shelf.adapter.CategorySelectAdapter import org.koitharu.kotatsu.widget.shelf.model.CategoryItem @@ -57,7 +59,7 @@ class ShelfConfigActivity : viewModel.checkedId = config.categoryId viewModel.content.observe(this, this::onContentChanged) - viewModel.onError.observe(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) + viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) } override fun onClick(v: View) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt index 1208cb5dd..3a1b4deca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt @@ -1,13 +1,15 @@ package org.koitharu.kotatsu.widget.shelf -import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.widget.shelf.model.CategoryItem import javax.inject.Inject @@ -19,7 +21,7 @@ class ShelfConfigViewModel @Inject constructor( private val selectedCategoryId = MutableStateFlow(0L) - val content: LiveData> = combine( + val content: StateFlow> = combine( favouritesRepository.observeCategories(), selectedCategoryId, ) { categories, selectedId -> @@ -29,7 +31,11 @@ class ShelfConfigViewModel @Inject constructor( CategoryItem(it.id, it.title, selectedId == it.id) } list - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - var checkedId: Long by selectedCategoryId::value + var checkedId: Long + get() = selectedCategoryId.value + set(value) { + selectedCategoryId.value = value + } } diff --git a/app/src/main/res/drawable/ic_data_privacy.xml b/app/src/main/res/drawable/ic_data_privacy.xml new file mode 100644 index 000000000..0697d42ee --- /dev/null +++ b/app/src/main/res/drawable/ic_data_privacy.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_error_small.xml b/app/src/main/res/drawable/ic_error_small.xml new file mode 100644 index 000000000..964da43cc --- /dev/null +++ b/app/src/main/res/drawable/ic_error_small.xml @@ -0,0 +1,11 @@ + + + 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"> + android:pathData="M22,13.5C22,15.26 20.7,16.72 19,16.96V20A2,2 0 0,1 17,22H13.2V21.7A2.7,2.7 0 0,0 10.5,19C9,19 7.8,20.21 7.8,21.7V22H4A2,2 0 0,1 2,20V16.2H2.3C3.79,16.2 5,15 5,13.5C5,12 3.79,10.8 2.3,10.8H2V7A2,2 0 0,1 4,5H7.04C7.28,3.3 8.74,2 10.5,2C12.26,2 13.72,3.3 13.96,5H17A2,2 0 0,1 19,7V10.04C20.7,10.28 22,11.74 22,13.5M17,15H18.5A1.5,1.5 0 0,0 20,13.5A1.5,1.5 0 0,0 18.5,12H17V7H12V5.5A1.5,1.5 0 0,0 10.5,4A1.5,1.5 0 0,0 9,5.5V7H4V9.12C5.76,9.8 7,11.5 7,13.5C7,15.5 5.75,17.2 4,17.88V20H6.12C6.8,18.25 8.5,17 10.5,17C12.5,17 14.2,18.25 14.88,20H17V15Z" /> diff --git a/app/src/main/res/drawable/ic_list_create.xml b/app/src/main/res/drawable/ic_sort.xml similarity index 64% rename from app/src/main/res/drawable/ic_list_create.xml rename to app/src/main/res/drawable/ic_sort.xml index dce66429c..944c99fd4 100644 --- a/app/src/main/res/drawable/ic_list_create.xml +++ b/app/src/main/res/drawable/ic_sort.xml @@ -7,6 +7,6 @@ android:viewportWidth="24" android:viewportHeight="24"> - \ No newline at end of file + android:fillColor="#000000" + android:pathData="M18 21L14 17H17V7H14L18 3L22 7H19V17H22M2 19V17H12V19M2 13V11H9V13M2 7V5H6V7H2Z" /> + diff --git a/app/src/main/res/drawable/ic_tap.xml b/app/src/main/res/drawable/ic_tap.xml new file mode 100644 index 000000000..d0bdf143c --- /dev/null +++ b/app/src/main/res/drawable/ic_tap.xml @@ -0,0 +1,12 @@ + + + + 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 @@ + + + + + + + + + +