diff --git a/.editorconfig b/.editorconfig
index e39c63dcd..999845632 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -5,11 +5,11 @@ charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
-insert_final_newline = false
+insert_final_newline = true
max_line_length = 120
tab_width = 4
# noinspection EditorConfigKeyCorrectness
-disabled_rules=no-wildcard-imports,no-unused-imports
+disabled_rules = no-wildcard-imports, no-unused-imports
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_continuation_indent_size = 4
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 2bcd23609..38963f65d 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -3,6 +3,9 @@
+
+
+
diff --git a/README.md b/README.md
index 82e4fada7..a97f2a74c 100644
--- a/README.md
+++ b/README.md
@@ -21,11 +21,12 @@ Download APK directly from GitHub:
* Reading history and bookmarks
* Favourites organized by user-defined categories
* Downloading manga and reading it offline. Third-party CBZ archives also supported
-* Tablet-optimized material design UI
+* Tablet-optimized Material You UI
* Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed
* Shikimori integration (manga tracking)
* Password/fingerprint protect access to the app
+* History and favourites synchronization across devices (coming soon)
### Screenshots
diff --git a/app/build.gradle b/app/build.gradle
index 117ab9138..61a46bf01 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -3,6 +3,7 @@ plugins {
id 'kotlin-android'
id 'kotlin-kapt'
id 'kotlin-parcelize'
+ id 'dagger.hilt.android.plugin'
}
android {
@@ -28,6 +29,10 @@ android {
// define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
+
+ if (currentBranch().startsWith("feature/nextgen")) {
+ applicationIdSuffix = '.next'
+ }
}
buildTypes {
debug {
@@ -114,8 +119,13 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
- implementation 'io.insert-koin:koin-android:3.2.0'
+ implementation "com.google.dagger:hilt-android:2.42"
+ kapt "com.google.dagger:hilt-compiler:2.42"
+ implementation 'androidx.hilt:hilt-work:1.0.0'
+ kapt 'androidx.hilt:hilt-compiler:1.0.0'
+
implementation 'io.coil-kt:coil-base:2.1.0'
+ implementation 'io.coil-kt:coil-svg:2.1.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4'
@@ -134,9 +144,10 @@ dependencies {
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
- androidTestImplementation 'io.insert-koin:koin-test:3.2.0'
- androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
-}
\ No newline at end of file
+
+ androidTestImplementation 'com.google.dagger:hilt-android-testing:2.42'
+ kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.42'
+}
diff --git a/app/sampledata/covers/Forget-me-not Volume 1.jpg b/app/sampledata/covers/Forget-me-not Volume 1.jpg
new file mode 100644
index 000000000..f5bbc36e5
Binary files /dev/null and b/app/sampledata/covers/Forget-me-not Volume 1.jpg differ
diff --git a/app/sampledata/covers/Forget-me-not Volume 2.jpg b/app/sampledata/covers/Forget-me-not Volume 2.jpg
new file mode 100644
index 000000000..fea5eaaba
Binary files /dev/null and b/app/sampledata/covers/Forget-me-not Volume 2.jpg differ
diff --git a/app/sampledata/covers/La Pomme Prisoinniere.jpg b/app/sampledata/covers/La Pomme Prisoinniere.jpg
new file mode 100644
index 000000000..f82af29a7
Binary files /dev/null and b/app/sampledata/covers/La Pomme Prisoinniere.jpg differ
diff --git a/app/sampledata/covers/Momo Kanchou no Himitsu Kichi.jpg b/app/sampledata/covers/Momo Kanchou no Himitsu Kichi.jpg
new file mode 100644
index 000000000..6a5207cf7
Binary files /dev/null and b/app/sampledata/covers/Momo Kanchou no Himitsu Kichi.jpg differ
diff --git a/app/sampledata/covers/Omoide Emanon.jpg b/app/sampledata/covers/Omoide Emanon.jpg
new file mode 100644
index 000000000..b078f670a
Binary files /dev/null and b/app/sampledata/covers/Omoide Emanon.jpg differ
diff --git a/app/sampledata/covers/Sasurai Emanon Volume 1.jpg b/app/sampledata/covers/Sasurai Emanon Volume 1.jpg
new file mode 100644
index 000000000..4a185e835
Binary files /dev/null and b/app/sampledata/covers/Sasurai Emanon Volume 1.jpg differ
diff --git a/app/sampledata/covers/Sasurai Emanon Volume 2.jpg b/app/sampledata/covers/Sasurai Emanon Volume 2.jpg
new file mode 100644
index 000000000..f7f745f23
Binary files /dev/null and b/app/sampledata/covers/Sasurai Emanon Volume 2.jpg differ
diff --git a/app/sampledata/covers/Sasurai Emanon Volume 3.jpg b/app/sampledata/covers/Sasurai Emanon Volume 3.jpg
new file mode 100644
index 000000000..714538ff8
Binary files /dev/null and b/app/sampledata/covers/Sasurai Emanon Volume 3.jpg differ
diff --git a/app/sampledata/covers/Wandering Island Volume 1.jpg b/app/sampledata/covers/Wandering Island Volume 1.jpg
new file mode 100644
index 000000000..2d6edccee
Binary files /dev/null and b/app/sampledata/covers/Wandering Island Volume 1.jpg differ
diff --git a/app/sampledata/covers/Wandering Island Volume 2.jpg b/app/sampledata/covers/Wandering Island Volume 2.jpg
new file mode 100644
index 000000000..52e1e5562
Binary files /dev/null and b/app/sampledata/covers/Wandering Island Volume 2.jpg differ
diff --git a/app/sampledata/genres b/app/sampledata/genres
new file mode 100644
index 000000000..017d3ee73
--- /dev/null
+++ b/app/sampledata/genres
@@ -0,0 +1,10 @@
+Slice of Life, Mystery
+Slice of Life, Mystery
+Psychological, Romance, Comedy, Slice of Life, Supernatural
+Sci-Fi, Comedy
+Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
+Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
+Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
+Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
+Adventure, Slice of Life, Mystery
+Adventure, Slice of Life, Mystery
diff --git a/app/sampledata/titles b/app/sampledata/titles
new file mode 100644
index 000000000..9bdeb7594
--- /dev/null
+++ b/app/sampledata/titles
@@ -0,0 +1,10 @@
+Forget-me-not Vol. 1
+Forget-me-not Vol. 2
+La Pomme Prisoinniere
+Momo Kanchou no Himitsu Kichi
+Omoide Emanon
+Sasurai Emanon Vol. 1
+Sasurai Emanon Vol. 2
+Sasurai Emanon Vol. 3
+Wandering Island Vol. 1
+Wandering Island Vol. 2
diff --git a/app/src/androidTest/assets/categories/simple.json b/app/src/androidTest/assets/categories/simple.json
index 90f6ecf1a..58a2ab058 100644
--- a/app/src/androidTest/assets/categories/simple.json
+++ b/app/src/androidTest/assets/categories/simple.json
@@ -4,5 +4,6 @@
"sortKey": 1,
"order": "NEWEST",
"createdAt": 1335906000000,
- "isTrackingEnabled": true
-}
\ No newline at end of file
+ "isTrackingEnabled": true,
+ "isVisibleInLibrary": true
+}
diff --git a/app/src/androidTest/assets/kotatsu_test.bak b/app/src/androidTest/assets/kotatsu_test.bak
new file mode 100755
index 000000000..a6eae4cdc
Binary files /dev/null and b/app/src/androidTest/assets/kotatsu_test.bak differ
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt b/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt
index b9ef582c1..dbf4ec642 100644
--- a/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt
@@ -6,4 +6,4 @@ import kotlin.coroutines.suspendCoroutine
suspend fun Instrumentation.awaitForIdle() = suspendCoroutine { cont ->
waitForIdle { cont.resume(Unit) }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
index 23c8b9796..b7e4a07f5 100644
--- a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
@@ -3,10 +3,10 @@ package org.koitharu.kotatsu.core.db
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class)
class MangaDatabaseTest {
@@ -37,7 +37,7 @@ class MangaDatabaseTest {
TEST_DB,
migration.endVersion,
true,
- migration
+ migration,
).close()
}
}
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt
index ec4c04edc..4b1784bed 100644
--- a/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt
@@ -6,28 +6,40 @@ import android.os.Build
import androidx.core.content.getSystemService
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
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import org.koin.test.KoinTest
-import org.koin.test.inject
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 kotlin.test.assertEquals
-import kotlin.test.assertTrue
+@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
-class ShortcutsUpdaterTest : KoinTest {
+class ShortcutsUpdaterTest {
- private val historyRepository by inject()
- private val shortcutsUpdater by inject()
- private val database by inject()
+ @get:Rule
+ var hiltRule = HiltAndroidRule(this)
+
+ @Inject
+ lateinit var historyRepository: HistoryRepository
+
+ @Inject
+ lateinit var shortcutsUpdater: ShortcutsUpdater
+
+ @Inject
+ lateinit var database: MangaDatabase
@Before
fun setUp() {
+ hiltRule.inject()
database.clearAllTables()
}
@@ -43,7 +55,7 @@ class ShortcutsUpdaterTest : KoinTest {
chapterId = SampleData.chapter.id,
page = 4,
scroll = 2,
- percent = 0.3f
+ percent = 0.3f,
)
awaitUpdate()
@@ -62,4 +74,4 @@ class ShortcutsUpdaterTest : KoinTest {
instrumentation.awaitForIdle()
shortcutsUpdater.await()
}
-}
\ No newline at end of file
+}
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 1d0ca5498..f4448baa4 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
@@ -1,36 +1,53 @@
package org.koitharu.kotatsu.settings.backup
+import android.content.res.AssetManager
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 java.io.File
+import javax.inject.Inject
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
+import org.junit.Assert.*
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import org.koin.test.KoinTest
-import org.koin.test.get
-import org.koin.test.inject
import org.koitharu.kotatsu.SampleData
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 kotlin.test.*
+@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
-class AppBackupAgentTest : KoinTest {
+class AppBackupAgentTest {
- private val historyRepository by inject()
- private val favouritesRepository by inject()
- private val backupRepository by inject()
- private val database by inject()
+ @get:Rule
+ var hiltRule = HiltAndroidRule(this)
+
+ @Inject
+ lateinit var historyRepository: HistoryRepository
+
+ @Inject
+ lateinit var favouritesRepository: FavouritesRepository
+
+ @Inject
+ lateinit var backupRepository: BackupRepository
+
+ @Inject
+ lateinit var database: MangaDatabase
@Before
fun setUp() {
+ hiltRule.inject()
database.clearAllTables()
}
@Test
- fun testBackupRestore() = runTest {
+ fun backupAndRestore() = runTest {
val category = favouritesRepository.createCategory(
title = SampleData.favouriteCategory.title,
sortOrder = SampleData.favouriteCategory.order,
@@ -47,7 +64,10 @@ class AppBackupAgentTest : KoinTest {
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
val agent = AppBackupAgent()
- val backup = agent.createBackupFile(get(), backupRepository)
+ val backup = agent.createBackupFile(
+ context = InstrumentationRegistry.getInstrumentation().targetContext,
+ repository = backupRepository,
+ )
database.clearAllTables()
assertTrue(favouritesRepository.getAllManga().isEmpty())
@@ -59,9 +79,30 @@ class AppBackupAgentTest : KoinTest {
assertEquals(category, favouritesRepository.getCategory(category.id))
assertEquals(history, historyRepository.getOne(SampleData.manga))
- assertContentEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
+ assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags()
- assertContains(allTags, SampleData.tag)
+ assertTrue(SampleData.tag in allTags)
+ }
+
+ @Test
+ fun restoreOldBackup() {
+ val agent = AppBackupAgent()
+ val backup = File.createTempFile("backup_", ".tmp")
+ InstrumentationRegistry.getInstrumentation().context.assets
+ .open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING)
+ .use { input ->
+ backup.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ backup.inputStream().use {
+ agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
+ }
+ runTest {
+ assertEquals(6, historyRepository.observeAll().first().size)
+ assertEquals(2, favouritesRepository.observeCategories().first().size)
+ assertEquals(15, favouritesRepository.getAllManga().size)
+ }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
index a1ea460f6..37b5ebf02 100644
--- a/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
@@ -1,24 +1,39 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import javax.inject.Inject
+import junit.framework.TestCase.*
import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import org.koin.test.KoinTest
-import org.koin.test.inject
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.parsers.model.Manga
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
+@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
-class TrackerTest : KoinTest {
+class TrackerTest {
- private val repository by inject()
- private val dataRepository by inject()
- private val tracker by inject()
+ @get:Rule
+ var hiltRule = HiltAndroidRule(this)
+
+ @Inject
+ lateinit var repository: TrackingRepository
+
+ @Inject
+ lateinit var dataRepository: MangaDataRepository
+
+ @Inject
+ lateinit var tracker: Tracker
+
+ @Before
+ fun setUp() {
+ hiltRule.inject()
+ }
@Test
fun noUpdates() = runTest {
@@ -180,4 +195,4 @@ class TrackerTest : KoinTest {
dataRepository.storeManga(manga)
return manga
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 22bad7dd9..e064f604c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -77,8 +98,8 @@
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
index 23b3df2c6..c5ebfae04 100644
--- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
@@ -3,78 +3,55 @@ package org.koitharu.kotatsu
import android.app.Application
import android.content.Context
import android.os.StrictMode
+import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.strictmode.FragmentStrictMode
+import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker
+import androidx.work.Configuration
+import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
-import org.koin.android.ext.android.get
-import org.koin.android.ext.android.getKoin
-import org.koin.android.ext.koin.androidContext
-import org.koin.core.context.startKoin
-import org.koitharu.kotatsu.bookmarks.bookmarksModule
import org.koitharu.kotatsu.core.db.MangaDatabase
-import org.koitharu.kotatsu.core.db.databaseModule
-import org.koitharu.kotatsu.core.github.githubModule
-import org.koitharu.kotatsu.core.network.networkModule
import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.core.ui.uiModule
-import org.koitharu.kotatsu.details.detailsModule
-import org.koitharu.kotatsu.favourites.favouritesModule
-import org.koitharu.kotatsu.history.historyModule
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
-import org.koitharu.kotatsu.local.localModule
-import org.koitharu.kotatsu.main.mainModule
import org.koitharu.kotatsu.parsers.MangaLoaderContext
-import org.koitharu.kotatsu.reader.readerModule
-import org.koitharu.kotatsu.remotelist.remoteListModule
-import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule
-import org.koitharu.kotatsu.search.searchModule
-import org.koitharu.kotatsu.settings.settingsModule
-import org.koitharu.kotatsu.suggestions.suggestionsModule
-import org.koitharu.kotatsu.tracker.trackerModule
-import org.koitharu.kotatsu.widget.appWidgetModule
+import org.koitharu.kotatsu.utils.ext.processLifecycleScope
-class KotatsuApp : Application() {
+@HiltAndroidApp
+class KotatsuApp : Application(), Configuration.Provider {
+
+ @Inject
+ lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer>
+
+ @Inject
+ lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
+
+ @Inject
+ lateinit var database: MangaDatabase
+
+ @Inject
+ lateinit var settings: AppSettings
+
+ @Inject
+ lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
enableStrictMode()
}
- initKoin()
- AppCompatDelegate.setDefaultNightMode(get().theme)
+ AppCompatDelegate.setDefaultNightMode(settings.theme)
setupActivityLifecycleCallbacks()
- setupDatabaseObservers()
- }
-
- private fun initKoin() {
- startKoin {
- androidContext(this@KotatsuApp)
- modules(
- networkModule,
- databaseModule,
- githubModule,
- uiModule,
- mainModule,
- searchModule,
- localModule,
- favouritesModule,
- historyModule,
- remoteListModule,
- detailsModule,
- trackerModule,
- settingsModule,
- readerModule,
- appWidgetModule,
- suggestionsModule,
- shikimoriModule,
- bookmarksModule
- )
+ processLifecycleScope.launch(Dispatchers.Default) {
+ setupDatabaseObservers()
}
}
@@ -91,7 +68,8 @@ class KotatsuApp : Application() {
ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE,
- ReportField.SHARED_PREFERENCES
+ ReportField.CUSTOM_DATA,
+ ReportField.SHARED_PREFERENCES,
)
dialog {
text = getString(R.string.crash_text)
@@ -108,18 +86,22 @@ class KotatsuApp : Application() {
}
}
+ override fun getWorkManagerConfiguration(): Configuration {
+ return Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
+ }
+
+ @WorkerThread
private fun setupDatabaseObservers() {
- val observers = getKoin().getAll()
- val database = get()
val tracker = database.invalidationTracker
- observers.forEach {
+ databaseObservers.forEach {
tracker.addObserver(it)
}
}
private fun setupActivityLifecycleCallbacks() {
- val callbacks = getKoin().getAll()
- callbacks.forEach {
+ activityLifecycleCallbacks.forEach {
registerActivityLifecycleCallbacks(it)
}
}
@@ -129,7 +111,7 @@ class KotatsuApp : Application() {
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
- .build()
+ .build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
@@ -138,7 +120,7 @@ class KotatsuApp : Application() {
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog()
- .build()
+ .build(),
)
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
@@ -149,4 +131,4 @@ class KotatsuApp : Application() {
.detectFragmentTagUsage()
.build()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt
index 179d87868..9bc5c08dc 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt
@@ -1,14 +1,35 @@
package org.koitharu.kotatsu.base.domain
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.util.Size
import androidx.room.withTransaction
+import java.io.File
+import java.io.InputStream
+import java.util.zip.ZipFile
+import javax.inject.Inject
+import kotlin.math.roundToInt
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import okhttp3.OkHttpClient
+import okhttp3.Request
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.*
+import org.koitharu.kotatsu.core.network.CommonHeaders
+import org.koitharu.kotatsu.core.parser.MangaRepository
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
-class MangaDataRepository(private val db: MangaDatabase) {
+private const val MIN_WEBTOON_RATIO = 2
+
+class MangaDataRepository @Inject constructor(
+ private val okHttpClient: OkHttpClient,
+ private val db: MangaDatabase,
+) {
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
val tags = manga.tags.toEntities()
@@ -18,8 +39,8 @@ class MangaDataRepository(private val db: MangaDatabase) {
db.preferencesDao.upsert(
MangaPrefsEntity(
mangaId = manga.id,
- mode = mode.id
- )
+ mode = mode.id,
+ ),
)
}
}
@@ -49,4 +70,59 @@ class MangaDataRepository(private val db: MangaDatabase) {
suspend fun findTags(source: MangaSource): Set {
return db.tagsDao.findTags(source.name).toMangaTags()
}
-}
\ No newline at end of file
+
+ /**
+ * 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()
+ .header(CommonHeaders.REFERER, page.referer)
+ .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
+ .build()
+ okHttpClient.newCall(request).await().use {
+ runInterruptible(Dispatchers.IO) {
+ getBitmapSize(it.body?.byteStream())
+ }
+ }
+ }
+ return size.width * MIN_WEBTOON_RATIO < size.height
+ }
+
+ companion object {
+
+ 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/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt
deleted file mode 100644
index b3b32dc1f..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package org.koitharu.kotatsu.base.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 okhttp3.Request
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.get
-import org.koitharu.kotatsu.core.network.CommonHeaders
-import org.koitharu.kotatsu.core.parser.MangaRepository
-import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.parsers.util.await
-import java.io.File
-import java.io.InputStream
-import java.util.zip.ZipFile
-import kotlin.math.roundToInt
-
-object MangaUtils : KoinComponent {
-
- private const val MIN_WEBTOON_RATIO = 2
-
- /**
- * Automatic determine type of manga by page size
- * @return ReaderMode.WEBTOON if page is wide
- */
- suspend fun determineMangaIsWebtoon(pages: List): Boolean {
- val pageIndex = (pages.size * 0.3).roundToInt()
- val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
- val url = MangaRepository(page.source).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()
- .header(CommonHeaders.REFERER, page.referer)
- .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
- .build()
- get().newCall(request).await().use {
- runInterruptible(Dispatchers.IO) {
- getBitmapSize(it.body?.byteStream())
- }
- }
- }
- return size.width * MIN_WEBTOON_RATIO < size.height
- }
-
- 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)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt
index 43c9bf7e4..d416216f4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
fun interface ReversibleHandle {
@@ -10,7 +11,11 @@ fun interface ReversibleHandle {
}
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
- reverse()
+ runCatching {
+ reverse()
+ }.onFailure {
+ it.printStackTraceDebug()
+ }
}
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
index 9cdce9654..4f87f334d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
@@ -13,23 +13,32 @@ 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
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
-import org.koin.android.ext.android.get
+import dagger.hilt.android.EntryPointAccessors
+import javax.inject.Inject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
+import org.koitharu.kotatsu.base.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
+import org.koitharu.kotatsu.base.ui.util.inject
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.utils.ext.getThemeColor
abstract class BaseActivity :
AppCompatActivity(),
WindowInsetsDelegate.WindowInsetsListener {
+ @Inject
+ lateinit var settings: AppSettings
+
protected lateinit var binding: B
private set
@@ -42,7 +51,7 @@ abstract class BaseActivity :
val actionModeDelegate = ActionModeDelegate()
override fun onCreate(savedInstanceState: Bundle?) {
- val settings = get()
+ EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this)
val isAmoled = settings.isAmoledTheme
val isDynamic = settings.isDynamicTheme
// TODO support DialogWhenLarge theme
@@ -96,25 +105,33 @@ abstract class BaseActivity :
protected fun isDarkAmoledTheme(): Boolean {
val uiMode = resources.configuration.uiMode
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
- return isNight && get().isAmoledTheme
+ return isNight && settings.isAmoledTheme
}
@CallSuper
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
actionModeDelegate.onSupportActionModeStarted(mode)
+ val actionModeColor = ColorUtils.compositeColors(
+ ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
+ getThemeColor(com.google.android.material.R.attr.colorSurface),
+ )
val insets = ViewCompat.getRootWindowInsets(binding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
- val view = findViewById(androidx.appcompat.R.id.action_mode_bar)
- view?.updateLayoutParams {
- topMargin = insets.top
+ findViewById(androidx.appcompat.R.id.action_mode_bar).apply {
+ setBackgroundColor(actionModeColor)
+ updateLayoutParams {
+ topMargin = insets.top
+ }
}
+ window.statusBarColor = actionModeColor
}
@CallSuper
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
actionModeDelegate.onSupportActionModeFinished(mode)
+ window.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
}
override fun onBackPressed() {
@@ -128,4 +145,4 @@ abstract class BaseActivity :
super.onBackPressed()
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
index c8c4051b3..1bba104b8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
@@ -9,13 +9,13 @@ import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
+import com.google.android.material.R as materialR
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
import org.koitharu.kotatsu.utils.ext.displayCompat
-import com.google.android.material.R as materialR
abstract class BaseBottomSheet : BottomSheetDialogFragment() {
@@ -30,7 +30,7 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() {
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
- savedInstanceState: Bundle?
+ savedInstanceState: Bundle?,
): View {
val binding = onInflateView(inflater, container)
viewBinding = binding
@@ -83,4 +83,4 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() {
}
b.isDraggable = !isLocked
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt
index 17c8931b1..fbd4b8d3a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt
@@ -52,4 +52,4 @@ abstract class BaseFragment :
protected fun bindingOrNull() = viewBinding
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
index 7db0f6e22..e71dcd005 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
@@ -8,18 +8,21 @@ import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
-import org.koin.android.ext.android.inject
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
+@AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner {
- protected val settings by inject(mode = LazyThreadSafetyMode.NONE)
+ @Inject
+ lateinit var settings: AppSettings
@Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
@@ -48,7 +51,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
@CallSuper
override fun onWindowInsetsChanged(insets: Insets) {
listView.updatePadding(
- bottom = insets.bottom
+ bottom = insets.bottom,
)
}
@@ -57,4 +60,4 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt
index 241d13f94..0e63b3950 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt
@@ -15,7 +15,7 @@ abstract class CoroutineIntentService : BaseService() {
private val mutex = Mutex()
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
launchCoroutine(intent, startId)
return Service.START_REDELIVER_INTENT
@@ -34,4 +34,4 @@ abstract class CoroutineIntentService : BaseService() {
}
protected abstract suspend fun processIntent(intent: Intent?)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/RememberSelectionDialogListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/RememberSelectionDialogListener.kt
new file mode 100644
index 000000000..7783a564b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/RememberSelectionDialogListener.kt
@@ -0,0 +1,13 @@
+package org.koitharu.kotatsu.base.ui.dialog
+
+import android.content.DialogInterface
+
+class RememberSelectionDialogListener(initialValue: Int) : DialogInterface.OnClickListener {
+
+ var selection: Int = initialValue
+ private set
+
+ override fun onClick(dialog: DialogInterface?, which: Int) {
+ selection = which
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt
index b3171a9df..5cadc9c6f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt
@@ -12,9 +12,9 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
+import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
-import kotlin.coroutines.EmptyCoroutineContext
private const val KEY_SELECTION = "selection"
private const val PROVIDER_NAME = "selection_decoration"
@@ -23,15 +23,18 @@ class ListSelectionController(
private val activity: Activity,
private val decoration: AbstractSelectionItemDecoration,
private val registryOwner: SavedStateRegistryOwner,
- private val callback: Callback,
+ private val callback: Callback2,
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
private var actionMode: ActionMode? = null
- private val stateEventObserver = StateEventObserver()
val count: Int
get() = decoration.checkedItemsCount
+ init {
+ registryOwner.lifecycle.addObserver(StateEventObserver())
+ }
+
fun snapshot(): Set {
return peekCheckedIds().toSet()
}
@@ -55,7 +58,6 @@ class ListSelectionController(
fun attachToRecyclerView(recyclerView: RecyclerView) {
recyclerView.addItemDecoration(decoration)
- registryOwner.lifecycle.addObserver(stateEventObserver)
}
override fun saveState(): Bundle {
@@ -87,19 +89,19 @@ class ListSelectionController(
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
- return callback.onCreateActionMode(mode, menu)
+ return callback.onCreateActionMode(this, mode, menu)
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
- return callback.onPrepareActionMode(mode, menu)
+ return callback.onPrepareActionMode(this, mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
- return callback.onActionItemClicked(mode, item)
+ return callback.onActionItemClicked(this, mode, item)
}
override fun onDestroyActionMode(mode: ActionMode) {
- callback.onDestroyActionMode(mode)
+ callback.onDestroyActionMode(this, mode)
clear()
actionMode = null
}
@@ -112,7 +114,7 @@ class ListSelectionController(
private fun notifySelectionChanged() {
val count = decoration.checkedItemsCount
- callback.onSelectionChanged(count)
+ callback.onSelectionChanged(this, count)
if (count == 0) {
actionMode?.finish()
} else {
@@ -129,17 +131,56 @@ class ListSelectionController(
notifySelectionChanged()
}
- interface Callback : ActionMode.Callback {
+ @Deprecated("")
+ interface Callback : Callback2 {
fun onSelectionChanged(count: Int)
- override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean
+ fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean
+
+ fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean
+
+ fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean
+
+ fun onDestroyActionMode(mode: ActionMode) = Unit
+
+ override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
+ onSelectionChanged(count)
+ }
+
+ override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
+ return onCreateActionMode(mode, menu)
+ }
+
+ override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
+ return onPrepareActionMode(mode, menu)
+ }
+
+ override fun onActionItemClicked(
+ controller: ListSelectionController,
+ mode: ActionMode,
+ item: MenuItem,
+ ): Boolean = onActionItemClicked(mode, item)
+
+ override fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) {
+ onDestroyActionMode(mode)
+ }
+ }
- override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean
+ interface Callback2 {
+
+ fun onSelectionChanged(controller: ListSelectionController, count: Int)
+
+ fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean
+
+ fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
+ mode.title = controller.count.toString()
+ return true
+ }
- override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean
+ fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean
- override fun onDestroyActionMode(mode: ActionMode) = Unit
+ fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit
}
private inner class StateEventObserver : LifecycleEventObserver {
@@ -159,4 +200,4 @@ class ListSelectionController(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/SectionedSelectionController.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/SectionedSelectionController.kt
new file mode 100644
index 000000000..5e96cd066
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/SectionedSelectionController.kt
@@ -0,0 +1,225 @@
+package org.koitharu.kotatsu.base.ui.list
+
+import android.app.Activity
+import android.os.Bundle
+import android.util.ArrayMap
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ActionMode
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.RecyclerView
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryOwner
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.Dispatchers
+import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
+
+private const val PROVIDER_NAME = "selection_decoration_sectioned"
+
+class SectionedSelectionController(
+ private val activity: Activity,
+ private val owner: SavedStateRegistryOwner,
+ private val callback: Callback,
+) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
+
+ private var actionMode: ActionMode? = null
+
+ private var pendingData: MutableMap>? = null
+ private val decorations = ArrayMap()
+
+ val count: Int
+ get() = decorations.values.sumOf { it.checkedItemsCount }
+
+ init {
+ owner.lifecycle.addObserver(StateEventObserver())
+ }
+
+ fun snapshot(): Map> {
+ return decorations.mapValues { it.value.checkedItemsIds.toSet() }
+ }
+
+ fun peekCheckedIds(): Map> {
+ return decorations.mapValues { it.value.checkedItemsIds }
+ }
+
+ fun clear() {
+ decorations.values.forEach {
+ it.clearSelection()
+ }
+ notifySelectionChanged()
+ }
+
+ fun attachToRecyclerView(section: T, recyclerView: RecyclerView) {
+ val decoration = getDecoration(section)
+ val pendingIds = pendingData?.remove(section.toString())
+ if (!pendingIds.isNullOrEmpty()) {
+ decoration.checkAll(pendingIds)
+ startActionMode()
+ notifySelectionChanged()
+ }
+ recyclerView.addItemDecoration(decoration)
+ if (pendingData?.isEmpty() == true) {
+ pendingData = null
+ }
+ }
+
+ override fun saveState(): Bundle {
+ val bundle = Bundle(decorations.size)
+ for ((k, v) in decorations) {
+ bundle.putLongArray(k.toString(), v.checkedItemsIds.toLongArray())
+ }
+ return bundle
+ }
+
+ fun onItemClick(section: T, id: Long): Boolean {
+ val decoration = getDecoration(section)
+ if (isInSelectionMode()) {
+ decoration.toggleItemChecked(id)
+ if (isInSelectionMode()) {
+ actionMode?.invalidate()
+ } else {
+ actionMode?.finish()
+ }
+ notifySelectionChanged()
+ return true
+ }
+ return false
+ }
+
+ fun onItemLongClick(section: T, id: Long): Boolean {
+ val decoration = getDecoration(section)
+ startActionMode()
+ return actionMode?.also {
+ decoration.setItemIsChecked(id, true)
+ notifySelectionChanged()
+ } != null
+ }
+
+ fun getSectionCount(section: T): Int {
+ return decorations[section]?.checkedItemsCount ?: 0
+ }
+
+ fun addToSelection(section: T, ids: Collection): Boolean {
+ val decoration = getDecoration(section)
+ startActionMode()
+ return actionMode?.also {
+ decoration.checkAll(ids)
+ notifySelectionChanged()
+ } != null
+ }
+
+ fun clearSelection(section: T) {
+ decorations[section]?.clearSelection() ?: return
+ notifySelectionChanged()
+ }
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ return callback.onCreateActionMode(this, mode, menu)
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ return callback.onPrepareActionMode(this, mode, menu)
+ }
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return callback.onActionItemClicked(this, mode, item)
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {
+ callback.onDestroyActionMode(this, mode)
+ clear()
+ actionMode = null
+ }
+
+ private fun startActionMode() {
+ if (actionMode == null) {
+ actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
+ }
+ }
+
+ private fun isInSelectionMode(): Boolean {
+ return decorations.values.any { x -> x.checkedItemsCount > 0 }
+ }
+
+ private fun notifySelectionChanged() {
+ val count = this.count
+ callback.onSelectionChanged(this, count)
+ if (count == 0) {
+ actionMode?.finish()
+ } else {
+ actionMode?.invalidate()
+ }
+ }
+
+ private fun restoreState(ids: MutableMap>) {
+ if (ids.isEmpty() || isInSelectionMode()) {
+ return
+ }
+ for ((k, v) in decorations) {
+ val items = ids.remove(k.toString())
+ if (!items.isNullOrEmpty()) {
+ v.checkAll(items)
+ }
+ }
+ pendingData = ids
+ if (isInSelectionMode()) {
+ startActionMode()
+ notifySelectionChanged()
+ }
+ }
+
+ private fun getDecoration(section: T): AbstractSelectionItemDecoration {
+ return decorations.getOrPut(section) {
+ callback.onCreateItemDecoration(this, section)
+ }
+ }
+
+ interface Callback {
+
+ fun onSelectionChanged(controller: SectionedSelectionController, count: Int)
+
+ fun onCreateActionMode(controller: SectionedSelectionController, mode: ActionMode, menu: Menu): Boolean
+
+ fun onPrepareActionMode(controller: SectionedSelectionController, mode: ActionMode, menu: Menu): Boolean {
+ mode.title = controller.count.toString()
+ return true
+ }
+
+ fun onDestroyActionMode(controller: SectionedSelectionController, mode: ActionMode) = Unit
+
+ fun onActionItemClicked(
+ controller: SectionedSelectionController,
+ mode: ActionMode,
+ item: MenuItem,
+ ): Boolean
+
+ fun onCreateItemDecoration(
+ controller: SectionedSelectionController,
+ section: T,
+ ): AbstractSelectionItemDecoration
+ }
+
+ private inner class StateEventObserver : LifecycleEventObserver {
+
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ if (event == Lifecycle.Event.ON_CREATE) {
+ val registry = owner.savedStateRegistry
+ registry.registerSavedStateProvider(PROVIDER_NAME, this@SectionedSelectionController)
+ val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
+ if (state != null) {
+ Dispatchers.Main.dispatch(EmptyCoroutineContext) { // == Handler.post
+ if (source.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
+ restoreState(
+ state.keySet()
+ .associateWithTo(HashMap()) { state.getLongArray(it)?.toList().orEmpty() },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt
index b86a87dce..5b9fbde29 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt
@@ -11,8 +11,8 @@ class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDec
outRect: Rect,
view: View,
parent: RecyclerView,
- state: RecyclerView.State
+ state: RecyclerView.State,
) {
outRect.set(spacing, spacing, spacing, spacing)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/BubbleAnimator.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/BubbleAnimator.kt
new file mode 100644
index 000000000..591fd6b99
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/BubbleAnimator.kt
@@ -0,0 +1,82 @@
+package org.koitharu.kotatsu.base.ui.list.fastscroll
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.view.View
+import android.view.ViewAnimationUtils
+import android.view.animation.AccelerateInterpolator
+import android.view.animation.DecelerateInterpolator
+import androidx.core.view.isInvisible
+import androidx.core.view.isVisible
+import org.koitharu.kotatsu.utils.ext.animatorDurationScale
+import org.koitharu.kotatsu.utils.ext.measureWidth
+import kotlin.math.hypot
+
+class BubbleAnimator(
+ private val bubble: View,
+) {
+
+ private val animationDuration = (bubble.resources.getInteger(android.R.integer.config_shortAnimTime) *
+ bubble.context.animatorDurationScale).toLong()
+ private var animator: Animator? = null
+ private var isHiding = false
+
+ fun show() {
+ if (bubble.isVisible && !isHiding) {
+ return
+ }
+ isHiding = false
+ animator?.cancel()
+ animator = ViewAnimationUtils.createCircularReveal(
+ bubble,
+ bubble.measureWidth(),
+ bubble.measuredHeight,
+ 0f,
+ hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(),
+ ).apply {
+ bubble.isVisible = true
+ duration = animationDuration
+ interpolator = DecelerateInterpolator()
+ start()
+ }
+ }
+
+ fun hide() {
+ if (!bubble.isVisible || isHiding) {
+ return
+ }
+ animator?.cancel()
+ isHiding = true
+ animator = ViewAnimationUtils.createCircularReveal(
+ bubble,
+ bubble.width,
+ bubble.height,
+ hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(),
+ 0f,
+ ).apply {
+ duration = animationDuration
+ interpolator = AccelerateInterpolator()
+ addListener(HideListener())
+ start()
+ }
+ }
+
+ private inner class HideListener : AnimatorListenerAdapter() {
+
+ private var isCancelled = false
+
+ override fun onAnimationCancel(animation: Animator?) {
+ super.onAnimationCancel(animation)
+ isCancelled = true
+ }
+
+ override fun onAnimationEnd(animation: Animator?) {
+ super.onAnimationEnd(animation)
+ if (!isCancelled && animation === this@BubbleAnimator.animator) {
+ bubble.isInvisible = true
+ isHiding = false
+ this@BubbleAnimator.animator = null
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScrollRecyclerView.kt
new file mode 100644
index 000000000..5a7c1274e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScrollRecyclerView.kt
@@ -0,0 +1,45 @@
+package org.koitharu.kotatsu.base.ui.list.fastscroll
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.ViewGroup
+import androidx.annotation.AttrRes
+import androidx.recyclerview.widget.RecyclerView
+import org.koitharu.kotatsu.R
+
+class FastScrollRecyclerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = androidx.recyclerview.R.attr.recyclerViewStyle,
+) : RecyclerView(context, attrs, defStyleAttr) {
+
+ val fastScroller = FastScroller(context, attrs)
+
+ init {
+ fastScroller.id = R.id.fast_scroller
+ fastScroller.layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+
+ override fun setAdapter(adapter: Adapter<*>?) {
+ super.setAdapter(adapter)
+ fastScroller.setSectionIndexer(adapter as? FastScroller.SectionIndexer)
+ }
+
+ override fun setVisibility(visibility: Int) {
+ super.setVisibility(visibility)
+ fastScroller.visibility = visibility
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ fastScroller.attachRecyclerView(this)
+ }
+
+ override fun onDetachedFromWindow() {
+ fastScroller.detachRecyclerView()
+ super.onDetachedFromWindow()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScroller.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScroller.kt
new file mode 100644
index 000000000..23c79d7a2
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScroller.kt
@@ -0,0 +1,521 @@
+package org.koitharu.kotatsu.base.ui.list.fastscroll
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.res.TypedArray
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.ViewGroup
+import android.widget.*
+import androidx.annotation.*
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.content.ContextCompat
+import androidx.core.content.withStyledAttributes
+import androidx.core.view.GravityCompat
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.RecyclerView
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.databinding.FastScrollerBinding
+import org.koitharu.kotatsu.utils.ext.getThemeColor
+import org.koitharu.kotatsu.utils.ext.isLayoutReversed
+import kotlin.math.roundToInt
+import com.google.android.material.R as materialR
+
+private const val SCROLLBAR_HIDE_DELAY = 1000L
+private const val TRACK_SNAP_RANGE = 5
+
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+class FastScroller @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = R.attr.fastScrollerStyle,
+) : LinearLayout(context, attrs, defStyleAttr) {
+
+ enum class BubbleSize(@DrawableRes val drawableId: Int, @DimenRes val textSizeId: Int) {
+ NORMAL(R.drawable.fastscroll_bubble, R.dimen.fastscroll_bubble_text_size),
+ SMALL(R.drawable.fastscroll_bubble_small, R.dimen.fastscroll_bubble_text_size_small)
+ }
+
+ private val binding = FastScrollerBinding.inflate(LayoutInflater.from(context), this)
+
+ private val scrollbarPaddingEnd = context.resources.getDimension(R.dimen.fastscroll_scrollbar_padding_end)
+
+ @ColorInt
+ private var bubbleColor = 0
+
+ @ColorInt
+ private var handleColor = 0
+
+ private var bubbleHeight = 0
+ private var handleHeight = 0
+ private var viewHeight = 0
+ private var hideScrollbar = true
+ private var showBubble = true
+ private var showBubbleAlways = false
+ private var bubbleSize = BubbleSize.NORMAL
+ private var bubbleImage: Drawable? = null
+ private var handleImage: Drawable? = null
+ private var trackImage: Drawable? = null
+ private var recyclerView: RecyclerView? = null
+ private val scrollbarAnimator = ScrollbarAnimator(binding.scrollbar, scrollbarPaddingEnd)
+ private val bubbleAnimator = BubbleAnimator(binding.bubble)
+
+ private var fastScrollListener: FastScrollListener? = null
+ private var sectionIndexer: SectionIndexer? = null
+
+ private val scrollbarHider = Runnable {
+ hideBubble()
+ hideScrollbar()
+ }
+
+ private val scrollListener: RecyclerView.OnScrollListener = object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ if (!binding.thumb.isSelected && isEnabled) {
+ val y = recyclerView.scrollProportion
+ setViewPositions(y)
+
+ if (showBubbleAlways) {
+ val targetPos = getRecyclerViewTargetPosition(y)
+ sectionIndexer?.let { binding.bubble.text = it.getSectionText(recyclerView.context, targetPos) }
+ }
+ }
+ }
+
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
+ super.onScrollStateChanged(recyclerView, newState)
+
+ if (isEnabled) {
+ when (newState) {
+ RecyclerView.SCROLL_STATE_DRAGGING -> {
+ handler.removeCallbacks(scrollbarHider)
+ showScrollbar()
+ if (showBubbleAlways && sectionIndexer != null) showBubble()
+ }
+ RecyclerView.SCROLL_STATE_IDLE -> if (hideScrollbar && !binding.thumb.isSelected) {
+ handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY)
+ }
+ }
+ }
+ }
+ }
+
+ private val RecyclerView.scrollProportion: Float
+ get() {
+ val rangeDiff = computeVerticalScrollRange() - computeVerticalScrollExtent()
+ val proportion = computeVerticalScrollOffset() / if (rangeDiff > 0) rangeDiff.toFloat() else 1f
+ return viewHeight * proportion
+ }
+
+ init {
+ clipChildren = false
+ orientation = HORIZONTAL
+
+ @ColorInt var bubbleColor = context.getThemeColor(materialR.attr.colorControlNormal, Color.DKGRAY)
+ @ColorInt var handleColor = bubbleColor
+ @ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY)
+ @ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE)
+
+ var showTrack = false
+
+ context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr) {
+ bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor)
+ handleColor = getColor(R.styleable.FastScroller_thumbColor, handleColor)
+ trackColor = getColor(R.styleable.FastScroller_trackColor, trackColor)
+ textColor = getColor(R.styleable.FastScroller_bubbleTextColor, textColor)
+ hideScrollbar = getBoolean(R.styleable.FastScroller_hideScrollbar, hideScrollbar)
+ showBubble = getBoolean(R.styleable.FastScroller_showBubble, showBubble)
+ showBubbleAlways = getBoolean(R.styleable.FastScroller_showBubbleAlways, showBubbleAlways)
+ showTrack = getBoolean(R.styleable.FastScroller_showTrack, showTrack)
+ 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)
+ }
+
+ setTrackColor(trackColor)
+ setHandleColor(handleColor)
+ setBubbleColor(bubbleColor)
+ setBubbleTextColor(textColor)
+ setHideScrollbar(hideScrollbar)
+ setBubbleVisible(showBubble, showBubbleAlways)
+ setTrackVisible(showTrack)
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
+ super.onSizeChanged(w, h, oldW, oldH)
+ viewHeight = h
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ val setYPositions: () -> Unit = {
+ val y = event.y
+ setViewPositions(y)
+ setRecyclerViewPosition(y)
+ }
+
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ if (event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) return false
+
+ requestDisallowInterceptTouchEvent(true)
+ setHandleSelected(true)
+
+ handler.removeCallbacks(scrollbarHider)
+ showScrollbar()
+ if (showBubble && sectionIndexer != null) showBubble()
+
+ fastScrollListener?.onFastScrollStart(this)
+
+ setYPositions()
+ return true
+ }
+ MotionEvent.ACTION_MOVE -> {
+ setYPositions()
+ return true
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ requestDisallowInterceptTouchEvent(false)
+ setHandleSelected(false)
+
+ if (hideScrollbar) handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY)
+ if (!showBubbleAlways) hideBubble()
+
+ fastScrollListener?.onFastScrollStop(this)
+
+ return true
+ }
+ }
+
+ return super.onTouchEvent(event)
+ }
+
+ /**
+ * Set the enabled state of this view.
+ *
+ * @param enabled True if this view is enabled, false otherwise
+ */
+ override fun setEnabled(enabled: Boolean) {
+ super.setEnabled(enabled)
+ isVisible = enabled
+ }
+
+ /**
+ * Set the [ViewGroup.LayoutParams] associated with this view. These supply
+ * parameters to the *parent* of this view specifying how it should be arranged.
+ *
+ * @param params The [ViewGroup.LayoutParams] for this view, cannot be null
+ */
+ override fun setLayoutParams(params: ViewGroup.LayoutParams) {
+ params.width = LayoutParams.WRAP_CONTENT
+ super.setLayoutParams(params)
+ }
+
+ /**
+ * Set the [ViewGroup.LayoutParams] associated with this view. These supply
+ * parameters to the *parent* of this view specifying how it should be arranged.
+ *
+ * @param viewGroup The parent [ViewGroup] for this view, cannot be null
+ */
+ fun setLayoutParams(viewGroup: ViewGroup) {
+ val recyclerViewId = recyclerView?.id ?: NO_ID
+ val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
+ val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
+
+ require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" }
+
+ when (viewGroup) {
+ is ConstraintLayout -> {
+ val endId = if (recyclerView?.parent === parent) recyclerViewId else ConstraintSet.PARENT_ID
+ val startId = id
+
+ ConstraintSet().apply {
+ clone(viewGroup)
+ connect(startId, ConstraintSet.TOP, endId, ConstraintSet.TOP)
+ connect(startId, ConstraintSet.BOTTOM, endId, ConstraintSet.BOTTOM)
+ connect(startId, ConstraintSet.END, endId, ConstraintSet.END)
+ applyTo(viewGroup)
+ }
+
+ layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
+ height = 0
+ setMargins(0, marginTop, 0, marginBottom)
+ }
+ }
+ is CoordinatorLayout -> layoutParams = (layoutParams as CoordinatorLayout.LayoutParams).apply {
+ height = LayoutParams.MATCH_PARENT
+ anchorGravity = GravityCompat.END
+ anchorId = recyclerViewId
+ setMargins(0, marginTop, 0, marginBottom)
+ }
+ is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
+ height = LayoutParams.MATCH_PARENT
+ gravity = GravityCompat.END
+ setMargins(0, marginTop, 0, marginBottom)
+ }
+ is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply {
+ height = 0
+ addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
+ addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
+ addRule(RelativeLayout.ALIGN_END, recyclerViewId)
+ setMargins(0, marginTop, 0, marginBottom)
+ }
+ else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")
+ }
+
+ updateViewHeights()
+ }
+
+ /**
+ * Set the [RecyclerView] associated with this [FastScroller]. This allows the
+ * FastScroller to set its layout parameters and listen for scroll changes.
+ *
+ * @param recyclerView The [RecyclerView] to attach, cannot be null
+ * @see detachRecyclerView
+ */
+ fun attachRecyclerView(recyclerView: RecyclerView) {
+ if (this.recyclerView != null) {
+ detachRecyclerView()
+ }
+ this.recyclerView = recyclerView
+
+ 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)
+ }
+
+ recyclerView.addOnScrollListener(scrollListener)
+
+ // set initial positions for bubble and thumb
+ post { setViewPositions(this.recyclerView?.scrollProportion ?: 0f) }
+ }
+
+ /**
+ * Clears references to the attached [RecyclerView] and stops listening for scroll changes.
+ *
+ * @see attachRecyclerView
+ */
+ fun detachRecyclerView() {
+ recyclerView?.removeOnScrollListener(scrollListener)
+ recyclerView = null
+ }
+
+ /**
+ * Set a new [FastScrollListener] that will listen to fast scroll events.
+ *
+ * @param fastScrollListener The new [FastScrollListener] to set, or null to set none
+ */
+ fun setFastScrollListener(fastScrollListener: FastScrollListener?) {
+ this.fastScrollListener = fastScrollListener
+ }
+
+ /**
+ * Set a new [SectionIndexer] that provides section text for this [FastScroller].
+ *
+ * @param sectionIndexer The new [SectionIndexer] to set, or null to set none
+ */
+ fun setSectionIndexer(sectionIndexer: SectionIndexer?) {
+ this.sectionIndexer = sectionIndexer
+ }
+
+ /**
+ * Hide the scrollbar when not scrolling.
+ *
+ * @param hideScrollbar True to hide the scrollbar, false to show
+ */
+ fun setHideScrollbar(hideScrollbar: Boolean) {
+ if (this.hideScrollbar != hideScrollbar) {
+ this.hideScrollbar = hideScrollbar
+ binding.scrollbar.isGone = hideScrollbar
+ }
+ }
+
+ /**
+ * Show the scroll track while scrolling.
+ *
+ * @param visible True to show scroll track, false to hide
+ */
+ fun setTrackVisible(visible: Boolean) {
+ binding.track.isVisible = visible
+ }
+
+ /**
+ * Set the color of the scroll track.
+ *
+ * @param color The color for the scroll track
+ */
+ fun setTrackColor(@ColorInt color: Int) {
+ if (trackImage == null) {
+ trackImage = ContextCompat.getDrawable(context, R.drawable.fastscroll_track)
+ }
+
+ trackImage?.let {
+ it.setTint(color)
+ binding.track.setImageDrawable(it)
+ }
+ }
+
+ /**
+ * Set the color of the scroll thumb.
+ *
+ * @param color The color for the scroll thumb
+ */
+ fun setHandleColor(@ColorInt color: Int) {
+ handleColor = color
+
+ if (handleImage == null) {
+ handleImage = ContextCompat.getDrawable(context, R.drawable.fastscroll_handle)
+ }
+
+ handleImage?.let {
+ it.setTint(handleColor)
+ binding.thumb.setImageDrawable(it)
+ }
+ }
+
+ /**
+ * Show the section bubble while scrolling.
+ *
+ * @param visible True to show the bubble, false to hide
+ * @param always True to always show the bubble, false to only show on thumb touch
+ */
+ @JvmOverloads
+ fun setBubbleVisible(visible: Boolean, always: Boolean = false) {
+ showBubble = visible
+ showBubbleAlways = visible && always
+ }
+
+ /**
+ * Set the background color of the section bubble.
+ *
+ * @param color The background color for the section bubble
+ */
+ fun setBubbleColor(@ColorInt color: Int) {
+ bubbleColor = color
+
+ if (bubbleImage == null) {
+ bubbleImage = ContextCompat.getDrawable(context, bubbleSize.drawableId)
+ }
+
+ bubbleImage?.let {
+ it.setTint(bubbleColor)
+ binding.bubble.background = it
+ }
+ }
+
+ /**
+ * Set the text color of the section bubble.
+ *
+ * @param color The text color for the section bubble
+ */
+ fun setBubbleTextColor(@ColorInt color: Int) = binding.bubble.setTextColor(color)
+
+ /**
+ * Set the scaled pixel text size of the section bubble.
+ *
+ * @param size The scaled pixel text size for the section bubble
+ */
+ fun setBubbleTextSize(size: Int) {
+ binding.bubble.textSize = size.toFloat()
+ }
+
+ private fun getRecyclerViewTargetPosition(y: Float) = recyclerView?.let { recyclerView ->
+ val itemCount = recyclerView.adapter?.itemCount ?: 0
+
+ val proportion = when {
+ binding.thumb.y == 0f -> 0f
+ binding.thumb.y + handleHeight >= viewHeight - TRACK_SNAP_RANGE -> 1f
+ else -> y / viewHeight.toFloat()
+ }
+
+ var scrolledItemCount = (proportion * itemCount).roundToInt()
+
+ if (recyclerView.layoutManager.isLayoutReversed) {
+ scrolledItemCount = itemCount - scrolledItemCount
+ }
+
+ if (itemCount > 0) scrolledItemCount.coerceIn(0, itemCount - 1) else 0
+ } ?: 0
+
+ private fun setRecyclerViewPosition(y: Float) {
+ val layoutManager = recyclerView?.layoutManager ?: return
+ val targetPos = getRecyclerViewTargetPosition(y)
+ layoutManager.scrollToPosition(targetPos)
+ if (showBubble) sectionIndexer?.let { binding.bubble.text = it.getSectionText(context, targetPos) }
+ }
+
+ private fun setViewPositions(y: Float) {
+ bubbleHeight = binding.bubble.measuredHeight
+ handleHeight = binding.thumb.measuredHeight
+
+ val bubbleHandleHeight = bubbleHeight + handleHeight / 2f
+
+ if (showBubble && viewHeight >= bubbleHandleHeight) {
+ binding.bubble.y = (y - bubbleHeight).coerceIn(0f, viewHeight - bubbleHandleHeight)
+ }
+
+ if (viewHeight >= handleHeight) {
+ binding.thumb.y = (y - handleHeight / 2).coerceIn(0f, viewHeight - handleHeight.toFloat())
+ }
+ }
+
+ private fun updateViewHeights() {
+ val measureSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
+ binding.bubble.measure(measureSpec, measureSpec)
+ bubbleHeight = binding.bubble.measuredHeight
+ binding.thumb.measure(measureSpec, measureSpec)
+ handleHeight = binding.thumb.measuredHeight
+ }
+
+ private fun showBubble() {
+ bubbleAnimator.show()
+ }
+
+ private fun hideBubble() {
+ bubbleAnimator.hide()
+ }
+
+ private fun showScrollbar() {
+ if (recyclerView?.run { canScrollVertically(1) || canScrollVertically(-1) } == true) {
+ scrollbarAnimator.show()
+ }
+ }
+
+ private fun hideScrollbar() {
+ scrollbarAnimator.hide()
+ }
+
+ private fun setHandleSelected(selected: Boolean) {
+ binding.thumb.isSelected = selected
+ handleImage?.setTint(if (selected) bubbleColor else handleColor)
+ }
+
+ private fun TypedArray.getBubbleSize(@StyleableRes index: Int, defaultValue: BubbleSize): BubbleSize {
+ val ordinal = getInt(index, -1)
+ return BubbleSize.values().getOrNull(ordinal) ?: defaultValue
+ }
+
+ private val BubbleSize.textSize
+ @Px get() = resources.getDimension(textSizeId)
+
+ interface FastScrollListener {
+
+ fun onFastScrollStart(fastScroller: FastScroller)
+
+ fun onFastScrollStop(fastScroller: FastScroller)
+ }
+
+ interface SectionIndexer {
+
+ fun getSectionText(context: Context, position: Int): CharSequence
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/ScrollbarAnimator.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/ScrollbarAnimator.kt
new file mode 100644
index 000000000..a00fc90b9
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/ScrollbarAnimator.kt
@@ -0,0 +1,69 @@
+package org.koitharu.kotatsu.base.ui.list.fastscroll
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.view.View
+import android.view.ViewPropertyAnimator
+import androidx.core.view.isInvisible
+import androidx.core.view.isVisible
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.utils.ext.animatorDurationScale
+
+class ScrollbarAnimator(
+ private val scrollbar: View,
+ private val scrollbarPaddingEnd: Float,
+) {
+
+ private val animationDuration = (scrollbar.resources.getInteger(R.integer.config_defaultAnimTime) *
+ scrollbar.context.animatorDurationScale).toLong()
+ private var animator: ViewPropertyAnimator? = null
+ private var isHiding = false
+
+ fun show() {
+ if (scrollbar.isVisible && !isHiding) {
+ return
+ }
+ isHiding = false
+ animator?.cancel()
+ scrollbar.translationX = scrollbarPaddingEnd
+ scrollbar.isVisible = true
+ animator = scrollbar
+ .animate()
+ .translationX(0f)
+ .alpha(1f)
+ .setDuration(animationDuration)
+ }
+
+ fun hide() {
+ if (!scrollbar.isVisible || isHiding) {
+ return
+ }
+ animator?.cancel()
+ isHiding = true
+ animator = scrollbar
+ .animate()
+ .translationX(scrollbarPaddingEnd)
+ .alpha(0f)
+ .setDuration(animationDuration)
+ .setListener(HideListener())
+ }
+
+ private inner class HideListener : AnimatorListenerAdapter() {
+
+ private var isCancelled = false
+
+ override fun onAnimationCancel(animation: Animator?) {
+ super.onAnimationCancel(animation)
+ isCancelled = true
+ }
+
+ override fun onAnimationEnd(animation: Animator?) {
+ super.onAnimationEnd(animation)
+ if (!isCancelled && animation === this@ScrollbarAnimator.animator) {
+ scrollbar.isInvisible = true
+ isHiding = false
+ this@ScrollbarAnimator.animator = null
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
index f072da2fa..985205ed6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
@@ -4,8 +4,11 @@ import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import java.util.*
+import javax.inject.Inject
+import javax.inject.Singleton
-class ActivityRecreationHandle : ActivityLifecycleCallbacks {
+@Singleton
+class ActivityRecreationHandle @Inject constructor() : ActivityLifecycleCallbacks {
private val activities = WeakHashMap()
@@ -31,4 +34,4 @@ class ActivityRecreationHandle : ActivityLifecycleCallbacks {
val snapshot = activities.keys.toList()
snapshot.forEach { it.recreate() }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/BaseActivityEntryPoint.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/BaseActivityEntryPoint.kt
new file mode 100644
index 000000000..232529d52
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/BaseActivityEntryPoint.kt
@@ -0,0 +1,18 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.core.prefs.AppSettings
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface BaseActivityEntryPoint {
+ val settings: AppSettings
+}
+
+// Hilt cannot inject into parametrized classes
+fun BaseActivityEntryPoint.inject(activity: BaseActivity<*>) {
+ activity.settings = settings
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ReversibleAction.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ReversibleAction.kt
new file mode 100644
index 000000000..57bb80a78
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ReversibleAction.kt
@@ -0,0 +1,9 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import androidx.annotation.StringRes
+import org.koitharu.kotatsu.base.domain.ReversibleHandle
+
+class ReversibleAction(
+ @StringRes val stringResId: Int,
+ val handle: ReversibleHandle?,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/StatusBarDimHelper.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/StatusBarDimHelper.kt
new file mode 100644
index 000000000..7a8bf28d4
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/StatusBarDimHelper.kt
@@ -0,0 +1,42 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import android.animation.ValueAnimator
+import android.view.animation.AccelerateDecelerateInterpolator
+import com.google.android.material.R as materialR
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.shape.MaterialShapeDrawable
+import org.koitharu.kotatsu.utils.ext.getAnimationDuration
+
+class StatusBarDimHelper : AppBarLayout.OnOffsetChangedListener {
+
+ private var animator: ValueAnimator? = null
+ private val interpolator = AccelerateDecelerateInterpolator()
+
+ override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
+ val foreground = appBarLayout.statusBarForeground ?: return
+ val start = foreground.alpha
+ val collapsed = verticalOffset != 0
+ val end = if (collapsed) 255 else 0
+ animator?.cancel()
+ if (start == end) {
+ animator = null
+ return
+ }
+ animator = ValueAnimator.ofInt(start, end).apply {
+ duration = appBarLayout.context.getAnimationDuration(materialR.integer.app_bar_elevation_anim_duration)
+ interpolator = this@StatusBarDimHelper.interpolator
+ addUpdateListener {
+ foreground.alpha = it.animatedValue as Int
+ }
+ start()
+ }
+ }
+
+ fun attachToAppBar(appBarLayout: AppBarLayout) {
+ appBarLayout.addOnOffsetChangedListener(this)
+ appBarLayout.statusBarForeground =
+ MaterialShapeDrawable.createWithElevationOverlay(appBarLayout.context).apply {
+ alpha = 0
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/BottomSheetHeaderBar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/BottomSheetHeaderBar.kt
new file mode 100644
index 000000000..3aaa7270d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/BottomSheetHeaderBar.kt
@@ -0,0 +1,271 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.animation.LayoutTransition
+import android.content.Context
+import android.transition.AutoTransition
+import android.transition.TransitionManager
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowInsets
+import android.view.animation.AccelerateDecelerateInterpolator
+import android.view.animation.DecelerateInterpolator
+import androidx.annotation.AttrRes
+import androidx.annotation.StringRes
+import androidx.appcompat.widget.Toolbar
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.content.withStyledAttributes
+import androidx.core.view.*
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import com.google.android.material.R as materialR
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import java.util.*
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.databinding.LayoutSheetHeaderBinding
+import org.koitharu.kotatsu.utils.ext.getAnimationDuration
+import org.koitharu.kotatsu.utils.ext.getThemeDrawable
+import org.koitharu.kotatsu.utils.ext.parents
+
+class BottomSheetHeaderBar @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = materialR.attr.appBarLayoutStyle,
+) : AppBarLayout(context, attrs, defStyleAttr), MenuHost {
+
+ private val binding = LayoutSheetHeaderBinding.inflate(LayoutInflater.from(context), this)
+ private val closeDrawable = context.getThemeDrawable(materialR.attr.actionModeCloseDrawable)
+ private val bottomSheetCallback = Callback()
+ private var bottomSheetBehavior: BottomSheetBehavior<*>? = null
+ private val locationBuffer = IntArray(2)
+ private val expansionListeners = LinkedList()
+ private var fitStatusBar = false
+ private var transition: AutoTransition? = null
+
+ @Deprecated("")
+ val toolbar: MaterialToolbar
+ get() = binding.toolbar
+
+ var title: CharSequence?
+ get() = binding.toolbar.title
+ set(value) {
+ binding.toolbar.title = value
+ }
+
+ var subtitle: CharSequence?
+ get() = binding.toolbar.subtitle
+ set(value) {
+ binding.toolbar.subtitle = value
+ }
+
+ init {
+ setBackgroundResource(R.drawable.sheet_toolbar_background)
+ layoutTransition = LayoutTransition().apply {
+ setDuration(context.getAnimationDuration(R.integer.config_tinyAnimTime))
+ }
+ context.withStyledAttributes(attrs, R.styleable.BottomSheetHeaderBar, defStyleAttr) {
+ binding.toolbar.title = getString(R.styleable.BottomSheetHeaderBar_title)
+ fitStatusBar = getBoolean(R.styleable.BottomSheetHeaderBar_fitStatusBar, fitStatusBar)
+ val menuResId = getResourceId(R.styleable.BottomSheetHeaderBar_menu, 0)
+ if (menuResId != 0) {
+ binding.toolbar.inflateMenu(menuResId)
+ }
+ }
+ binding.toolbar.setNavigationOnClickListener(bottomSheetCallback)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ setBottomSheetBehavior(findParentBottomSheetBehavior())
+ }
+
+ override fun onDetachedFromWindow() {
+ setBottomSheetBehavior(null)
+ super.onDetachedFromWindow()
+ }
+
+ override fun addView(child: View?, index: Int) {
+ if (shouldAddView(child)) {
+ super.addView(child, index)
+ } else {
+ binding.toolbar.addView(child, index)
+ }
+ }
+
+ override fun addView(child: View?, width: Int, height: Int) {
+ if (shouldAddView(child)) {
+ super.addView(child, width, height)
+ } else {
+ binding.toolbar.addView(child, width, height)
+ }
+ }
+
+ override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
+ if (shouldAddView(child)) {
+ super.addView(child, index, params)
+ } else {
+ binding.toolbar.addView(child, index, convertLayoutParams(params))
+ }
+ }
+
+ override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets {
+ dispatchInsets(if (insets != null) WindowInsetsCompat.toWindowInsetsCompat(insets) else null)
+ return super.onApplyWindowInsets(insets)
+ }
+
+ override fun addMenuProvider(provider: MenuProvider) {
+ binding.toolbar.addMenuProvider(provider)
+ }
+
+ override fun addMenuProvider(provider: MenuProvider, owner: LifecycleOwner) {
+ binding.toolbar.addMenuProvider(provider, owner)
+ }
+
+ override fun addMenuProvider(provider: MenuProvider, owner: LifecycleOwner, state: Lifecycle.State) {
+ binding.toolbar.addMenuProvider(provider, owner, state)
+ }
+
+ override fun removeMenuProvider(provider: MenuProvider) {
+ binding.toolbar.removeMenuProvider(provider)
+ }
+
+ override fun invalidateMenu() {
+ binding.toolbar.invalidateMenu()
+ }
+
+ fun setNavigationOnClickListener(onClickListener: OnClickListener) {
+ binding.toolbar.setNavigationOnClickListener(onClickListener)
+ }
+
+ fun addOnExpansionChangeListener(listener: OnExpansionChangeListener) {
+ expansionListeners.add(listener)
+ }
+
+ fun removeOnExpansionChangeListener(listener: OnExpansionChangeListener) {
+ expansionListeners.remove(listener)
+ }
+
+ fun setTitle(@StringRes resId: Int) {
+ binding.toolbar.setTitle(resId)
+ }
+
+ fun setSubtitle(@StringRes resId: Int) {
+ binding.toolbar.setSubtitle(resId)
+ }
+
+ private fun setBottomSheetBehavior(behavior: BottomSheetBehavior<*>?) {
+ bottomSheetBehavior?.removeBottomSheetCallback(bottomSheetCallback)
+ bottomSheetBehavior = behavior
+ if (behavior != null) {
+ onBottomSheetStateChanged(behavior.state)
+ behavior.addBottomSheetCallback(bottomSheetCallback)
+ }
+ }
+
+ private fun onBottomSheetStateChanged(newState: Int) {
+ val isExpanded = newState == BottomSheetBehavior.STATE_EXPANDED && isOnTopOfScreen()
+ if (isExpanded == binding.dragHandle.isGone) {
+ return
+ }
+ TransitionManager.beginDelayedTransition(this, getTransition())
+ binding.toolbar.navigationIcon = (if (isExpanded) closeDrawable else null)
+ binding.dragHandle.isGone = isExpanded
+ expansionListeners.forEach { it.onExpansionStateChanged(this, isExpanded) }
+ dispatchInsets(ViewCompat.getRootWindowInsets(this))
+ }
+
+ private fun dispatchInsets(insets: WindowInsetsCompat?) {
+ if (!fitStatusBar) {
+ return
+ }
+ val isExpanded = binding.dragHandle.isGone
+ if (isExpanded) {
+ val topInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
+ updatePadding(top = topInset)
+ } else {
+ updatePadding(top = 0)
+ }
+ }
+
+ private fun findParentBottomSheetBehavior(): BottomSheetBehavior<*>? {
+ for (p in parents) {
+ val layoutParams = (p as? View)?.layoutParams
+ if (layoutParams is CoordinatorLayout.LayoutParams) {
+ val behavior = layoutParams.behavior
+ if (behavior is BottomSheetBehavior<*>) {
+ return behavior
+ }
+ }
+ }
+ return null
+ }
+
+ private fun isOnTopOfScreen(): Boolean {
+ getLocationInWindow(locationBuffer)
+ val topInset = ViewCompat.getRootWindowInsets(this)
+ ?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
+ val zeroTop = (layoutParams as? MarginLayoutParams)?.topMargin ?: 0
+ return (locationBuffer[1] - topInset) <= zeroTop
+ }
+
+ private fun dismissBottomSheet() {
+ val behavior = bottomSheetBehavior ?: return
+ if (behavior.isHideable) {
+ behavior.state = BottomSheetBehavior.STATE_HIDDEN
+ } else {
+ behavior.state = BottomSheetBehavior.STATE_COLLAPSED
+ }
+ }
+
+ private fun shouldAddView(child: View?): Boolean {
+ if (child == null) {
+ return true
+ }
+ val viewId = child.id
+ return viewId == R.id.dragHandle || viewId == R.id.toolbar || viewId == R.id.frame
+ }
+
+ private fun convertLayoutParams(params: ViewGroup.LayoutParams?): Toolbar.LayoutParams? {
+ return when (params) {
+ null -> null
+ is MarginLayoutParams -> {
+ val lp = Toolbar.LayoutParams(params)
+ if (params is LayoutParams) {
+ lp.gravity = params.gravity
+ }
+ lp
+ }
+ else -> Toolbar.LayoutParams(params)
+ }
+ }
+
+ private fun getTransition(): AutoTransition {
+ transition?.let { return it }
+ val t = AutoTransition()
+ t.duration = context.getAnimationDuration(android.R.integer.config_shortAnimTime)
+ t.addTarget(binding.dragHandle)
+ transition = t
+ return t
+ }
+
+ private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), View.OnClickListener {
+
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ onBottomSheetStateChanged(newState)
+ }
+
+ override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
+
+ override fun onClick(v: View?) {
+ dismissBottomSheet()
+ }
+ }
+
+ fun interface OnExpansionChangeListener {
+
+ fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean)
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt
index 77d4acc2c..f159a56cd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt
@@ -8,23 +8,33 @@ import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.IdRes
import androidx.core.view.children
+import com.google.android.material.R as materialR
import com.google.android.material.button.MaterialButton
+import com.google.android.material.shape.ShapeAppearanceModel
class CheckableButtonGroup @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- @AttrRes defStyleAttr: Int = 0,
-) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener {
+ @AttrRes defStyleAttr: Int = materialR.attr.materialButtonToggleGroupStyle,
+) : LinearLayout(context, attrs, defStyleAttr, materialR.style.Widget_MaterialComponents_MaterialButtonToggleGroup),
+ View.OnClickListener {
+
+ private val originalCornerData = ArrayList()
var onCheckedChangeListener: OnCheckedChangeListener? = null
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
if (child is MaterialButton) {
- child.setOnClickListener(this)
+ setupButton(child)
}
super.addView(child, index, params)
}
+ override fun onFinishInflate() {
+ super.onFinishInflate()
+ updateChildShapes()
+ }
+
override fun onClick(v: View) {
setCheckedId(v.id)
}
@@ -36,7 +46,74 @@ class CheckableButtonGroup @JvmOverloads constructor(
onCheckedChangeListener?.onCheckedChanged(this, viewRes)
}
+ private fun updateChildShapes() {
+ val childCount = childCount
+ val firstVisibleChildIndex = 0
+ val lastVisibleChildIndex = childCount - 1
+ for (i in 0 until childCount) {
+ val button: MaterialButton = getChildAt(i) as? MaterialButton ?: continue
+ if (button.visibility == GONE) {
+ continue
+ }
+ val builder = button.shapeAppearanceModel.toBuilder()
+ val newCornerData: CornerData? =
+ getNewCornerData(i, firstVisibleChildIndex, lastVisibleChildIndex)
+ updateBuilderWithCornerData(builder, newCornerData)
+ button.shapeAppearanceModel = builder.build()
+ }
+ }
+
+ private fun setupButton(button: MaterialButton) {
+ button.setOnClickListener(this)
+ button.isElegantTextHeight = false
+ // Saves original corner data
+ val shapeAppearanceModel: ShapeAppearanceModel = button.shapeAppearanceModel
+ originalCornerData.add(
+ CornerData(
+ shapeAppearanceModel.topLeftCornerSize,
+ shapeAppearanceModel.bottomLeftCornerSize,
+ shapeAppearanceModel.topRightCornerSize,
+ shapeAppearanceModel.bottomRightCornerSize,
+ ),
+ )
+ }
+
+ private fun getNewCornerData(
+ index: Int,
+ firstVisibleChildIndex: Int,
+ lastVisibleChildIndex: Int,
+ ): CornerData? {
+ val cornerData: CornerData = originalCornerData.get(index)
+
+ // If only one (visible) child exists, use its original corners
+ if (firstVisibleChildIndex == lastVisibleChildIndex) {
+ return cornerData
+ }
+ val isHorizontal = orientation == HORIZONTAL
+ if (index == firstVisibleChildIndex) {
+ return if (isHorizontal) cornerData.start(this) else cornerData.top()
+ }
+ return if (index == lastVisibleChildIndex) {
+ if (isHorizontal) cornerData.end(this) else cornerData.bottom()
+ } else null
+ }
+
+ private fun updateBuilderWithCornerData(
+ shapeAppearanceModelBuilder: ShapeAppearanceModel.Builder,
+ cornerData: CornerData?,
+ ) {
+ if (cornerData == null) {
+ shapeAppearanceModelBuilder.setAllCornerSizes(0f)
+ return
+ }
+ shapeAppearanceModelBuilder
+ .setTopLeftCornerSize(cornerData.topLeft)
+ .setBottomLeftCornerSize(cornerData.bottomLeft)
+ .setTopRightCornerSize(cornerData.topRight)
+ .setBottomRightCornerSize(cornerData.bottomRight)
+ }
+
fun interface OnCheckedChangeListener {
fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
index c20b615cf..809add879 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
@@ -5,23 +5,26 @@ import android.util.AttributeSet
import android.view.View.OnClickListener
import androidx.annotation.DrawableRes
import androidx.core.view.children
+import com.google.android.material.R as materialR
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.utils.ext.castOrNull
+import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
class ChipsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle
+ defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
) : ChipGroup(context, attrs, defStyleAttr) {
private var isLayoutSuppressedCompat = false
private var isLayoutCalledOnSuppressed = false
- private var chipOnClickListener = OnClickListener {
+ private val chipOnClickListener = OnClickListener {
onChipClickListener?.onChipClick(it as Chip, it.tag)
}
- private var chipOnCloseListener = OnClickListener {
+ private val chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
}
var onChipClickListener: OnChipClickListener? = null
@@ -60,15 +63,27 @@ class ChipsView @JvmOverloads constructor(
}
}
+ fun getCheckedData(cls: Class): Set {
+ val result = LinkedHashSet(childCount)
+ for (child in children) {
+ if (child is Chip && child.isChecked) {
+ result += cls.castOrNull(child.tag) ?: continue
+ }
+ }
+ return result
+ }
+
private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title
if (model.icon == 0) {
chip.isChipIconVisible = false
} else {
- chip.isCheckedIconVisible = true
+ chip.isChipIconVisible = true
chip.setChipIconResource(model.icon)
}
- chip.isClickable = onChipClickListener != null
+ chip.isClickable = onChipClickListener != null || model.isCheckable
+ chip.isCheckable = model.isCheckable
+ chip.isChecked = model.isChecked
chip.tag = model.data
}
@@ -76,11 +91,13 @@ class ChipsView @JvmOverloads constructor(
val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
chip.setChipDrawable(drawable)
+ chip.isCheckedIconVisible = true
+ chip.setCheckedIconResource(R.drawable.ic_check)
+ chip.checkedIconTint = context.getThemeColorStateList(materialR.attr.colorControlNormal)
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(chipOnClickListener)
- chip.isCheckable = false
addView(chip)
return chip
}
@@ -98,7 +115,9 @@ class ChipsView @JvmOverloads constructor(
class ChipModel(
@DrawableRes val icon: Int,
val title: CharSequence,
- val data: Any? = null
+ val isCheckable: Boolean,
+ val isChecked: Boolean,
+ val data: Any? = null,
) {
override fun equals(other: Any?): Boolean {
@@ -109,6 +128,8 @@ class ChipsView @JvmOverloads constructor(
if (icon != other.icon) return false
if (title != other.title) return false
+ if (isCheckable != other.isCheckable) return false
+ if (isChecked != other.isChecked) return false
if (data != other.data) return false
return true
@@ -117,7 +138,9 @@ class ChipsView @JvmOverloads constructor(
override fun hashCode(): Int {
var result = icon
result = 31 * result + title.hashCode()
- result = 31 * result + data.hashCode()
+ result = 31 * result + isCheckable.hashCode()
+ result = 31 * result + isChecked.hashCode()
+ result = 31 * result + (data?.hashCode() ?: 0)
return result
}
}
@@ -131,4 +154,4 @@ class ChipsView @JvmOverloads constructor(
fun onChipCloseClick(chip: Chip, data: Any?)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CornerData.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CornerData.kt
new file mode 100644
index 000000000..9818d1f27
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CornerData.kt
@@ -0,0 +1,47 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.view.View
+import androidx.core.view.ViewCompat
+import com.google.android.material.shape.AbsoluteCornerSize
+import com.google.android.material.shape.CornerSize
+
+class CornerData(
+ var topLeft: CornerSize,
+ var bottomLeft: CornerSize,
+ var topRight: CornerSize,
+ var bottomRight: CornerSize,
+) {
+
+ fun start(view: View): CornerData {
+ return if (isLayoutRtl(view)) right() else left()
+ }
+
+ fun end(view: View): CornerData {
+ return if (isLayoutRtl(view)) left() else right()
+ }
+
+ fun left(): CornerData {
+ return CornerData(topLeft, bottomLeft, noCorner, noCorner)
+ }
+
+ fun right(): CornerData {
+ return CornerData(noCorner, noCorner, topRight, bottomRight)
+ }
+
+ fun top(): CornerData {
+ return CornerData(topLeft, noCorner, topRight, noCorner)
+ }
+
+ fun bottom(): CornerData {
+ return CornerData(noCorner, bottomLeft, noCorner, bottomRight)
+ }
+
+ private companion object {
+
+ val noCorner: CornerSize = AbsoluteCornerSize(0f)
+
+ fun isLayoutRtl(view: View): Boolean {
+ return ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
deleted file mode 100644
index 909252b48..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * Copyright 2018 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.koitharu.kotatsu.base.ui.widgets
-
-import android.content.Context
-import android.content.res.ColorStateList
-import android.graphics.drawable.Drawable
-import android.util.AttributeSet
-import android.view.LayoutInflater
-import android.widget.FrameLayout
-import androidx.annotation.ColorInt
-import androidx.annotation.StringRes
-import androidx.core.graphics.drawable.DrawableCompat
-import androidx.core.view.postDelayed
-import com.google.android.material.color.MaterialColors
-import com.google.android.material.shape.MaterialShapeDrawable
-import com.google.android.material.shape.ShapeAppearanceModel
-import com.google.android.material.snackbar.Snackbar
-import org.koitharu.kotatsu.databinding.FadingSnackbarLayoutBinding
-import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
-import com.google.android.material.R as materialR
-
-private const val ENTER_DURATION = 300L
-private const val EXIT_DURATION = 200L
-private const val SHORT_DURATION_MS = 1_500L
-private const val LONG_DURATION_MS = 2_750L
-
-/**
- * A custom snackbar implementation allowing more control over placement and entry/exit animations.
- *
- * Xtimms: Well, my sufferings over the Snackbar in [DetailsActivity] will go away forever... Thanks, Google.
- *
- * https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/widget/FadingSnackbar.kt
- */
-class FadingSnackbar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0,
-) : FrameLayout(context, attrs, defStyleAttr) {
-
- private val binding = FadingSnackbarLayoutBinding.inflate(LayoutInflater.from(context), this)
-
- init {
- binding.snackbarLayout.background = createThemedBackground()
- }
-
- fun dismiss() {
- if (visibility == VISIBLE && alpha == 1f) {
- animate()
- .alpha(0f)
- .withEndAction { visibility = GONE }
- .duration = EXIT_DURATION
- }
- }
-
- fun show(
- messageText: CharSequence?,
- @StringRes actionId: Int = 0,
- duration: Int = Snackbar.LENGTH_SHORT,
- onActionClick: (FadingSnackbar.() -> Unit)? = null,
- onDismiss: (() -> Unit)? = null,
- ) {
- binding.snackbarText.text = messageText
- if (actionId != 0) {
- with(binding.snackbarAction) {
- visibility = VISIBLE
- text = context.getString(actionId)
- setOnClickListener {
- onActionClick?.invoke(this@FadingSnackbar) ?: dismiss()
- }
- }
- } else {
- binding.snackbarAction.visibility = GONE
- }
- alpha = 0f
- visibility = VISIBLE
- animate()
- .alpha(1f)
- .duration = ENTER_DURATION
- if (duration == Snackbar.LENGTH_INDEFINITE) {
- return
- }
- val durationMs = ENTER_DURATION + if (duration == Snackbar.LENGTH_LONG) LONG_DURATION_MS else SHORT_DURATION_MS
- postDelayed(durationMs) {
- dismiss()
- onDismiss?.invoke()
- }
- }
-
- private fun createThemedBackground(): Drawable {
- val backgroundColor = MaterialColors.layer(this, materialR.attr.colorSurface, materialR.attr.colorOnSurface, 1f)
- val shapeAppearanceModel = ShapeAppearanceModel.builder(
- context,
- materialR.style.ShapeAppearance_Material3_Corner_ExtraSmall,
- 0
- ).build()
- val background = createMaterialShapeDrawableBackground(
- backgroundColor,
- shapeAppearanceModel,
- )
- val backgroundTint = context.getThemeColorStateList(materialR.attr.colorSurfaceInverse)
- return if (backgroundTint != null) {
- val wrappedDrawable = DrawableCompat.wrap(background)
- DrawableCompat.setTintList(wrappedDrawable, backgroundTint)
- wrappedDrawable
- } else {
- DrawableCompat.wrap(background)
- }
- }
-
- private fun createMaterialShapeDrawableBackground(
- @ColorInt backgroundColor: Int,
- shapeAppearanceModel: ShapeAppearanceModel,
- ): MaterialShapeDrawable {
- val background = MaterialShapeDrawable(shapeAppearanceModel)
- background.fillColor = ColorStateList.valueOf(backgroundColor)
- return background
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt
new file mode 100644
index 000000000..b1742420a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt
@@ -0,0 +1,101 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.view.animation.DecelerateInterpolator
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.ViewCompat
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.utils.ext.getAnimationDuration
+import org.koitharu.kotatsu.utils.ext.measureHeight
+
+class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
+ context: Context? = null,
+ attrs: AttributeSet? = null,
+) : CoordinatorLayout.Behavior(context, attrs) {
+
+ @ViewCompat.NestedScrollType
+ private var lastStartedType: Int = 0
+
+ private var offsetAnimator: ValueAnimator? = null
+
+ private var dyRatio = 1F
+
+ override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean {
+ return dependency is AppBarLayout
+ }
+
+ override fun onDependentViewChanged(
+ parent: CoordinatorLayout,
+ child: BottomNavigationView,
+ dependency: View,
+ ): Boolean {
+ val appBarSize = dependency.measureHeight()
+ dyRatio = if (appBarSize > 0) {
+ child.measureHeight().toFloat() / appBarSize
+ } else {
+ 1F
+ }
+ return false
+ }
+
+ override fun onStartNestedScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: BottomNavigationView,
+ directTargetChild: View,
+ target: View,
+ axes: Int,
+ type: Int,
+ ): Boolean {
+ if (axes != ViewCompat.SCROLL_AXIS_VERTICAL) {
+ return false
+ }
+ lastStartedType = type
+ offsetAnimator?.cancel()
+ return true
+ }
+
+ override fun onNestedPreScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: BottomNavigationView,
+ target: View,
+ dx: Int,
+ dy: Int,
+ consumed: IntArray,
+ type: Int,
+ ) {
+ super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
+ child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat())
+ }
+
+ override fun onStopNestedScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: BottomNavigationView,
+ target: View,
+ type: Int,
+ ) {
+ if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
+ animateBottomNavigationVisibility(child, child.translationY < child.height / 2)
+ }
+ }
+
+ private fun animateBottomNavigationVisibility(child: BottomNavigationView, isVisible: Boolean) {
+ offsetAnimator?.cancel()
+ offsetAnimator = ValueAnimator().apply {
+ interpolator = DecelerateInterpolator()
+ duration = child.context.getAnimationDuration(R.integer.config_shorterAnimTime)
+ addUpdateListener {
+ child.translationY = it.animatedValue as Float
+ }
+ }
+ offsetAnimator?.setFloatValues(
+ child.translationY,
+ if (isVisible) 0F else child.height.toFloat(),
+ )
+ offsetAnimator?.start()
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
index bdaf8f476..e51509920 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
@@ -9,16 +9,17 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.InsetDrawable
import android.graphics.drawable.RippleDrawable
import android.graphics.drawable.ShapeDrawable
-import android.graphics.drawable.shapes.RectShape
+import android.graphics.drawable.shapes.RoundRectShape
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatCheckedTextView
+import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
+import org.koitharu.kotatsu.utils.ext.resolveDp
@SuppressLint("RestrictedApi")
class ListItemTextView @JvmOverloads constructor(
@@ -38,10 +39,11 @@ class ListItemTextView @JvmOverloads constructor(
context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) {
val itemRippleColor = getRippleColor(context)
val shape = createShapeDrawable(this)
+ val roundCorners = FloatArray(8) { resources.resolveDp(32f) }
background = RippleDrawable(
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
shape,
- ShapeDrawable(RectShape()),
+ ShapeDrawable(RoundRectShape(roundCorners, null, null)),
)
checkedDrawableStart = getDrawable(R.styleable.ListItemTextView_checkedDrawableStart)
checkedDrawableEnd = getDrawable(R.styleable.ListItemTextView_checkedDrawableEnd)
@@ -118,7 +120,7 @@ class ListItemTextView @JvmOverloads constructor(
}
private fun getRippleColor(context: Context): ColorStateList {
- return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
+ return ContextCompat.getColorStateList(context, R.color.selector_overlay)
?: ColorStateList.valueOf(Color.TRANSPARENT)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt
new file mode 100644
index 000000000..aed43d75b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt
@@ -0,0 +1,123 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Outline
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewOutlineProvider
+import androidx.annotation.ColorInt
+import androidx.annotation.FloatRange
+import androidx.core.graphics.ColorUtils
+import com.google.android.material.R as materialR
+import kotlin.random.Random
+import org.koitharu.kotatsu.parsers.util.replaceWith
+import org.koitharu.kotatsu.utils.ext.getThemeColor
+import org.koitharu.kotatsu.utils.ext.resolveDp
+
+class SegmentedBarView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : View(context, attrs, defStyleAttr) {
+
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val segmentsData = ArrayList()
+ private val segmentsSizes = ArrayList()
+ private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
+ private var cornerSize = 0f
+
+ var segments: List
+ get() = segmentsData
+ set(value) {
+ segmentsData.replaceWith(value)
+ updateSizes()
+ invalidate()
+ }
+
+ init {
+ paint.strokeWidth = context.resources.resolveDp(1f)
+ outlineProvider = OutlineProvider()
+ clipToOutline = true
+
+ if (isInEditMode) {
+ segments = List(Random.nextInt(3, 5)) {
+ Segment(
+ percent = Random.nextFloat(),
+ color = ColorUtils.HSLToColor(floatArrayOf(Random.nextInt(0, 360).toFloat(), 0.5f, 0.5f)),
+ )
+ }
+ }
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ cornerSize = h / 2f
+ updateSizes()
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ if (segmentsSizes.isEmpty()) {
+ return
+ }
+ val w = width.toFloat()
+ var x = w - segmentsSizes.last()
+ for (i in (0 until segmentsData.size).reversed()) {
+ val segment = segmentsData[i]
+ paint.color = segment.color
+ paint.style = Paint.Style.FILL
+ val segmentWidth = segmentsSizes[i]
+ canvas.drawRoundRect(0f, 0f, x + cornerSize, height.toFloat(), cornerSize, cornerSize, paint)
+ paint.color = outlineColor
+ paint.style = Paint.Style.STROKE
+ canvas.drawRoundRect(0f, 0f, x + cornerSize, height.toFloat(), cornerSize, cornerSize, paint)
+ x -= segmentWidth
+ }
+ paint.color = outlineColor
+ paint.style = Paint.Style.STROKE
+ canvas.drawRoundRect(0f, 0f, w, height.toFloat(), cornerSize, cornerSize, paint)
+ }
+
+ private fun updateSizes() {
+ segmentsSizes.clear()
+ segmentsSizes.ensureCapacity(segmentsData.size + 1)
+ var w = width.toFloat()
+ for (segment in segmentsData) {
+ val segmentWidth = (w * segment.percent).coerceAtLeast(cornerSize)
+ segmentsSizes.add(segmentWidth)
+ w -= segmentWidth
+ }
+ segmentsSizes.add(w)
+ }
+
+ class Segment(
+ @FloatRange(from = 0.0, to = 1.0) val percent: Float,
+ @ColorInt val color: Int,
+ ) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Segment
+
+ if (percent != other.percent) return false
+ if (color != other.color) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = percent.hashCode()
+ result = 31 * result + color
+ return result
+ }
+ }
+
+ private class OutlineProvider : ViewOutlineProvider() {
+ override fun getOutline(view: View, outline: Outline) {
+ outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f)
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SlidingBottomNavigationView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SlidingBottomNavigationView.kt
new file mode 100644
index 000000000..f012d9a95
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SlidingBottomNavigationView.kt
@@ -0,0 +1,142 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.TimeInterpolator
+import android.content.Context
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.AttributeSet
+import android.view.ViewPropertyAnimator
+import androidx.annotation.AttrRes
+import androidx.annotation.StyleRes
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.customview.view.AbsSavedState
+import androidx.interpolator.view.animation.FastOutLinearInInterpolator
+import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
+import com.google.android.material.R as materialR
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import org.koitharu.kotatsu.utils.ext.applySystemAnimatorScale
+import org.koitharu.kotatsu.utils.ext.measureHeight
+
+private const val STATE_DOWN = 1
+private const val STATE_UP = 2
+
+private const val SLIDE_UP_ANIMATION_DURATION = 225L
+private const val SLIDE_DOWN_ANIMATION_DURATION = 175L
+
+class SlidingBottomNavigationView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = materialR.attr.bottomNavigationStyle,
+ @StyleRes defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView,
+) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes),
+ CoordinatorLayout.AttachedBehavior {
+
+ private var currentAnimator: ViewPropertyAnimator? = null
+
+ private var currentState = STATE_UP
+ private var behavior = HideBottomNavigationOnScrollBehavior()
+
+ override fun getBehavior(): CoordinatorLayout.Behavior<*> {
+ return behavior
+ }
+
+ override fun onSaveInstanceState(): Parcelable {
+ val superState = super.onSaveInstanceState()
+ return SavedState(superState, currentState, translationY)
+ }
+
+ override fun onRestoreInstanceState(state: Parcelable?) {
+ if (state is SavedState) {
+ super.onRestoreInstanceState(state.superState)
+ super.setTranslationY(state.translationY)
+ currentState = state.currentState
+ } else {
+ super.onRestoreInstanceState(state)
+ }
+ }
+
+ override fun setTranslationY(translationY: Float) {
+ // Disallow translation change when state down
+ if (currentState != STATE_DOWN) {
+ super.setTranslationY(translationY)
+ }
+ }
+
+ fun show() {
+ currentAnimator?.cancel()
+ clearAnimation()
+
+ currentState = STATE_UP
+ animateTranslation(
+ 0F,
+ SLIDE_UP_ANIMATION_DURATION,
+ LinearOutSlowInInterpolator(),
+ )
+ }
+
+ fun hide() {
+ currentAnimator?.cancel()
+ clearAnimation()
+
+ currentState = STATE_DOWN
+ val target = measureHeight()
+ if (target == 0) {
+ return
+ }
+ animateTranslation(
+ target.toFloat(),
+ SLIDE_DOWN_ANIMATION_DURATION,
+ FastOutLinearInInterpolator(),
+ )
+ }
+
+ private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) {
+ currentAnimator = animate()
+ .translationY(targetY)
+ .setInterpolator(interpolator)
+ .setDuration(duration)
+ .applySystemAnimatorScale(context)
+ .setListener(
+ object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator?) {
+ currentAnimator = null
+ postInvalidate()
+ }
+ },
+ )
+ }
+
+ internal class SavedState : AbsSavedState {
+ var currentState = STATE_UP
+ var translationY = 0F
+
+ constructor(superState: Parcelable, currentState: Int, translationY: Float) : super(superState) {
+ this.currentState = currentState
+ this.translationY = translationY
+ }
+
+ constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
+ currentState = source.readInt()
+ translationY = source.readFloat()
+ }
+
+ override fun writeToParcel(out: Parcel, flags: Int) {
+ super.writeToParcel(out, flags)
+ out.writeInt(currentState)
+ out.writeFloat(translationY)
+ }
+
+ companion object {
+
+ @Suppress("unused")
+ @JvmField
+ val CREATOR: Parcelable.Creator = object : Parcelable.Creator {
+ override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
+
+ override fun newArray(size: Int): Array = arrayOfNulls(size)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SquareLayout.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SquareLayout.kt
deleted file mode 100644
index 589d75382..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SquareLayout.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.koitharu.kotatsu.base.ui.widgets
-
-import android.content.Context
-import android.util.AttributeSet
-import android.widget.FrameLayout
-
-class SquareLayout @JvmOverloads constructor(
- context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
-) : FrameLayout(context, attrs, defStyleAttr) {
-
- public override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
- super.onMeasure(widthMeasureSpec, widthMeasureSpec)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt
index 0d915dc4f..3279dfc06 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt
@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.base.ui.widgets
-import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
@@ -21,8 +20,7 @@ class WindowInsetHolder @JvmOverloads constructor(
private var desiredHeight = 0
private var desiredWidth = 0
- @SuppressLint("RtlHardcoded")
- override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
+ override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
.getInsets(WindowInsetsCompat.Type.systemBars())
val gravity = getLayoutGravity()
@@ -41,24 +39,26 @@ class WindowInsetHolder @JvmOverloads constructor(
desiredHeight = newHeight
requestLayout()
}
- return super.dispatchApplyWindowInsets(insets)
+ return super.onApplyWindowInsets(insets)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
+ val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
- super.onMeasure(
- if (desiredWidth == 0 || widthMode == MeasureSpec.EXACTLY) {
- widthMeasureSpec
- } else {
- MeasureSpec.makeMeasureSpec(desiredWidth, widthMode)
- },
- if (desiredHeight == 0 || heightMode == MeasureSpec.EXACTLY) {
- heightMeasureSpec
- } else {
- MeasureSpec.makeMeasureSpec(desiredHeight, heightMode)
- },
- )
+ val heightSize = MeasureSpec.getSize(heightMeasureSpec)
+
+ val width: Int = when (widthMode) {
+ MeasureSpec.EXACTLY -> widthSize
+ MeasureSpec.AT_MOST -> minOf(desiredWidth, widthSize)
+ else -> desiredWidth
+ }
+ val height = when (heightMode) {
+ MeasureSpec.EXACTLY -> heightSize
+ MeasureSpec.AT_MOST -> minOf(desiredHeight, heightSize)
+ else -> desiredHeight
+ }
+ setMeasuredDimension(width, height)
}
private fun getLayoutGravity(): Int {
@@ -69,4 +69,4 @@ class WindowInsetHolder @JvmOverloads constructor(
else -> Gravity.NO_GRAVITY
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt
deleted file mode 100644
index 4a8294765..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package org.koitharu.kotatsu.bookmarks
-
-import org.koin.dsl.module
-import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
-
-val bookmarksModule
- get() = module {
-
- factory { BookmarksRepository(get()) }
- }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt
deleted file mode 100644
index 4bd63d65d..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.koitharu.kotatsu.bookmarks.data
-
-import androidx.room.Embedded
-import androidx.room.Junction
-import androidx.room.Relation
-import org.koitharu.kotatsu.core.db.entity.MangaEntity
-import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
-import org.koitharu.kotatsu.core.db.entity.TagEntity
-
-class BookmarkWithManga(
- @Embedded val bookmark: BookmarkEntity,
- @Relation(
- parentColumn = "manga_id",
- entityColumn = "manga_id"
- )
- val manga: MangaEntity,
- @Relation(
- parentColumn = "manga_id",
- entityColumn = "tag_id",
- associateBy = Junction(MangaTagsEntity::class)
- )
- val tags: List,
-)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
index dd023be7a..076b19a3c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
@@ -1,20 +1,27 @@
package org.koitharu.kotatsu.bookmarks.data
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.Query
+import androidx.room.*
import kotlinx.coroutines.flow.Flow
+import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao
abstract class BookmarksDao {
+ @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
+ abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
+
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC")
abstract fun observe(mangaId: Long): Flow>
+ @Transaction
+ @Query(
+ "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at"
+ )
+ abstract fun observe(): Flow