Compare commits
54 Commits
v3.0
...
testbuild-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fbae1256b | |||
| d61ba80bf6 | |||
| 74c9fa9488 | |||
| ce732ccca0 | |||
|
|
6b99e360e0 | ||
|
|
1c73d54a94 | ||
|
|
36e21caf96 | ||
|
|
f7f9c53466 | ||
|
|
3f2ee2a925 | ||
|
|
b1c069f62f | ||
|
|
22d48fce8f | ||
|
|
6b2666c701 | ||
|
|
414f438762 | ||
|
|
54f60040b5 | ||
| b0515033da | |||
| d37eb07301 | |||
|
|
c2fa27712c | ||
|
|
10a2589c10 | ||
|
|
05eb96e7c0 | ||
|
|
15a08ad6ae | ||
|
|
3e437c2ecb | ||
|
|
fea667b87c | ||
|
|
93eaaac084 | ||
|
|
a60df582a2 | ||
|
|
aec1d4e0d6 | ||
|
|
507f2e883c | ||
| 5e82c75893 | |||
| 9c9a389aa5 | |||
| 1b3af70690 | |||
| 2e17efe82b | |||
| 5bed854b9c | |||
| 7262b403f0 | |||
| a6fcbefc7b | |||
| 7f9ea0efa0 | |||
| 934861322e | |||
| e008fbab9b | |||
| 2cd9ea19fd | |||
| 699a249620 | |||
| 6c87d5b0bc | |||
| c92bdae842 | |||
| 6ca9608a80 | |||
| 8f9c0cbff1 | |||
| cc6b114e4d | |||
|
|
3d5c2123d4 | ||
|
|
36b4e16b7c | ||
|
|
3ebd074e93 | ||
|
|
e9b2b545a4 | ||
| cca6d5fa04 | |||
| 36a7a3ebbc | |||
| 48ec9a1ea9 | |||
| 76a9a0d1ab | |||
| f2175b40c0 | |||
| 85b992ca32 | |||
| 41fb351fe0 |
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@@ -7,7 +7,7 @@
|
|||||||
<option name="testRunner" value="GRADLE" />
|
<option name="testRunner" value="GRADLE" />
|
||||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="Embedded JDK" />
|
<option name="gradleJvm" value="Android Studio default JDK" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -25,6 +25,8 @@ Download APK from Github Releases:
|
|||||||
* Tablet-optimized material design UI
|
* Tablet-optimized material design UI
|
||||||
* Standard and Webtoon-optimized reader
|
* Standard and Webtoon-optimized reader
|
||||||
* Notifications about new chapters with updates feed
|
* Notifications about new chapters with updates feed
|
||||||
|
* Available in multiple languages
|
||||||
|
* Password protect access to the app
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
@@ -35,6 +37,14 @@ Download APK from Github Releases:
|
|||||||
|  |  |
|
|  |  |
|
||||||
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
|
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
|
||||||
|
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
||||||
|
<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages, please head over to the Weblate <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a>
|
||||||
|
|
||||||
### License
|
### License
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 32
|
targetSdkVersion 32
|
||||||
versionCode 400
|
versionCode 402
|
||||||
versionName '3.0'
|
versionName '3.1.1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -65,12 +65,12 @@ android {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||||||
implementation('com.github.nv95:kotatsu-parsers:3ea7e92e64') {
|
implementation('com.github.nv95:kotatsu-parsers:8e23a7fcd4') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
|
||||||
|
|
||||||
implementation 'androidx.core:core-ktx:1.7.0'
|
implementation 'androidx.core:core-ktx:1.7.0'
|
||||||
implementation 'androidx.activity:activity-ktx:1.4.0'
|
implementation 'androidx.activity:activity-ktx:1.4.0'
|
||||||
@@ -100,7 +100,7 @@ dependencies {
|
|||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||||
|
|
||||||
implementation 'io.insert-koin:koin-android:3.1.5'
|
implementation 'io.insert-koin:koin-android:3.1.6'
|
||||||
implementation 'io.coil-kt:coil-base:1.4.0'
|
implementation 'io.coil-kt:coil-base:1.4.0'
|
||||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
@@ -108,7 +108,7 @@ dependencies {
|
|||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
|
||||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
|
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||||
|
|||||||
@@ -8,23 +8,23 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission
|
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="28"
|
|
||||||
tools:ignore="ScopedStorage" />
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:fullBackupContent="@xml/backup_descriptor"
|
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
|
||||||
|
android:dataExtractionRules="@xml/backup_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_content"
|
||||||
|
android:fullBackupOnly="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Kotatsu"
|
android:theme="@style/Theme.Kotatsu"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
tools:ignore="UnusedAttribute">
|
tools:ignore="UnusedAttribute">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
|
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -58,15 +58,6 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
|
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
|
||||||
android:label="@string/settings" />
|
android:label="@string/settings" />
|
||||||
<activity
|
|
||||||
android:name="org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:label="@string/settings">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
@@ -104,6 +95,7 @@
|
|||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:label="@string/downloads" />
|
android:label="@string/downloads" />
|
||||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
|
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,19 @@ package org.koitharu.kotatsu.base.domain
|
|||||||
|
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.*
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
|
||||||
|
|
||||||
class MangaDataRepository(private val db: MangaDatabase) {
|
class MangaDataRepository(private val db: MangaDatabase) {
|
||||||
|
|
||||||
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
|
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
|
||||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
val tags = manga.tags.toEntities()
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.tagsDao.upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||||
db.preferencesDao.upsert(
|
db.preferencesDao.upsert(
|
||||||
MangaPrefsEntity(
|
MangaPrefsEntity(
|
||||||
mangaId = manga.id,
|
mangaId = manga.id,
|
||||||
@@ -37,21 +34,19 @@ class MangaDataRepository(private val db: MangaDatabase) {
|
|||||||
|
|
||||||
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
||||||
intent.manga != null -> intent.manga
|
intent.manga != null -> intent.manga
|
||||||
intent.mangaId != 0L -> db.mangaDao.find(intent.mangaId)?.toManga()
|
intent.mangaId != 0L -> findMangaById(intent.mangaId)
|
||||||
else -> null // TODO resolve uri
|
else -> null // TODO resolve uri
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun storeManga(manga: Manga) {
|
suspend fun storeManga(manga: Manga) {
|
||||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
val tags = manga.tags.toEntities()
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.tagsDao.upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findTags(source: MangaSource): Set<MangaTag> {
|
suspend fun findTags(source: MangaSource): Set<MangaTag> {
|
||||||
return db.tagsDao.findTags(source.name).mapToSet {
|
return db.tagsDao.findTags(source.name).toMangaTags()
|
||||||
it.toMangaTag()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
|
|||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
import org.koitharu.kotatsu.utils.ext.medianOrNull
|
import org.koitharu.kotatsu.parsers.util.medianOrNull
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.view.KeyEvent
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.appcompat.widget.ActionBarContextView
|
import androidx.appcompat.widget.ActionBarContextView
|
||||||
@@ -20,11 +21,13 @@ import androidx.viewbinding.ViewBinding
|
|||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
|
||||||
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
abstract class BaseActivity<B : ViewBinding> :
|
||||||
|
AppCompatActivity(),
|
||||||
WindowInsetsDelegate.WindowInsetsListener {
|
WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
protected lateinit var binding: B
|
protected lateinit var binding: B
|
||||||
@@ -36,6 +39,8 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
|||||||
@Suppress("LeakingThis")
|
@Suppress("LeakingThis")
|
||||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||||
|
|
||||||
|
val actionModeDelegate = ActionModeDelegate()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
val settings = get<AppSettings>()
|
val settings = get<AppSettings>()
|
||||||
when {
|
when {
|
||||||
@@ -90,8 +95,10 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
|||||||
return isNight && get<AppSettings>().isAmoledTheme
|
return isNight && get<AppSettings>().isAmoledTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
override fun onSupportActionModeStarted(mode: ActionMode) {
|
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||||
super.onSupportActionModeStarted(mode)
|
super.onSupportActionModeStarted(mode)
|
||||||
|
actionModeDelegate.onSupportActionModeStarted(mode)
|
||||||
val insets = ViewCompat.getRootWindowInsets(binding.root)
|
val insets = ViewCompat.getRootWindowInsets(binding.root)
|
||||||
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
||||||
val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)
|
val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)
|
||||||
@@ -100,6 +107,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onSupportActionModeFinished(mode: ActionMode) {
|
||||||
|
super.onSupportActionModeFinished(mode)
|
||||||
|
actionModeDelegate.onSupportActionModeFinished(mode)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
if ( // https://issuetracker.google.com/issues/139738913
|
if ( // https://issuetracker.google.com/issues/139738913
|
||||||
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
|
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
|
||||||
@@ -111,4 +124,4 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
|||||||
super.onBackPressed()
|
super.onBackPressed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,12 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
|
|
||||||
abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
abstract class BaseFragment<B : ViewBinding> :
|
||||||
|
Fragment(),
|
||||||
WindowInsetsDelegate.WindowInsetsListener {
|
WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
private var viewBinding: B? = null
|
private var viewBinding: B? = null
|
||||||
@@ -23,6 +25,9 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
|||||||
@Suppress("LeakingThis")
|
@Suppress("LeakingThis")
|
||||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||||
|
|
||||||
|
protected val actionModeDelegate: ActionModeDelegate
|
||||||
|
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -47,4 +52,4 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
|||||||
protected fun bindingOrNull() = viewBinding
|
protected fun bindingOrNull() = viewBinding
|
||||||
|
|
||||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,18 @@ import androidx.annotation.CallSuper
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
||||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
|
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
|
||||||
|
|
||||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(),
|
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||||
|
PreferenceFragmentCompat(),
|
||||||
WindowInsetsDelegate.WindowInsetsListener,
|
WindowInsetsDelegate.WindowInsetsListener,
|
||||||
RecyclerViewOwner {
|
RecyclerViewOwner {
|
||||||
|
|
||||||
@@ -39,16 +43,20 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : Pre
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (titleId != 0) {
|
if (titleId != 0) {
|
||||||
activity?.setTitle(titleId)
|
setTitle(getString(titleId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
listView.updatePadding(
|
listView.updatePadding(
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
bottom = insets.bottom
|
bottom = insets.bottom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@Suppress("UsePropertyAccessSyntax")
|
||||||
|
protected fun setTitle(title: CharSequence) {
|
||||||
|
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
|
||||||
|
?: activity?.setTitle(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui.dialog
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.BaseAdapter
|
import android.widget.BaseAdapter
|
||||||
@@ -12,7 +13,6 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.utils.ext.inflate
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
||||||
@@ -66,7 +66,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
|||||||
val volumes = getAvailableVolumes(storageManager)
|
val volumes = getAvailableVolumes(storageManager)
|
||||||
|
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
val view = convertView ?: parent.inflate(R.layout.item_storage)
|
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
|
||||||
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
|
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
|
||||||
view.tag = it
|
view.tag = it
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list.decor
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.view.children
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
||||||
|
|
||||||
|
abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
||||||
|
|
||||||
|
private val bounds = Rect()
|
||||||
|
private val boundsF = RectF()
|
||||||
|
private val selection = HashSet<Long>()
|
||||||
|
|
||||||
|
protected var hasBackground: Boolean = true
|
||||||
|
protected var hasForeground: Boolean = false
|
||||||
|
protected var isIncludeDecorAndMargins: Boolean = true
|
||||||
|
|
||||||
|
val checkedItemsCount: Int
|
||||||
|
get() = selection.size
|
||||||
|
|
||||||
|
val checkedItemsIds: Set<Long>
|
||||||
|
get() = selection
|
||||||
|
|
||||||
|
fun toggleItemChecked(id: Long) {
|
||||||
|
if (!selection.remove(id)) {
|
||||||
|
selection.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setItemIsChecked(id: Long, isChecked: Boolean) {
|
||||||
|
if (isChecked) {
|
||||||
|
selection.add(id)
|
||||||
|
} else {
|
||||||
|
selection.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkAll(ids: Collection<Long>) {
|
||||||
|
selection.addAll(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelection() {
|
||||||
|
selection.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||||
|
if (hasBackground) {
|
||||||
|
doDraw(canvas, parent, state, false)
|
||||||
|
} else {
|
||||||
|
super.onDraw(canvas, parent, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||||
|
if (hasForeground) {
|
||||||
|
doDraw(canvas, parent, state, true)
|
||||||
|
} else {
|
||||||
|
super.onDrawOver(canvas, parent, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State, isOver: Boolean) {
|
||||||
|
val checkpoint = canvas.save()
|
||||||
|
if (parent.clipToPadding) {
|
||||||
|
canvas.clipRect(
|
||||||
|
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
|
||||||
|
parent.height - parent.paddingBottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (child in parent.children) {
|
||||||
|
val itemId = getItemId(parent, child)
|
||||||
|
if (itemId != NO_ID && itemId in selection) {
|
||||||
|
if (isIncludeDecorAndMargins) {
|
||||||
|
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||||
|
} else {
|
||||||
|
bounds.set(child.left, child.top, child.right, child.bottom)
|
||||||
|
}
|
||||||
|
boundsF.set(bounds)
|
||||||
|
boundsF.offset(child.translationX, child.translationY)
|
||||||
|
if (isOver) {
|
||||||
|
onDrawForeground(canvas, parent, child, boundsF, state)
|
||||||
|
} else {
|
||||||
|
onDrawBackground(canvas, parent, child, boundsF, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.restoreToCount(checkpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child)
|
||||||
|
|
||||||
|
protected open fun onDrawBackground(
|
||||||
|
canvas: Canvas,
|
||||||
|
parent: RecyclerView,
|
||||||
|
child: View,
|
||||||
|
bounds: RectF,
|
||||||
|
state: RecyclerView.State,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
protected open fun onDrawForeground(
|
||||||
|
canvas: Canvas,
|
||||||
|
parent: RecyclerView,
|
||||||
|
child: View,
|
||||||
|
bounds: RectF,
|
||||||
|
state: RecyclerView.State,
|
||||||
|
) = Unit
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
|
||||||
|
class ActionModeDelegate {
|
||||||
|
|
||||||
|
private var activeActionMode: ActionMode? = null
|
||||||
|
private var listeners: MutableList<ActionModeListener>? = null
|
||||||
|
|
||||||
|
val isActionModeStarted: Boolean
|
||||||
|
get() = activeActionMode != null
|
||||||
|
|
||||||
|
fun onSupportActionModeStarted(mode: ActionMode) {
|
||||||
|
activeActionMode = mode
|
||||||
|
listeners?.forEach { it.onActionModeStarted(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSupportActionModeFinished(mode: ActionMode) {
|
||||||
|
activeActionMode = null
|
||||||
|
listeners?.forEach { it.onActionModeFinished(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListener(listener: ActionModeListener) {
|
||||||
|
if (listeners == null) {
|
||||||
|
listeners = ArrayList()
|
||||||
|
}
|
||||||
|
checkNotNull(listeners).add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeListener(listener: ActionModeListener) {
|
||||||
|
listeners?.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListener(listener: ActionModeListener, owner: LifecycleOwner) {
|
||||||
|
addListener(listener)
|
||||||
|
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ListenerLifecycleObserver(
|
||||||
|
private val listener: ActionModeListener,
|
||||||
|
) : DefaultLifecycleObserver {
|
||||||
|
|
||||||
|
override fun onDestroy(owner: LifecycleOwner) {
|
||||||
|
super.onDestroy(owner)
|
||||||
|
removeListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
|
||||||
|
interface ActionModeListener {
|
||||||
|
|
||||||
|
fun onActionModeStarted(mode: ActionMode)
|
||||||
|
|
||||||
|
fun onActionModeFinished(mode: ActionMode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||||
|
|
||||||
|
class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
|
||||||
|
|
||||||
|
@Suppress("unused") constructor() : super()
|
||||||
|
@Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
|
||||||
|
|
||||||
|
override fun onStartNestedScroll(
|
||||||
|
coordinatorLayout: CoordinatorLayout,
|
||||||
|
child: ExtendedFloatingActionButton,
|
||||||
|
directTargetChild: View,
|
||||||
|
target: View,
|
||||||
|
axes: Int,
|
||||||
|
type: Int
|
||||||
|
): Boolean {
|
||||||
|
return axes == ViewCompat.SCROLL_AXIS_VERTICAL
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNestedScroll(
|
||||||
|
coordinatorLayout: CoordinatorLayout,
|
||||||
|
child: ExtendedFloatingActionButton,
|
||||||
|
target: View,
|
||||||
|
dxConsumed: Int,
|
||||||
|
dyConsumed: Int,
|
||||||
|
dxUnconsumed: Int,
|
||||||
|
dyUnconsumed: Int,
|
||||||
|
type: Int,
|
||||||
|
consumed: IntArray
|
||||||
|
) {
|
||||||
|
if (dyConsumed > 0) {
|
||||||
|
if (child.isExtended) {
|
||||||
|
child.shrink()
|
||||||
|
}
|
||||||
|
} else if (dyConsumed < 0) {
|
||||||
|
if (!child.isExtended) {
|
||||||
|
child.extend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,12 @@ import android.graphics.drawable.shapes.RectShape
|
|||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
import androidx.appcompat.widget.AppCompatCheckedTextView
|
import androidx.appcompat.widget.AppCompatCheckedTextView
|
||||||
import androidx.core.content.res.use
|
|
||||||
import androidx.core.content.withStyledAttributes
|
import androidx.core.content.withStyledAttributes
|
||||||
import com.google.android.material.ripple.RippleUtils
|
import com.google.android.material.ripple.RippleUtils
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import com.google.android.material.shape.ShapeAppearanceModel
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
class ListItemTextView @JvmOverloads constructor(
|
class ListItemTextView @JvmOverloads constructor(
|
||||||
@@ -119,8 +119,7 @@ class ListItemTextView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getRippleColorFallback(context: Context): ColorStateList {
|
private fun getRippleColorFallback(context: Context): ColorStateList {
|
||||||
return context.obtainStyledAttributes(intArrayOf(android.R.attr.colorControlHighlight)).use {
|
return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
|
||||||
it.getColorStateList(0)
|
?: ColorStateList.valueOf(Color.TRANSPARENT)
|
||||||
} ?: ColorStateList.valueOf(Color.TRANSPARENT)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.widgets
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
|
||||||
|
class WindowInsetHolder @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = 0,
|
||||||
|
) : View(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private var desiredHeight = 0
|
||||||
|
private var desiredWidth = 0
|
||||||
|
|
||||||
|
@SuppressLint("RtlHardcoded")
|
||||||
|
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||||
|
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
|
||||||
|
.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val gravity = getLayoutGravity()
|
||||||
|
val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
|
||||||
|
Gravity.LEFT -> barsInsets.left
|
||||||
|
Gravity.RIGHT -> barsInsets.right
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) {
|
||||||
|
Gravity.TOP -> barsInsets.top
|
||||||
|
Gravity.BOTTOM -> barsInsets.bottom
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
if (newWidth != desiredWidth || newHeight != desiredHeight) {
|
||||||
|
desiredWidth = newWidth
|
||||||
|
desiredHeight = newHeight
|
||||||
|
requestLayout()
|
||||||
|
}
|
||||||
|
return super.dispatchApplyWindowInsets(insets)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
|
val widthMode = MeasureSpec.getMode(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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLayoutGravity(): Int {
|
||||||
|
return when (val lp = layoutParams) {
|
||||||
|
is FrameLayout.LayoutParams -> lp.gravity
|
||||||
|
is LinearLayout.LayoutParams -> lp.gravity
|
||||||
|
is CoordinatorLayout.LayoutParams -> lp.gravity
|
||||||
|
else -> Gravity.NO_GRAVITY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.db
|
package org.koitharu.kotatsu.core.db
|
||||||
|
|
||||||
import androidx.room.Room
|
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koitharu.kotatsu.core.db.migrations.*
|
|
||||||
|
|
||||||
val databaseModule
|
val databaseModule
|
||||||
get() = module {
|
get() = module {
|
||||||
single {
|
single { MangaDatabase.create(androidContext()) }
|
||||||
Room.databaseBuilder(
|
|
||||||
androidContext(),
|
|
||||||
MangaDatabase::class.java,
|
|
||||||
"kotatsu-db"
|
|
||||||
).addMigrations(
|
|
||||||
Migration1To2(),
|
|
||||||
Migration2To3(),
|
|
||||||
Migration3To4(),
|
|
||||||
Migration4To5(),
|
|
||||||
Migration5To6(),
|
|
||||||
Migration6To7(),
|
|
||||||
Migration7To8(),
|
|
||||||
Migration8To9(),
|
|
||||||
).addCallback(
|
|
||||||
DatabasePrePopulateCallback(androidContext().resources)
|
|
||||||
).build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package org.koitharu.kotatsu.core.db
|
package org.koitharu.kotatsu.core.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import org.koitharu.kotatsu.core.db.dao.*
|
import org.koitharu.kotatsu.core.db.dao.*
|
||||||
import org.koitharu.kotatsu.core.db.entity.*
|
import org.koitharu.kotatsu.core.db.entity.*
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.*
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
@@ -40,4 +43,24 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
abstract val trackLogsDao: TrackLogsDao
|
abstract val trackLogsDao: TrackLogsDao
|
||||||
|
|
||||||
abstract val suggestionDao: SuggestionDao
|
abstract val suggestionDao: SuggestionDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun create(context: Context): MangaDatabase = Room.databaseBuilder(
|
||||||
|
context,
|
||||||
|
MangaDatabase::class.java,
|
||||||
|
"kotatsu-db"
|
||||||
|
).addMigrations(
|
||||||
|
Migration1To2(),
|
||||||
|
Migration2To3(),
|
||||||
|
Migration3To4(),
|
||||||
|
Migration4To5(),
|
||||||
|
Migration5To6(),
|
||||||
|
Migration6To7(),
|
||||||
|
Migration7To8(),
|
||||||
|
Migration8To9(),
|
||||||
|
).addCallback(
|
||||||
|
DatabasePrePopulateCallback(context.resources)
|
||||||
|
).build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ abstract class TagsDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"""SELECT tags.* FROM tags
|
"""SELECT tags.* FROM tags
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
GROUP BY manga_tags.tag_id
|
GROUP BY tags.title
|
||||||
ORDER BY COUNT(manga_id) DESC
|
ORDER BY COUNT(manga_id) DESC
|
||||||
LIMIT :limit"""
|
LIMIT :limit"""
|
||||||
)
|
)
|
||||||
@@ -22,7 +22,7 @@ abstract class TagsDao {
|
|||||||
"""SELECT tags.* FROM tags
|
"""SELECT tags.* FROM tags
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
WHERE tags.source = :source
|
WHERE tags.source = :source
|
||||||
GROUP BY manga_tags.tag_id
|
GROUP BY tags.title
|
||||||
ORDER BY COUNT(manga_id) DESC
|
ORDER BY COUNT(manga_id) DESC
|
||||||
LIMIT :limit"""
|
LIMIT :limit"""
|
||||||
)
|
)
|
||||||
@@ -32,7 +32,7 @@ abstract class TagsDao {
|
|||||||
"""SELECT tags.* FROM tags
|
"""SELECT tags.* FROM tags
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
WHERE tags.source = :source AND title LIKE :query
|
WHERE tags.source = :source AND title LIKE :query
|
||||||
GROUP BY manga_tags.tag_id
|
GROUP BY tags.title
|
||||||
ORDER BY COUNT(manga_id) DESC
|
ORDER BY COUNT(manga_id) DESC
|
||||||
LIMIT :limit"""
|
LIMIT :limit"""
|
||||||
)
|
)
|
||||||
@@ -42,7 +42,7 @@ abstract class TagsDao {
|
|||||||
"""SELECT tags.* FROM tags
|
"""SELECT tags.* FROM tags
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
WHERE title LIKE :query
|
WHERE title LIKE :query
|
||||||
GROUP BY manga_tags.tag_id
|
GROUP BY tags.title
|
||||||
ORDER BY COUNT(manga_id) DESC
|
ORDER BY COUNT(manga_id) DESC
|
||||||
LIMIT :limit"""
|
LIMIT :limit"""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.entity
|
||||||
|
|
||||||
|
import java.util.*
|
||||||
|
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
|
||||||
|
// Entity to model
|
||||||
|
|
||||||
|
fun TagEntity.toMangaTag() = MangaTag(
|
||||||
|
key = this.key,
|
||||||
|
title = this.title.toTitleCase(),
|
||||||
|
source = MangaSource.valueOf(this.source),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
|
||||||
|
|
||||||
|
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||||
|
id = this.id,
|
||||||
|
title = this.title,
|
||||||
|
altTitle = this.altTitle,
|
||||||
|
state = this.state?.let { MangaState.valueOf(it) },
|
||||||
|
rating = this.rating,
|
||||||
|
isNsfw = this.isNsfw,
|
||||||
|
url = this.url,
|
||||||
|
publicUrl = this.publicUrl,
|
||||||
|
coverUrl = this.coverUrl,
|
||||||
|
largeCoverUrl = this.largeCoverUrl,
|
||||||
|
author = this.author,
|
||||||
|
source = MangaSource.valueOf(this.source),
|
||||||
|
tags = tags
|
||||||
|
)
|
||||||
|
|
||||||
|
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||||
|
|
||||||
|
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
|
||||||
|
id = trackLog.id,
|
||||||
|
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
|
||||||
|
manga = manga.toManga(tags.toMangaTags()),
|
||||||
|
createdAt = Date(trackLog.createdAt)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model to entity
|
||||||
|
|
||||||
|
fun Manga.toEntity() = MangaEntity(
|
||||||
|
id = id,
|
||||||
|
url = url,
|
||||||
|
publicUrl = publicUrl,
|
||||||
|
source = source.name,
|
||||||
|
largeCoverUrl = largeCoverUrl,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
altTitle = altTitle,
|
||||||
|
rating = rating,
|
||||||
|
isNsfw = isNsfw,
|
||||||
|
state = state?.name,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun MangaTag.toEntity() = TagEntity(
|
||||||
|
title = title,
|
||||||
|
key = key,
|
||||||
|
source = source.name,
|
||||||
|
id = "${key}_${source.name}".longHashCode()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||||
|
|
||||||
|
// Other
|
||||||
|
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||||
|
SortOrder.valueOf(name)
|
||||||
|
}.getOrDefault(fallback)
|
||||||
@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
|||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
|
|
||||||
@Entity(tableName = "manga")
|
@Entity(tableName = "manga")
|
||||||
class MangaEntity(
|
class MangaEntity(
|
||||||
@@ -16,46 +12,11 @@ class MangaEntity(
|
|||||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
||||||
@ColumnInfo(name = "url") val url: String,
|
@ColumnInfo(name = "url") val url: String,
|
||||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||||
@ColumnInfo(name = "rating") val rating: Float, //normalized value [0..1] or -1
|
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||||
@ColumnInfo(name = "state") val state: String?,
|
@ColumnInfo(name = "state") val state: String?,
|
||||||
@ColumnInfo(name = "author") val author: String?,
|
@ColumnInfo(name = "author") val author: String?,
|
||||||
@ColumnInfo(name = "source") val source: String
|
@ColumnInfo(name = "source") val source: String
|
||||||
) {
|
)
|
||||||
|
|
||||||
fun toManga(tags: Set<MangaTag> = emptySet()) = Manga(
|
|
||||||
id = this.id,
|
|
||||||
title = this.title,
|
|
||||||
altTitle = this.altTitle,
|
|
||||||
state = this.state?.let { MangaState.valueOf(it) },
|
|
||||||
rating = this.rating,
|
|
||||||
isNsfw = this.isNsfw,
|
|
||||||
url = this.url,
|
|
||||||
publicUrl = this.publicUrl,
|
|
||||||
coverUrl = this.coverUrl,
|
|
||||||
largeCoverUrl = this.largeCoverUrl,
|
|
||||||
author = this.author,
|
|
||||||
source = MangaSource.valueOf(this.source),
|
|
||||||
tags = tags
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun from(manga: Manga) = MangaEntity(
|
|
||||||
id = manga.id,
|
|
||||||
url = manga.url,
|
|
||||||
publicUrl = manga.publicUrl,
|
|
||||||
source = manga.source.name,
|
|
||||||
largeCoverUrl = manga.largeCoverUrl,
|
|
||||||
coverUrl = manga.coverUrl,
|
|
||||||
altTitle = manga.altTitle,
|
|
||||||
rating = manga.rating,
|
|
||||||
isNsfw = manga.isNsfw,
|
|
||||||
state = manga.state?.name,
|
|
||||||
title = manga.title,
|
|
||||||
author = manga.author
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,15 @@ import androidx.room.ForeignKey
|
|||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "preferences", foreignKeys = [
|
tableName = "preferences",
|
||||||
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
childColumns = ["manga_id"],
|
childColumns = ["manga_id"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE
|
||||||
)]
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
class MangaPrefsEntity(
|
class MangaPrefsEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import androidx.room.Entity
|
|||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], foreignKeys = [
|
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"],
|
||||||
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
|||||||
import androidx.room.Embedded
|
import androidx.room.Embedded
|
||||||
import androidx.room.Junction
|
import androidx.room.Junction
|
||||||
import androidx.room.Relation
|
import androidx.room.Relation
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
|
||||||
|
|
||||||
class MangaWithTags(
|
class MangaWithTags(
|
||||||
@Embedded val manga: MangaEntity,
|
@Embedded val manga: MangaEntity,
|
||||||
@@ -12,10 +11,5 @@ class MangaWithTags(
|
|||||||
entityColumn = "tag_id",
|
entityColumn = "tag_id",
|
||||||
associateBy = Junction(MangaTagsEntity::class)
|
associateBy = Junction(MangaTagsEntity::class)
|
||||||
)
|
)
|
||||||
val tags: List<TagEntity>
|
val tags: List<TagEntity>,
|
||||||
) {
|
)
|
||||||
|
|
||||||
fun toManga() = manga.toManga(tags.mapToSet {
|
|
||||||
it.toMangaTag()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
|||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
|
||||||
|
|
||||||
@Entity(tableName = "tags")
|
@Entity(tableName = "tags")
|
||||||
class TagEntity(
|
class TagEntity(
|
||||||
@@ -15,21 +11,4 @@ class TagEntity(
|
|||||||
@ColumnInfo(name = "title") val title: String,
|
@ColumnInfo(name = "title") val title: String,
|
||||||
@ColumnInfo(name = "key") val key: String,
|
@ColumnInfo(name = "key") val key: String,
|
||||||
@ColumnInfo(name = "source") val source: String
|
@ColumnInfo(name = "source") val source: String
|
||||||
) {
|
)
|
||||||
|
|
||||||
fun toMangaTag() = MangaTag(
|
|
||||||
key = this.key,
|
|
||||||
title = this.title.toTitleCase(),
|
|
||||||
source = MangaSource.valueOf(this.source)
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun fromMangaTag(tag: MangaTag) = TagEntity(
|
|
||||||
title = tag.title,
|
|
||||||
key = tag.key,
|
|
||||||
source = tag.source.name,
|
|
||||||
id = "${tag.key}_${tag.source.name}".longHashCode()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
|
|||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "tracks", foreignKeys = [
|
tableName = "tracks",
|
||||||
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
|
|||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "track_logs", foreignKeys = [
|
tableName = "track_logs",
|
||||||
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
@@ -20,5 +21,5 @@ class TrackLogEntity(
|
|||||||
@ColumnInfo(name = "id") val id: Long = 0L,
|
@ColumnInfo(name = "id") val id: Long = 0L,
|
||||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||||
@ColumnInfo(name = "chapters") val chapters: String,
|
@ColumnInfo(name = "chapters") val chapters: String,
|
||||||
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
|
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
|||||||
import androidx.room.Embedded
|
import androidx.room.Embedded
|
||||||
import androidx.room.Junction
|
import androidx.room.Junction
|
||||||
import androidx.room.Relation
|
import androidx.room.Relation
|
||||||
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class TrackLogWithManga(
|
class TrackLogWithManga(
|
||||||
@Embedded val trackLog: TrackLogEntity,
|
@Embedded val trackLog: TrackLogEntity,
|
||||||
@@ -19,13 +16,5 @@ class TrackLogWithManga(
|
|||||||
entityColumn = "tag_id",
|
entityColumn = "tag_id",
|
||||||
associateBy = Junction(MangaTagsEntity::class)
|
associateBy = Junction(MangaTagsEntity::class)
|
||||||
)
|
)
|
||||||
val tags: List<TagEntity>
|
val tags: List<TagEntity>,
|
||||||
) {
|
)
|
||||||
|
|
||||||
fun toTrackingLogItem() = TrackingLogItem(
|
|
||||||
id = trackLog.id,
|
|
||||||
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
|
|
||||||
manga = manga.toManga(tags.mapToSet { x -> x.toMangaTag() }),
|
|
||||||
createdAt = Date(trackLog.createdAt)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
|
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||||
this
|
|
||||||
} else {
|
|
||||||
Manga(
|
|
||||||
id = id,
|
|
||||||
title = title,
|
|
||||||
altTitle = altTitle,
|
|
||||||
url = url,
|
|
||||||
publicUrl = publicUrl,
|
|
||||||
rating = rating,
|
|
||||||
isNsfw = isNsfw,
|
|
||||||
coverUrl = coverUrl,
|
|
||||||
tags = tags,
|
|
||||||
state = state,
|
|
||||||
author = author,
|
|
||||||
largeCoverUrl = largeCoverUrl,
|
|
||||||
description = description,
|
|
||||||
chapters = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import android.os.Parcel
|
|||||||
import androidx.core.os.ParcelCompat
|
import androidx.core.os.ParcelCompat
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
|
||||||
fun Manga.writeToParcel(out: Parcel, flags: Int) {
|
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
|
||||||
out.writeLong(id)
|
out.writeLong(id)
|
||||||
out.writeString(title)
|
out.writeString(title)
|
||||||
out.writeString(altTitle)
|
out.writeString(altTitle)
|
||||||
@@ -18,7 +18,11 @@ fun Manga.writeToParcel(out: Parcel, flags: Int) {
|
|||||||
out.writeParcelable(ParcelableMangaTags(tags), flags)
|
out.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||||
out.writeSerializable(state)
|
out.writeSerializable(state)
|
||||||
out.writeString(author)
|
out.writeString(author)
|
||||||
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
if (withChapters) {
|
||||||
|
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
||||||
|
} else {
|
||||||
|
out.writeString(null)
|
||||||
|
}
|
||||||
out.writeSerializable(source)
|
out.writeSerializable(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,39 @@ package org.koitharu.kotatsu.core.model.parcelable
|
|||||||
|
|
||||||
import android.os.Parcel
|
import android.os.Parcel
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.util.Log
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
// Limits to avoid TransactionTooLargeException
|
||||||
|
private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size
|
||||||
|
private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
|
||||||
|
|
||||||
class ParcelableManga(
|
class ParcelableManga(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
|
private val withChapters: Boolean,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
constructor(parcel: Parcel) : this(parcel.readManga())
|
constructor(parcel: Parcel) : this(parcel.readManga(), true)
|
||||||
|
|
||||||
init {
|
|
||||||
if (BuildConfig.DEBUG && manga.chapters != null) {
|
|
||||||
Log.w("ParcelableManga", "Passing manga with chapters as Parcelable is dangerous!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||||
manga.writeToParcel(parcel, flags)
|
val chapters = manga.chapters
|
||||||
|
if (!withChapters || chapters == null) {
|
||||||
|
manga.writeToParcel(parcel, flags, withChapters = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
|
||||||
|
// fast path
|
||||||
|
manga.writeToParcel(parcel, flags, withChapters = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tempParcel = Parcel.obtain()
|
||||||
|
manga.writeToParcel(tempParcel, flags, withChapters = true)
|
||||||
|
val size = tempParcel.dataSize()
|
||||||
|
if (size < MAX_SAFE_SIZE) {
|
||||||
|
parcel.appendFrom(tempParcel, 0, size)
|
||||||
|
} else {
|
||||||
|
manga.writeToParcel(parcel, flags, withChapters = false)
|
||||||
|
}
|
||||||
|
tempParcel.recycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun describeContents(): Int {
|
override fun describeContents(): Int {
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okio.Buffer
|
|
||||||
import java.io.IOException
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
|
|
||||||
private const val TAG = "CURL"
|
|
||||||
|
|
||||||
class CurlLoggingInterceptor(
|
|
||||||
private val extraCurlOptions: String? = null,
|
|
||||||
) : Interceptor {
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request: Request = chain.request()
|
|
||||||
var compressed = false
|
|
||||||
val curlCmd = StringBuilder("curl")
|
|
||||||
if (extraCurlOptions != null) {
|
|
||||||
curlCmd.append(" ").append(extraCurlOptions)
|
|
||||||
}
|
|
||||||
curlCmd.append(" -X ").append(request.method)
|
|
||||||
val headers = request.headers
|
|
||||||
var i = 0
|
|
||||||
val count = headers.size
|
|
||||||
while (i < count) {
|
|
||||||
val name = headers.name(i)
|
|
||||||
val value = headers.value(i)
|
|
||||||
if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value,
|
|
||||||
ignoreCase = true)
|
|
||||||
) {
|
|
||||||
compressed = true
|
|
||||||
}
|
|
||||||
curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"")
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
val requestBody = request.body
|
|
||||||
if (requestBody != null) {
|
|
||||||
val buffer = Buffer()
|
|
||||||
requestBody.writeTo(buffer)
|
|
||||||
val contentType = requestBody.contentType()
|
|
||||||
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
|
||||||
curlCmd.append(" --data $'")
|
|
||||||
.append(buffer.readString(charset).replace("\n", "\\n"))
|
|
||||||
.append("'")
|
|
||||||
}
|
|
||||||
curlCmd.append(if (compressed) " --compressed " else " ").append(request.url)
|
|
||||||
Log.d(TAG, "╭--- cURL (" + request.url + ")")
|
|
||||||
Log.d(TAG, curlCmd.toString())
|
|
||||||
Log.d(TAG, "╰--- (copy and paste the above line to a terminal)")
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import okhttp3.CookieJar
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.dsl.bind
|
import org.koin.dsl.bind
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
@@ -22,9 +21,6 @@ val networkModule
|
|||||||
cache(get<LocalStorageManager>().createHttpCache())
|
cache(get<LocalStorageManager>().createHttpCache())
|
||||||
addInterceptor(UserAgentInterceptor())
|
addInterceptor(UserAgentInterceptor())
|
||||||
addInterceptor(CloudFlareInterceptor())
|
addInterceptor(CloudFlareInterceptor())
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
addNetworkInterceptor(CurlLoggingInterceptor())
|
|
||||||
}
|
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }
|
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class ShortcutsRepository(
|
|||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val coil: ImageLoader,
|
private val coil: ImageLoader,
|
||||||
private val historyRepository: HistoryRepository,
|
private val historyRepository: HistoryRepository,
|
||||||
private val mangaRepository: MangaDataRepository
|
private val mangaRepository: MangaDataRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val iconSize by lazy {
|
private val iconSize by lazy {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import androidx.collection.arraySetOf
|
|||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
|
import java.io.File
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
@@ -19,10 +23,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.utils.ext.getEnumValue
|
import org.koitharu.kotatsu.utils.ext.getEnumValue
|
||||||
import org.koitharu.kotatsu.utils.ext.putEnumValue
|
import org.koitharu.kotatsu.utils.ext.putEnumValue
|
||||||
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
||||||
import java.io.File
|
|
||||||
import java.text.DateFormat
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class AppSettings(context: Context) {
|
class AppSettings(context: Context) {
|
||||||
|
|
||||||
@@ -165,6 +165,18 @@ class AppSettings(context: Context) {
|
|||||||
else -> SimpleDateFormat(format, Locale.getDefault())
|
else -> SimpleDateFormat(format, Locale.getDefault())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getSuggestionsTagsBlacklistRegex(): Regex? {
|
||||||
|
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
|
||||||
|
if (string.isNullOrEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val tags = string.split(',')
|
||||||
|
val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
|
||||||
|
Regex.escape(tag.trim())
|
||||||
|
}
|
||||||
|
return Regex(regex, RegexOption.IGNORE_CASE)
|
||||||
|
}
|
||||||
|
|
||||||
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
|
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
|
||||||
val list = MangaSource.values().toMutableList()
|
val list = MangaSource.values().toMutableList()
|
||||||
list.remove(MangaSource.LOCAL)
|
list.remove(MangaSource.LOCAL)
|
||||||
@@ -247,17 +259,16 @@ class AppSettings(context: Context) {
|
|||||||
const val KEY_PAGES_PRELOAD = "pages_preload"
|
const val KEY_PAGES_PRELOAD = "pages_preload"
|
||||||
const val KEY_SUGGESTIONS = "suggestions"
|
const val KEY_SUGGESTIONS = "suggestions"
|
||||||
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
||||||
|
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
|
||||||
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
|
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
|
||||||
|
|
||||||
// About
|
// About
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
|
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
|
||||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||||
const val KEY_APP_GRATITUDES = "about_gratitudes"
|
|
||||||
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
|
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
|
||||||
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
|
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
|
||||||
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
|
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
|
||||||
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
|
|
||||||
|
|
||||||
private const val NETWORK_NEVER = 0
|
private const val NETWORK_NEVER = 0
|
||||||
private const val NETWORK_ALWAYS = 1
|
private const val NETWORK_ALWAYS = 1
|
||||||
@@ -270,4 +281,4 @@ class AppSettings(context: Context) {
|
|||||||
private val isSamsung
|
private val isSamsung
|
||||||
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,9 +178,7 @@ class ChaptersFragment :
|
|||||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
val manga = viewModel.manga.value
|
|
||||||
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||||
mode.title = manga?.title
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,12 +188,7 @@ class ChaptersFragment :
|
|||||||
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
||||||
x.chapter.source == MangaSource.LOCAL
|
x.chapter.source == MangaSource.LOCAL
|
||||||
}
|
}
|
||||||
mode.subtitle = resources.getQuantityString(
|
mode.title = items.size.toString()
|
||||||
R.plurals.chapters_from_x,
|
|
||||||
items.size,
|
|
||||||
items.size,
|
|
||||||
chaptersAdapter?.itemCount ?: 0
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
@@ -17,6 +16,7 @@ import androidx.appcompat.view.ActionMode
|
|||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
@@ -163,7 +163,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
R.id.action_share -> {
|
R.id.action_share -> {
|
||||||
viewModel.manga.value?.let {
|
viewModel.manga.value?.let {
|
||||||
if (it.source == MangaSource.LOCAL) {
|
if (it.source == MangaSource.LOCAL) {
|
||||||
ShareHelper(this).shareCbz(Uri.parse(it.url).toFile())
|
ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
|
||||||
} else {
|
} else {
|
||||||
ShareHelper(this).shareMangaLink(it)
|
ShareHelper(this).shareMangaLink(it)
|
||||||
}
|
}
|
||||||
@@ -332,7 +332,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga): Intent {
|
fun newIntent(context: Context, manga: Manga): Intent {
|
||||||
return Intent(context, DetailsActivity::class.java)
|
return Intent(context, DetailsActivity::class.java)
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newIntent(context: Context, mangaId: Long): Intent {
|
fun newIntent(context: Context, mangaId: Long): Intent {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.os.Bundle
|
|||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import android.text.method.LinkMovementMethod
|
import android.text.method.LinkMovementMethod
|
||||||
import android.view.*
|
import android.view.*
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
@@ -224,14 +225,16 @@ class DetailsFragment :
|
|||||||
if (viewModel.readingHistory.value == null) {
|
if (viewModel.readingHistory.value == null) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
v.showPopupMenu(R.menu.popup_read) {
|
val menu = PopupMenu(v.context, v)
|
||||||
when (it.itemId) {
|
menu.inflate(R.menu.popup_read)
|
||||||
|
menu.setOnMenuItemClickListener { menuItem ->
|
||||||
|
when (menuItem.itemId) {
|
||||||
R.id.action_read -> {
|
R.id.action_read -> {
|
||||||
val branch = viewModel.selectedBranchValue
|
val branch = viewModel.selectedBranchValue
|
||||||
startActivity(
|
startActivity(
|
||||||
ReaderActivity.newIntent(
|
ReaderActivity.newIntent(
|
||||||
context = context ?: return@showPopupMenu false,
|
context = context ?: return@setOnMenuItemClickListener false,
|
||||||
manga = viewModel.manga.value ?: return@showPopupMenu false,
|
manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
|
||||||
state = viewModel.chapters.value?.firstOrNull { c ->
|
state = viewModel.chapters.value?.firstOrNull { c ->
|
||||||
c.chapter.branch == branch
|
c.chapter.branch == branch
|
||||||
}?.let { c ->
|
}?.let { c ->
|
||||||
@@ -244,6 +247,7 @@ class DetailsFragment :
|
|||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
menu.show()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
else -> return false
|
else -> return false
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.iterator
|
import org.koitharu.kotatsu.utils.ext.iterator
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class DetailsViewModel(
|
class DetailsViewModel(
|
||||||
@@ -191,7 +191,8 @@ class DetailsViewModel(
|
|||||||
// find default branch
|
// find default branch
|
||||||
val hist = historyRepository.getOne(manga)
|
val hist = historyRepository.getOne(manga)
|
||||||
selectedBranch.value = if (hist != null) {
|
selectedBranch.value = if (hist != null) {
|
||||||
manga.chapters?.find { it.id == hist.chapterId }?.branch
|
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
|
||||||
|
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
|
||||||
} else {
|
} else {
|
||||||
predictBranch(manga.chapters)
|
predictBranch(manga.chapters)
|
||||||
}
|
}
|
||||||
@@ -203,6 +204,8 @@ class DetailsViewModel(
|
|||||||
} else {
|
} else {
|
||||||
localMangaRepository.findSavedManga(manga)
|
localMangaRepository.findSavedManga(manga)
|
||||||
}
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
if (BuildConfig.DEBUG) error.printStackTrace()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.BaseAdapter
|
import android.widget.BaseAdapter
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.utils.ext.replaceWith
|
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||||
|
|
||||||
class BranchesAdapter : BaseAdapter() {
|
class BranchesAdapter : BaseAdapter() {
|
||||||
|
|
||||||
|
|||||||
@@ -2,69 +2,32 @@ package org.koitharu.kotatsu.details.ui.adapter
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Rect
|
import android.graphics.RectF
|
||||||
import androidx.core.content.ContextCompat
|
import android.view.View
|
||||||
import androidx.core.view.children
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||||
|
|
||||||
private val bounds = Rect()
|
|
||||||
private val selection = HashSet<Long>()
|
|
||||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
paint.color = ContextCompat.getColor(context, R.color.selector_foreground)
|
paint.color = context.getThemeColor(materialR.attr.colorSecondaryContainer, Color.LTGRAY)
|
||||||
paint.style = Paint.Style.FILL
|
paint.style = Paint.Style.FILL
|
||||||
}
|
}
|
||||||
|
|
||||||
val checkedItemsCount: Int
|
override fun onDrawBackground(
|
||||||
get() = selection.size
|
canvas: Canvas,
|
||||||
|
parent: RecyclerView,
|
||||||
val checkedItemsIds: Set<Long>
|
child: View,
|
||||||
get() = selection
|
bounds: RectF,
|
||||||
|
state: RecyclerView.State,
|
||||||
fun toggleItemChecked(id: Long) {
|
) {
|
||||||
if (!selection.remove(id)) {
|
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||||
selection.add(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setItemIsChecked(id: Long, isChecked: Boolean) {
|
|
||||||
if (isChecked) {
|
|
||||||
selection.add(id)
|
|
||||||
} else {
|
|
||||||
selection.remove(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkAll(ids: Collection<Long>) {
|
|
||||||
selection.addAll(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearSelection() {
|
|
||||||
selection.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
|
||||||
canvas.save()
|
|
||||||
if (parent.clipToPadding) {
|
|
||||||
canvas.clipRect(
|
|
||||||
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
|
|
||||||
parent.height - parent.paddingBottom
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (child in parent.children) {
|
|
||||||
val itemId = parent.getChildItemId(child)
|
|
||||||
if (itemId in selection) {
|
|
||||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
|
||||||
bounds.offset(child.translationX.toInt(), child.translationY.toInt())
|
|
||||||
canvas.drawRect(bounds, paint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvas.restore()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import android.webkit.MimeTypeMap
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.Scale
|
import coil.size.Scale
|
||||||
|
import java.io.File
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
@@ -21,12 +22,12 @@ import org.koitharu.kotatsu.local.data.MangaZip
|
|||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||||
import org.koitharu.kotatsu.utils.ext.referer
|
import org.koitharu.kotatsu.utils.ext.referer
|
||||||
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||||
private const val MAX_PARALLEL_DOWNLOADS = 2
|
private const val MAX_PARALLEL_DOWNLOADS = 2
|
||||||
@@ -55,22 +56,24 @@ class DownloadManager(
|
|||||||
|
|
||||||
fun downloadManga(
|
fun downloadManga(
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
chaptersIds: Set<Long>?,
|
chaptersIds: LongArray?,
|
||||||
startId: Int,
|
startId: Int,
|
||||||
): ProgressJob<DownloadState> {
|
): ProgressJob<DownloadState> {
|
||||||
val stateFlow = MutableStateFlow<DownloadState>(
|
val stateFlow = MutableStateFlow<DownloadState>(
|
||||||
DownloadState.Queued(startId = startId, manga = manga, cover = null)
|
DownloadState.Queued(startId = startId, manga = manga, cover = null)
|
||||||
)
|
)
|
||||||
val job = downloadMangaImpl(manga, chaptersIds, stateFlow, startId)
|
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
|
||||||
return ProgressJob(job, stateFlow)
|
return ProgressJob(job, stateFlow)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadMangaImpl(
|
private fun downloadMangaImpl(
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
chaptersIds: Set<Long>?,
|
chaptersIds: LongArray?,
|
||||||
outState: MutableStateFlow<DownloadState>,
|
outState: MutableStateFlow<DownloadState>,
|
||||||
startId: Int,
|
startId: Int,
|
||||||
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
||||||
|
@Suppress("NAME_SHADOWING") var manga = manga
|
||||||
|
val chaptersIdsSet = chaptersIds?.toMutableSet()
|
||||||
semaphore.acquire()
|
semaphore.acquire()
|
||||||
coroutineContext[WakeLockNode]?.acquire()
|
coroutineContext[WakeLockNode]?.acquire()
|
||||||
outState.value = DownloadState.Preparing(startId, manga, null)
|
outState.value = DownloadState.Preparing(startId, manga, null)
|
||||||
@@ -79,6 +82,9 @@ class DownloadManager(
|
|||||||
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
||||||
var output: MangaZip? = null
|
var output: MangaZip? = null
|
||||||
try {
|
try {
|
||||||
|
if (manga.source == MangaSource.LOCAL) {
|
||||||
|
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
|
||||||
|
}
|
||||||
val repo = MangaRepository(manga.source)
|
val repo = MangaRepository(manga.source)
|
||||||
cover = runCatching {
|
cover = runCatching {
|
||||||
imageLoader.execute(
|
imageLoader.execute(
|
||||||
@@ -91,48 +97,51 @@ class DownloadManager(
|
|||||||
).drawable
|
).drawable
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
outState.value = DownloadState.Preparing(startId, manga, cover)
|
outState.value = DownloadState.Preparing(startId, manga, cover)
|
||||||
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||||
output = MangaZip.findInDir(destination, data)
|
output = MangaZip.findInDir(destination, data)
|
||||||
output.prepare(data)
|
output.prepare(data)
|
||||||
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
||||||
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
||||||
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||||
}
|
}
|
||||||
val chapters = if (chaptersIds == null) {
|
val chapters = checkNotNull(
|
||||||
data.chapters.orEmpty()
|
if (chaptersIdsSet == null) {
|
||||||
} else {
|
data.chapters
|
||||||
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
|
} else {
|
||||||
|
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
|
||||||
|
}
|
||||||
|
) { "Chapters list must not be null" }
|
||||||
|
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
|
||||||
|
check(chaptersIdsSet.isNullOrEmpty()) {
|
||||||
|
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
|
||||||
}
|
}
|
||||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||||
if (chaptersIds == null || chapter.id in chaptersIds) {
|
val pages = repo.getPages(chapter)
|
||||||
val pages = repo.getPages(chapter)
|
for ((pageIndex, page) in pages.withIndex()) {
|
||||||
for ((pageIndex, page) in pages.withIndex()) {
|
failsafe@ do {
|
||||||
failsafe@ do {
|
try {
|
||||||
try {
|
val url = repo.getPageUrl(page)
|
||||||
val url = repo.getPageUrl(page)
|
val file = cache[url] ?: downloadFile(url, page.referer, destination)
|
||||||
val file =
|
output.addPage(
|
||||||
cache[url] ?: downloadFile(url, page.referer, destination)
|
chapter = chapter,
|
||||||
output.addPage(
|
file = file,
|
||||||
chapter,
|
pageNumber = pageIndex,
|
||||||
file,
|
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||||
pageIndex,
|
)
|
||||||
MimeTypeMap.getFileExtensionFromUrl(url)
|
} catch (e: IOException) {
|
||||||
)
|
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
||||||
} catch (e: IOException) {
|
connectivityManager.waitForNetwork()
|
||||||
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
continue@failsafe
|
||||||
connectivityManager.waitForNetwork()
|
}
|
||||||
continue@failsafe
|
} while (false)
|
||||||
}
|
|
||||||
} while (false)
|
|
||||||
|
|
||||||
outState.value = DownloadState.Progress(
|
outState.value = DownloadState.Progress(
|
||||||
startId, data, cover,
|
startId, data, cover,
|
||||||
totalChapters = chapters.size,
|
totalChapters = chapters.size,
|
||||||
currentChapter = chapterIndex,
|
currentChapter = chapterIndex,
|
||||||
totalPages = pages.size,
|
totalPages = pages.size,
|
||||||
currentPage = pageIndex,
|
currentPage = pageIndex,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
||||||
@@ -141,8 +150,9 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
val localManga = localMangaRepository.getFromFile(output.file)
|
val localManga = localMangaRepository.getFromFile(output.file)
|
||||||
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
||||||
} catch (_: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
outState.value = DownloadState.Cancelled(startId, manga, cover)
|
outState.value = DownloadState.Cancelled(startId, manga, cover)
|
||||||
|
throw e
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -188,13 +198,14 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable ->
|
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
|
||||||
val prevValue = outState.value
|
CoroutineExceptionHandler { _, throwable ->
|
||||||
outState.value = DownloadState.Error(
|
val prevValue = outState.value
|
||||||
startId = prevValue.startId,
|
outState.value = DownloadState.Error(
|
||||||
manga = prevValue.manga,
|
startId = prevValue.startId,
|
||||||
cover = prevValue.cover,
|
manga = prevValue.manga,
|
||||||
error = throwable,
|
cover = prevValue.cover,
|
||||||
)
|
error = throwable,
|
||||||
}
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
|
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||||
|
import org.koitharu.kotatsu.parsers.util.format
|
||||||
import org.koitharu.kotatsu.utils.ext.*
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
|
|||||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||||
import org.koitharu.kotatsu.download.ui.DownloadsActivity
|
import org.koitharu.kotatsu.download.ui.DownloadsActivity
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.format
|
||||||
import org.koitharu.kotatsu.utils.PendingIntentCompat
|
import org.koitharu.kotatsu.utils.PendingIntentCompat
|
||||||
import org.koitharu.kotatsu.utils.ext.format
|
|
||||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.os.PowerManager
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
@@ -23,7 +24,6 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.base.ui.BaseService
|
import org.koitharu.kotatsu.base.ui.BaseService
|
||||||
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.model.withoutChapters
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||||
@@ -31,10 +31,8 @@ import org.koitharu.kotatsu.download.domain.WakeLockNode
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.utils.ext.throttle
|
import org.koitharu.kotatsu.utils.ext.throttle
|
||||||
import org.koitharu.kotatsu.utils.ext.toArraySet
|
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.collections.set
|
|
||||||
|
|
||||||
class DownloadService : BaseService() {
|
class DownloadService : BaseService() {
|
||||||
|
|
||||||
@@ -66,11 +64,10 @@ class DownloadService : BaseService() {
|
|||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
super.onStartCommand(intent, flags, startId)
|
super.onStartCommand(intent, flags, startId)
|
||||||
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
|
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
|
||||||
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
|
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
|
||||||
return if (manga != null) {
|
return if (manga != null) {
|
||||||
jobs[startId] = downloadManga(startId, manga, chapters)
|
jobs[startId] = downloadManga(startId, manga, chapters)
|
||||||
jobCount.value = jobs.size
|
jobCount.value = jobs.size
|
||||||
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
|
|
||||||
START_REDELIVER_INTENT
|
START_REDELIVER_INTENT
|
||||||
} else {
|
} else {
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
@@ -97,7 +94,7 @@ class DownloadService : BaseService() {
|
|||||||
private fun downloadManga(
|
private fun downloadManga(
|
||||||
startId: Int,
|
startId: Int,
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
chaptersIds: Set<Long>?,
|
chaptersIds: LongArray?,
|
||||||
): ProgressJob<DownloadState> {
|
): ProgressJob<DownloadState> {
|
||||||
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
|
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||||
listenJob(job)
|
listenJob(job)
|
||||||
@@ -119,7 +116,7 @@ class DownloadService : BaseService() {
|
|||||||
(job.progressValue as? DownloadState.Done)?.let {
|
(job.progressValue as? DownloadState.Done)?.let {
|
||||||
sendBroadcast(
|
sendBroadcast(
|
||||||
Intent(ACTION_DOWNLOAD_COMPLETE)
|
Intent(ACTION_DOWNLOAD_COMPLETE)
|
||||||
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters()))
|
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
notificationSwitcher.detach(
|
notificationSwitcher.detach(
|
||||||
@@ -179,14 +176,38 @@ class DownloadService : BaseService() {
|
|||||||
}
|
}
|
||||||
confirmDataTransfer(context) {
|
confirmDataTransfer(context) {
|
||||||
val intent = Intent(context, DownloadService::class.java)
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
|
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
|
||||||
if (chaptersIds != null) {
|
if (chaptersIds != null) {
|
||||||
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||||
}
|
}
|
||||||
ContextCompat.startForegroundService(context, intent)
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
Toast.makeText(context, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun start(context: Context, manga: Collection<Manga>) {
|
||||||
|
if (manga.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirmDataTransfer(context) {
|
||||||
|
for (item in manga) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
|
intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmAndStart(context: Context, items: Set<Manga>) {
|
||||||
|
MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(R.string.save_manga)
|
||||||
|
.setMessage(R.string.batch_manga_save_confirm)
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.save) { _, _ ->
|
||||||
|
start(context, items)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
|
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
|
||||||
.putExtra(EXTRA_CANCEL_ID, startId)
|
.putExtra(EXTRA_CANCEL_ID, startId)
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ class ForegroundNotificationSwitcher(
|
|||||||
@Synchronized
|
@Synchronized
|
||||||
fun notify(startId: Int, notification: Notification) {
|
fun notify(startId: Int, notification: Notification) {
|
||||||
if (notifications.isEmpty()) {
|
if (notifications.isEmpty()) {
|
||||||
handler.postDelayed(StartForegroundRunnable(startId, notification), DEFAULT_DELAY)
|
service.startForeground(startId, notification)
|
||||||
|
} else {
|
||||||
|
notificationManager.notify(startId, notification)
|
||||||
}
|
}
|
||||||
notificationManager.notify(startId, notification)
|
|
||||||
notifications[startId] = notification
|
notifications[startId] = notification
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,16 +46,6 @@ class ForegroundNotificationSwitcher(
|
|||||||
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
|
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class StartForegroundRunnable(
|
|
||||||
private val startId: Int,
|
|
||||||
private val notification: Notification,
|
|
||||||
) : Runnable {
|
|
||||||
|
|
||||||
override fun run() {
|
|
||||||
service.startForeground(startId, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class NotifyRunnable(
|
private inner class NotifyRunnable(
|
||||||
private val startId: Int,
|
private val startId: Int,
|
||||||
private val notification: Notification?,
|
private val notification: Notification?,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.favourites.data
|
||||||
|
|
||||||
|
import java.util.*
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.SortOrder
|
||||||
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
|
||||||
|
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
sortKey = sortKey,
|
||||||
|
order = SortOrder(order, SortOrder.NEWEST),
|
||||||
|
createdAt = Date(createdAt),
|
||||||
|
)
|
||||||
@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.favourites.data
|
|||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Entity(tableName = "favourite_categories")
|
@Entity(tableName = "favourite_categories")
|
||||||
class FavouriteCategoryEntity(
|
class FavouriteCategoryEntity(
|
||||||
@@ -15,13 +12,4 @@ class FavouriteCategoryEntity(
|
|||||||
@ColumnInfo(name = "sort_key") val sortKey: Int,
|
@ColumnInfo(name = "sort_key") val sortKey: Int,
|
||||||
@ColumnInfo(name = "title") val title: String,
|
@ColumnInfo(name = "title") val title: String,
|
||||||
@ColumnInfo(name = "order") val order: String,
|
@ColumnInfo(name = "order") val order: String,
|
||||||
) {
|
)
|
||||||
|
|
||||||
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
|
|
||||||
id = id ?: categoryId.toLong(),
|
|
||||||
title = title,
|
|
||||||
sortKey = sortKey,
|
|
||||||
order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST,
|
|
||||||
createdAt = Date(createdAt),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,36 +6,35 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.*
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
|
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
|
||||||
|
|
||||||
class FavouritesRepository(private val db: MangaDatabase) {
|
class FavouritesRepository(private val db: MangaDatabase) {
|
||||||
|
|
||||||
suspend fun getAllManga(): List<Manga> {
|
suspend fun getAllManga(): List<Manga> {
|
||||||
val entities = db.favouritesDao.findAll()
|
val entities = db.favouritesDao.findAll()
|
||||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAll(order: SortOrder): Flow<List<Manga>> {
|
fun observeAll(order: SortOrder): Flow<List<Manga>> {
|
||||||
return db.favouritesDao.observeAll(order)
|
return db.favouritesDao.observeAll(order)
|
||||||
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getManga(categoryId: Long): List<Manga> {
|
suspend fun getManga(categoryId: Long): List<Manga> {
|
||||||
val entities = db.favouritesDao.findAll(categoryId)
|
val entities = db.favouritesDao.findAll(categoryId)
|
||||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
|
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
|
||||||
return db.favouritesDao.observeAll(categoryId, order)
|
return db.favouritesDao.observeAll(categoryId, order)
|
||||||
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
||||||
@@ -43,21 +42,6 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
|||||||
.flatMapLatest { order -> observeAll(categoryId, order) }
|
.flatMapLatest { order -> observeAll(categoryId, order) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
|
|
||||||
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
|
|
||||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAllCategories(): List<FavouriteCategory> {
|
|
||||||
val entities = db.favouriteCategoriesDao.findAll()
|
|
||||||
return entities.map { it.toFavouriteCategory() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getCategories(mangaId: Long): List<FavouriteCategory> {
|
|
||||||
val entities = db.favouritesDao.find(mangaId)?.categories
|
|
||||||
return entities?.map { it.toFavouriteCategory() }.orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeCategories(): Flow<List<FavouriteCategory>> {
|
fun observeCategories(): Flow<List<FavouriteCategory>> {
|
||||||
return db.favouriteCategoriesDao.observeAll().mapItems {
|
return db.favouriteCategoriesDao.observeAll().mapItems {
|
||||||
it.toFavouriteCategory()
|
it.toFavouriteCategory()
|
||||||
@@ -70,8 +54,8 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
|||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeCategoriesIds(mangaId: Long): Flow<List<Long>> {
|
fun observeCategoriesIds(mangaId: Long): Flow<Set<Long>> {
|
||||||
return db.favouritesDao.observeIds(mangaId)
|
return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addCategory(title: String): FavouriteCategory {
|
suspend fun addCategory(title: String): FavouriteCategory {
|
||||||
@@ -107,27 +91,37 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addToCategory(manga: Manga, categoryId: Long) {
|
suspend fun addToCategory(categoryId: Long, mangas: Collection<Manga>) {
|
||||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.tagsDao.upsert(tags)
|
for (manga in mangas) {
|
||||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
val tags = manga.tags.toEntities()
|
||||||
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
|
db.tagsDao.upsert(tags)
|
||||||
db.favouritesDao.insert(entity)
|
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||||
|
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
|
||||||
|
db.favouritesDao.insert(entity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeFromCategory(manga: Manga, categoryId: Long) {
|
suspend fun removeFromFavourites(ids: Collection<Long>) {
|
||||||
db.favouritesDao.delete(categoryId, manga.id)
|
db.withTransaction {
|
||||||
|
for (id in ids) {
|
||||||
|
db.favouritesDao.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeFromFavourites(manga: Manga) {
|
suspend fun removeFromCategory(categoryId: Long, ids: Collection<Long>) {
|
||||||
db.favouritesDao.delete(manga.id)
|
db.withTransaction {
|
||||||
|
for (id in ids) {
|
||||||
|
db.favouritesDao.delete(categoryId, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeOrder(categoryId: Long): Flow<SortOrder> {
|
private fun observeOrder(categoryId: Long): Flow<SortOrder> {
|
||||||
return db.favouriteCategoriesDao.observe(categoryId)
|
return db.favouriteCategoriesDao.observe(categoryId)
|
||||||
.map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST }
|
.map { x -> SortOrder(x.order, SortOrder.NEWEST) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,19 @@ package org.koitharu.kotatsu.favourites.ui
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.*
|
import android.view.*
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.children
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.ActionModeListener
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
import org.koitharu.kotatsu.core.ui.titleRes
|
import org.koitharu.kotatsu.core.ui.titleRes
|
||||||
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
|
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
|
||||||
@@ -20,11 +25,13 @@ import org.koitharu.kotatsu.main.ui.AppBarOwner
|
|||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||||
import org.koitharu.kotatsu.utils.ext.showPopupMenu
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
class FavouritesContainerFragment :
|
||||||
FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback {
|
BaseFragment<FragmentFavouritesBinding>(),
|
||||||
|
FavouritesTabLongClickListener,
|
||||||
|
CategoriesEditDelegate.CategoriesEditCallback,
|
||||||
|
ActionModeListener {
|
||||||
|
|
||||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||||
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
|
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
@@ -51,6 +58,7 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
|||||||
binding.pager.adapter = adapter
|
binding.pager.adapter = adapter
|
||||||
pagerAdapter = adapter
|
pagerAdapter = adapter
|
||||||
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
|
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
|
||||||
|
actionModeDelegate.addListener(this, viewLifecycleOwner)
|
||||||
|
|
||||||
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged)
|
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged)
|
||||||
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
||||||
@@ -61,6 +69,16 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
|||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onActionModeStarted(mode: ActionMode) {
|
||||||
|
binding.pager.isUserInputEnabled = false
|
||||||
|
binding.tabs.setTabsEnabled(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionModeFinished(mode: ActionMode) {
|
||||||
|
binding.pager.isUserInputEnabled = true
|
||||||
|
binding.tabs.setTabsEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
||||||
binding.root.updatePadding(
|
binding.root.updatePadding(
|
||||||
@@ -105,22 +123,24 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
|||||||
|
|
||||||
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
|
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
|
||||||
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
|
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
|
||||||
tabView.showPopupMenu(menuRes, { menu ->
|
val menu = PopupMenu(tabView.context, tabView)
|
||||||
createOrderSubmenu(menu, category)
|
menu.inflate(menuRes)
|
||||||
}) {
|
createOrderSubmenu(menu.menu, category)
|
||||||
|
menu.setOnMenuItemClickListener {
|
||||||
when (it.itemId) {
|
when (it.itemId) {
|
||||||
R.id.action_remove -> editDelegate.deleteCategory(category)
|
R.id.action_remove -> editDelegate.deleteCategory(category)
|
||||||
R.id.action_rename -> editDelegate.renameCategory(category)
|
R.id.action_rename -> editDelegate.renameCategory(category)
|
||||||
R.id.action_create -> editDelegate.createCategory()
|
R.id.action_create -> editDelegate.createCategory()
|
||||||
R.id.action_order -> return@showPopupMenu false
|
R.id.action_order -> return@setOnMenuItemClickListener false
|
||||||
else -> {
|
else -> {
|
||||||
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
|
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
|
||||||
?: return@showPopupMenu false
|
?: return@setOnMenuItemClickListener false
|
||||||
viewModel.setCategoryOrder(category.id, order)
|
viewModel.setCategoryOrder(category.id, order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
menu.show()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,18 +166,20 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
|||||||
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
|
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
|
||||||
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
|
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
|
||||||
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
|
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
|
||||||
val menuItem = submenu.add(
|
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
|
||||||
R.id.group_order,
|
|
||||||
Menu.NONE,
|
|
||||||
i,
|
|
||||||
item.titleRes
|
|
||||||
)
|
|
||||||
menuItem.isCheckable = true
|
menuItem.isCheckable = true
|
||||||
menuItem.isChecked = item == category.order
|
menuItem.isChecked = item == category.order
|
||||||
}
|
}
|
||||||
submenu.setGroupCheckable(R.id.group_order, true, true)
|
submenu.setGroupCheckable(R.id.group_order, true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun TabLayout.setTabsEnabled(enabled: Boolean) {
|
||||||
|
val tabStrip = getChildAt(0) as? ViewGroup ?: return
|
||||||
|
for (tab in tabStrip.children) {
|
||||||
|
tab.isEnabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun newInstance() = FavouritesContainerFragment()
|
fun newInstance() = FavouritesContainerFragment()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.os.Bundle
|
|||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
@@ -23,11 +24,12 @@ import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
|
|||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||||
import org.koitharu.kotatsu.utils.ext.showPopupMenu
|
|
||||||
|
|
||||||
class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
class CategoriesActivity :
|
||||||
|
BaseActivity<ActivityCategoriesBinding>(),
|
||||||
OnListItemClickListener<FavouriteCategory>,
|
OnListItemClickListener<FavouriteCategory>,
|
||||||
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
|
View.OnClickListener,
|
||||||
|
CategoriesEditDelegate.CategoriesEditCallback {
|
||||||
|
|
||||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||||
|
|
||||||
@@ -58,26 +60,27 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: FavouriteCategory, view: View) {
|
override fun onItemClick(item: FavouriteCategory, view: View) {
|
||||||
view.showPopupMenu(R.menu.popup_category, { menu ->
|
val menu = PopupMenu(view.context, view)
|
||||||
createOrderSubmenu(menu, item)
|
menu.inflate(R.menu.popup_category)
|
||||||
}) {
|
createOrderSubmenu(menu.menu, item)
|
||||||
when (it.itemId) {
|
menu.setOnMenuItemClickListener { menuItem ->
|
||||||
|
when (menuItem.itemId) {
|
||||||
R.id.action_remove -> editDelegate.deleteCategory(item)
|
R.id.action_remove -> editDelegate.deleteCategory(item)
|
||||||
R.id.action_rename -> editDelegate.renameCategory(item)
|
R.id.action_rename -> editDelegate.renameCategory(item)
|
||||||
R.id.action_order -> return@showPopupMenu false
|
R.id.action_order -> return@setOnMenuItemClickListener false
|
||||||
else -> {
|
else -> {
|
||||||
val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false
|
val order = SORT_ORDERS.getOrNull(menuItem.order) ?: return@setOnMenuItemClickListener false
|
||||||
viewModel.setCategoryOrder(item.id, order)
|
viewModel.setCategoryOrder(item.id, order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
menu.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
|
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
|
||||||
reorderHelper.startDrag(
|
val viewHolder = binding.recyclerView.findContainingViewHolder(view) ?: return false
|
||||||
binding.recyclerView.findContainingViewHolder(view) ?: return false
|
reorderHelper.startDrag(viewHolder)
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
|||||||
binding.recyclerView.updatePadding(
|
binding.recyclerView.updatePadding(
|
||||||
left = insets.left,
|
left = insets.left,
|
||||||
right = insets.right,
|
right = insets.right,
|
||||||
bottom = 2 * insets.bottom + binding.fabAdd.measureHeight()
|
bottom = 2 * insets.bottom + binding.fabAdd.measureHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.domain.MangaIntent
|
|
||||||
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
|
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
|
||||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
@@ -26,10 +27,10 @@ class FavouriteCategoriesDialog :
|
|||||||
BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
|
BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
|
||||||
OnListItemClickListener<MangaCategoryItem>,
|
OnListItemClickListener<MangaCategoryItem>,
|
||||||
CategoriesEditDelegate.CategoriesEditCallback,
|
CategoriesEditDelegate.CategoriesEditCallback,
|
||||||
View.OnClickListener {
|
Toolbar.OnMenuItemClickListener {
|
||||||
|
|
||||||
private val viewModel by viewModel<MangaCategoriesViewModel> {
|
private val viewModel by viewModel<MangaCategoriesViewModel> {
|
||||||
parametersOf(requireNotNull(arguments?.getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga)
|
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
|
||||||
}
|
}
|
||||||
|
|
||||||
private var adapter: MangaCategoriesAdapter? = null
|
private var adapter: MangaCategoriesAdapter? = null
|
||||||
@@ -46,7 +47,7 @@ class FavouriteCategoriesDialog :
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
adapter = MangaCategoriesAdapter(this)
|
adapter = MangaCategoriesAdapter(this)
|
||||||
binding.recyclerViewCategories.adapter = adapter
|
binding.recyclerViewCategories.adapter = adapter
|
||||||
binding.textViewAdd.setOnClickListener(this)
|
binding.toolbar.setOnMenuItemClickListener(this)
|
||||||
|
|
||||||
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
|
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
|
||||||
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
||||||
@@ -57,9 +58,13 @@ class FavouriteCategoriesDialog :
|
|||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(v: View) {
|
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||||
when (v.id) {
|
return when (item.itemId) {
|
||||||
R.id.textView_add -> editDelegate.createCategory()
|
R.id.action_create -> {
|
||||||
|
editDelegate.createCategory()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +91,15 @@ class FavouriteCategoriesDialog :
|
|||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val TAG = "FavouriteCategoriesDialog"
|
private const val TAG = "FavouriteCategoriesDialog"
|
||||||
|
private const val KEY_MANGA_LIST = "manga_list"
|
||||||
|
|
||||||
fun show(fm: FragmentManager, manga: Manga) = FavouriteCategoriesDialog()
|
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
|
||||||
.withArgs(1) {
|
|
||||||
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) {
|
||||||
}.show(fm, TAG)
|
putParcelableArrayList(
|
||||||
|
KEY_MANGA_LIST,
|
||||||
|
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) }
|
||||||
|
)
|
||||||
|
}.show(fm, TAG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,19 +4,20 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.model.ids
|
||||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||||
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
|
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
|
|
||||||
class MangaCategoriesViewModel(
|
class MangaCategoriesViewModel(
|
||||||
private val manga: Manga,
|
private val manga: List<Manga>,
|
||||||
private val favouritesRepository: FavouritesRepository
|
private val favouritesRepository: FavouritesRepository
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val content = combine(
|
val content = combine(
|
||||||
favouritesRepository.observeCategories(),
|
favouritesRepository.observeCategories(),
|
||||||
favouritesRepository.observeCategoriesIds(manga.id)
|
observeCategoriesIds(),
|
||||||
) { all, checked ->
|
) { all, checked ->
|
||||||
all.map {
|
all.map {
|
||||||
MangaCategoryItem(
|
MangaCategoryItem(
|
||||||
@@ -30,9 +31,9 @@ class MangaCategoriesViewModel(
|
|||||||
fun setChecked(categoryId: Long, isChecked: Boolean) {
|
fun setChecked(categoryId: Long, isChecked: Boolean) {
|
||||||
launchJob(Dispatchers.Default) {
|
launchJob(Dispatchers.Default) {
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
favouritesRepository.addToCategory(manga, categoryId)
|
favouritesRepository.addToCategory(categoryId, manga)
|
||||||
} else {
|
} else {
|
||||||
favouritesRepository.removeFromCategory(manga, categoryId)
|
favouritesRepository.removeFromCategory(categoryId, manga.ids())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,4 +43,25 @@ class MangaCategoriesViewModel(
|
|||||||
favouritesRepository.addCategory(name)
|
favouritesRepository.addCategory(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeCategoriesIds() = if (manga.size == 1) {
|
||||||
|
// Fast path
|
||||||
|
favouritesRepository.observeCategoriesIds(manga[0].id)
|
||||||
|
} else {
|
||||||
|
combine(
|
||||||
|
manga.map { favouritesRepository.observeCategoriesIds(it.id) }
|
||||||
|
) { array ->
|
||||||
|
val result = HashSet<Long>()
|
||||||
|
var isFirst = true
|
||||||
|
for (ids in array) {
|
||||||
|
if (isFirst) {
|
||||||
|
result.addAll(ids)
|
||||||
|
isFirst = false
|
||||||
|
} else {
|
||||||
|
result.retainAll(ids.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.favourites.ui.list
|
package org.koitharu.kotatsu.favourites.ui.list
|
||||||
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||||
|
|
||||||
class FavouritesListFragment : MangaListFragment() {
|
class FavouritesListFragment : MangaListFragment() {
|
||||||
@@ -23,17 +23,27 @@ class FavouritesListFragment : MangaListFragment() {
|
|||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
override fun onScrolledToEnd() = Unit
|
||||||
|
|
||||||
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
super.onCreatePopupMenu(inflater, menu, data)
|
mode.menuInflater.inflate(R.menu.mode_favourites, menu)
|
||||||
inflater.inflate(R.menu.popup_favourites, menu)
|
return super.onCreateActionMode(mode, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = when (item.itemId) {
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
R.id.action_remove -> {
|
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
|
||||||
viewModel.removeFromFavourites(data)
|
it.source == MangaSource.LOCAL
|
||||||
true
|
}
|
||||||
|
return super.onPrepareActionMode(mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
R.id.action_remove -> {
|
||||||
|
viewModel.removeFromFavourites(selectedItemsIds)
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onActionItemClicked(mode, item)
|
||||||
}
|
}
|
||||||
else -> super.onPopupMenuItemSelected(item, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState
|
|||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
@@ -56,12 +55,15 @@ class FavouritesListViewModel(
|
|||||||
|
|
||||||
override fun onRetry() = Unit
|
override fun onRetry() = Unit
|
||||||
|
|
||||||
fun removeFromFavourites(manga: Manga) {
|
fun removeFromFavourites(ids: Set<Long>) {
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
launchJob {
|
launchJob {
|
||||||
if (categoryId == 0L) {
|
if (categoryId == 0L) {
|
||||||
repository.removeFromFavourites(manga)
|
repository.removeFromFavourites(ids)
|
||||||
} else {
|
} else {
|
||||||
repository.removeFromCategory(manga, categoryId)
|
repository.removeFromCategory(categoryId, ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.history.data
|
||||||
|
|
||||||
|
import java.util.*
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||||
|
|
||||||
|
fun HistoryEntity.toMangaHistory() = MangaHistory(
|
||||||
|
createdAt = Date(createdAt),
|
||||||
|
updatedAt = Date(updatedAt),
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll.toInt()
|
||||||
|
)
|
||||||
@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class HistoryDao {
|
abstract class HistoryDao {
|
||||||
|
|
||||||
@@ -23,8 +22,15 @@ abstract class HistoryDao {
|
|||||||
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
|
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
|
||||||
abstract suspend fun findAllManga(): List<MangaEntity>
|
abstract suspend fun findAllManga(): List<MangaEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM tags WHERE tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_id IN (SELECT manga_id FROM history))")
|
@Query(
|
||||||
abstract suspend fun findAllTags(): List<TagEntity>
|
"""SELECT tags.* FROM tags
|
||||||
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
INNER JOIN history ON history.manga_id = manga_tags.manga_id
|
||||||
|
GROUP BY manga_tags.tag_id
|
||||||
|
ORDER BY COUNT(manga_tags.manga_id) DESC
|
||||||
|
LIMIT :limit"""
|
||||||
|
)
|
||||||
|
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM history WHERE manga_id = :id")
|
@Query("SELECT * FROM history WHERE manga_id = :id")
|
||||||
abstract suspend fun find(id: Long): HistoryEntity?
|
abstract suspend fun find(id: Long): HistoryEntity?
|
||||||
@@ -32,6 +38,9 @@ abstract class HistoryDao {
|
|||||||
@Query("SELECT * FROM history WHERE manga_id = :id")
|
@Query("SELECT * FROM history WHERE manga_id = :id")
|
||||||
abstract fun observe(id: Long): Flow<HistoryEntity?>
|
abstract fun observe(id: Long): Flow<HistoryEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM history")
|
||||||
|
abstract fun observeCount(): Flow<Int>
|
||||||
|
|
||||||
@Query("DELETE FROM history")
|
@Query("DELETE FROM history")
|
||||||
abstract suspend fun clear()
|
abstract suspend fun clear()
|
||||||
|
|
||||||
@@ -60,5 +69,4 @@ abstract class HistoryDao {
|
|||||||
true
|
true
|
||||||
} else false
|
} else false
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,10 @@ import androidx.room.Entity
|
|||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "history", foreignKeys = [
|
tableName = "history",
|
||||||
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
@@ -26,13 +25,4 @@ class HistoryEntity(
|
|||||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||||
@ColumnInfo(name = "page") val page: Int,
|
@ColumnInfo(name = "page") val page: Int,
|
||||||
@ColumnInfo(name = "scroll") val scroll: Float,
|
@ColumnInfo(name = "scroll") val scroll: Float,
|
||||||
) {
|
)
|
||||||
|
|
||||||
fun toMangaHistory() = MangaHistory(
|
|
||||||
createdAt = Date(createdAt),
|
|
||||||
updatedAt = Date(updatedAt),
|
|
||||||
chapterId = chapterId,
|
|
||||||
page = page,
|
|
||||||
scroll = scroll.toInt()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -19,5 +19,5 @@ class HistoryWithManga(
|
|||||||
entityColumn = "tag_id",
|
entityColumn = "tag_id",
|
||||||
associateBy = Junction(MangaTagsEntity::class)
|
associateBy = Junction(MangaTagsEntity::class)
|
||||||
)
|
)
|
||||||
val tags: List<TagEntity>
|
val tags: List<TagEntity>,
|
||||||
)
|
)
|
||||||
@@ -2,18 +2,18 @@ package org.koitharu.kotatsu.history.domain
|
|||||||
|
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.*
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
|
||||||
|
|
||||||
class HistoryRepository(
|
class HistoryRepository(
|
||||||
private val db: MangaDatabase,
|
private val db: MangaDatabase,
|
||||||
@@ -23,20 +23,25 @@ class HistoryRepository(
|
|||||||
|
|
||||||
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
|
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
|
||||||
val entities = db.historyDao.findAll(offset, limit)
|
val entities = db.historyDao.findAll(offset, limit)
|
||||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLastOrNull(): Manga? {
|
||||||
|
val entity = db.historyDao.findAll(0, 1).firstOrNull() ?: return null
|
||||||
|
return entity.manga.toManga(entity.tags.toMangaTags())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAll(): Flow<List<Manga>> {
|
fun observeAll(): Flow<List<Manga>> {
|
||||||
return db.historyDao.observeAll().mapItems {
|
return db.historyDao.observeAll().mapItems {
|
||||||
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag))
|
it.manga.toManga(it.tags.toMangaTags())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAllWithHistory(): Flow<List<MangaWithHistory>> {
|
fun observeAllWithHistory(): Flow<List<MangaWithHistory>> {
|
||||||
return db.historyDao.observeAll().mapItems {
|
return db.historyDao.observeAll().mapItems {
|
||||||
MangaWithHistory(
|
MangaWithHistory(
|
||||||
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)),
|
it.manga.toManga(it.tags.toMangaTags()),
|
||||||
it.history.toMangaHistory()
|
it.history.toMangaHistory(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,14 +52,20 @@ class HistoryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun observeHasItems(): Flow<Boolean> {
|
||||||
|
return db.historyDao.observeCount()
|
||||||
|
.map { it > 0 }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
|
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
|
||||||
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
|
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
val tags = manga.tags.toEntities()
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.tagsDao.upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||||
db.historyDao.upsert(
|
db.historyDao.upsert(
|
||||||
HistoryEntity(
|
HistoryEntity(
|
||||||
mangaId = manga.id,
|
mangaId = manga.id,
|
||||||
@@ -62,7 +73,7 @@ class HistoryRepository(
|
|||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
chapterId = chapterId,
|
chapterId = chapterId,
|
||||||
page = page,
|
page = page,
|
||||||
scroll = scroll.toFloat() // we migrate to int, but decide to not update database
|
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
trackingRepository.upsert(manga)
|
trackingRepository.upsert(manga)
|
||||||
@@ -81,17 +92,25 @@ class HistoryRepository(
|
|||||||
db.historyDao.delete(manga.id)
|
db.historyDao.delete(manga.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun delete(ids: Collection<Long>) {
|
||||||
|
db.withTransaction {
|
||||||
|
for (id in ids) {
|
||||||
|
db.historyDao.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to replace one manga with another one
|
* Try to replace one manga with another one
|
||||||
* Useful for replacing saved manga on deleting it with remove source
|
* Useful for replacing saved manga on deleting it with remove source
|
||||||
*/
|
*/
|
||||||
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
|
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
|
||||||
if (alternative == null || db.mangaDao.update(MangaEntity.from(alternative)) <= 0) {
|
if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) {
|
||||||
db.historyDao.delete(manga.id)
|
db.historyDao.delete(manga.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getAllTags(): Set<MangaTag> {
|
suspend fun getPopularTags(limit: Int): List<MangaTag> {
|
||||||
return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() }
|
return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,13 +5,12 @@ import android.view.Menu
|
|||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
|
||||||
|
|
||||||
class HistoryListFragment : MangaListFragment() {
|
class HistoryListFragment : MangaListFragment() {
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ class HistoryListFragment : MangaListFragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
viewModel.onItemRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
|
|
||||||
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
|
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
|
||||||
activity?.invalidateOptionsMenu()
|
activity?.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
@@ -59,30 +57,29 @@ class HistoryListFragment : MangaListFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
super.onCreatePopupMenu(inflater, menu, data)
|
mode.menuInflater.inflate(R.menu.mode_history, menu)
|
||||||
inflater.inflate(R.menu.popup_history, menu)
|
return super.onCreateActionMode(mode, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean {
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
|
||||||
|
it.source == MangaSource.LOCAL
|
||||||
|
}
|
||||||
|
return super.onPrepareActionMode(mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_remove -> {
|
R.id.action_remove -> {
|
||||||
viewModel.removeFromHistory(data)
|
viewModel.removeFromHistory(selectedItemsIds)
|
||||||
|
mode.finish()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onPopupMenuItemSelected(item, data)
|
else -> super.onActionItemClicked(mode, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onItemRemoved(item: Manga) {
|
|
||||||
Snackbar.make(
|
|
||||||
binding.recyclerView, getString(
|
|
||||||
R.string._s_removed_from_history,
|
|
||||||
item.title.ellipsize(16)
|
|
||||||
), Snackbar.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun newInstance() = HistoryListFragment()
|
fun newInstance() = HistoryListFragment()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.history.ui
|
|||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -13,14 +15,10 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
|
|||||||
import org.koitharu.kotatsu.history.domain.MangaWithHistory
|
import org.koitharu.kotatsu.history.domain.MangaWithHistory
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.*
|
import org.koitharu.kotatsu.list.ui.model.*
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
import org.koitharu.kotatsu.utils.ext.daysDiff
|
import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||||
import org.koitharu.kotatsu.utils.ext.onFirst
|
import org.koitharu.kotatsu.utils.ext.onFirst
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class HistoryListViewModel(
|
class HistoryListViewModel(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
@@ -29,7 +27,6 @@ class HistoryListViewModel(
|
|||||||
private val trackingRepository: TrackingRepository,
|
private val trackingRepository: TrackingRepository,
|
||||||
) : MangaListViewModel(settings) {
|
) : MangaListViewModel(settings) {
|
||||||
|
|
||||||
val onItemRemoved = SingleLiveEvent<Manga>()
|
|
||||||
val isGroupingEnabled = MutableLiveData<Boolean>()
|
val isGroupingEnabled = MutableLiveData<Boolean>()
|
||||||
|
|
||||||
private val historyGrouping = settings.observe()
|
private val historyGrouping = settings.observe()
|
||||||
@@ -72,10 +69,12 @@ class HistoryListViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeFromHistory(manga: Manga) {
|
fun removeFromHistory(ids: Set<Long>) {
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
launchJob {
|
launchJob {
|
||||||
repository.delete(manga)
|
repository.delete(ids)
|
||||||
onItemRemoved.call(manga)
|
|
||||||
shortcutsRepository.updateShortcuts()
|
shortcutsRepository.updateShortcuts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class ListModeSelectDialog : AlertDialogFragment<DialogListModeBinding>(),
|
|||||||
binding.textViewGridTitle.isVisible = mode == ListMode.GRID
|
binding.textViewGridTitle.isVisible = mode == ListMode.GRID
|
||||||
binding.sliderGrid.isVisible = mode == ListMode.GRID
|
binding.sliderGrid.isVisible = mode == ListMode.GRID
|
||||||
|
|
||||||
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter())
|
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(view.context))
|
||||||
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
|
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
|
||||||
binding.sliderGrid.addOnSliderTouchListener(this)
|
binding.sliderGrid.addOnSliderTouchListener(this)
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package org.koitharu.kotatsu.list.ui
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.*
|
import android.view.*
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.collection.ArraySet
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.GravityCompat
|
import androidx.core.view.isNotEmpty
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
@@ -24,24 +26,31 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
|||||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID
|
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
||||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
||||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.utils.ShareHelper
|
||||||
import org.koitharu.kotatsu.utils.ext.*
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
|
|
||||||
abstract class MangaListFragment :
|
abstract class MangaListFragment :
|
||||||
BaseFragment<FragmentListBinding>(),
|
BaseFragment<FragmentListBinding>(),
|
||||||
PaginationScrollListener.Callback,
|
PaginationScrollListener.Callback,
|
||||||
MangaListListener,
|
MangaListListener,
|
||||||
SwipeRefreshLayout.OnRefreshListener {
|
SwipeRefreshLayout.OnRefreshListener,
|
||||||
|
ActionMode.Callback {
|
||||||
|
|
||||||
private var listAdapter: MangaListAdapter? = null
|
private var listAdapter: MangaListAdapter? = null
|
||||||
private var paginationListener: PaginationScrollListener? = null
|
private var paginationListener: PaginationScrollListener? = null
|
||||||
|
private var selectionDecoration: MangaSelectionDecoration? = null
|
||||||
|
private var actionMode: ActionMode? = null
|
||||||
private val spanResolver = MangaListSpanResolver()
|
private val spanResolver = MangaListSpanResolver()
|
||||||
private val spanSizeLookup = SpanSizeLookup()
|
private val spanSizeLookup = SpanSizeLookup()
|
||||||
private val listCommitCallback = Runnable {
|
private val listCommitCallback = Runnable {
|
||||||
@@ -51,6 +60,12 @@ abstract class MangaListFragment :
|
|||||||
|
|
||||||
protected abstract val viewModel: MangaListViewModel
|
protected abstract val viewModel: MangaListViewModel
|
||||||
|
|
||||||
|
protected val selectedItemsIds: Set<Long>
|
||||||
|
get() = selectionDecoration?.checkedItemsIds?.toSet().orEmpty()
|
||||||
|
|
||||||
|
protected val selectedItems: Set<Manga>
|
||||||
|
get() = collectSelectedItems()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
@@ -68,10 +83,12 @@ abstract class MangaListFragment :
|
|||||||
lifecycleOwner = viewLifecycleOwner,
|
lifecycleOwner = viewLifecycleOwner,
|
||||||
listener = this,
|
listener = this,
|
||||||
)
|
)
|
||||||
|
selectionDecoration = MangaSelectionDecoration(view.context)
|
||||||
paginationListener = PaginationScrollListener(4, this)
|
paginationListener = PaginationScrollListener(4, this)
|
||||||
with(binding.recyclerView) {
|
with(binding.recyclerView) {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
adapter = listAdapter
|
adapter = listAdapter
|
||||||
|
addItemDecoration(selectionDecoration!!)
|
||||||
addOnScrollListener(paginationListener!!)
|
addOnScrollListener(paginationListener!!)
|
||||||
}
|
}
|
||||||
with(binding.swipeRefreshLayout) {
|
with(binding.swipeRefreshLayout) {
|
||||||
@@ -91,6 +108,7 @@ abstract class MangaListFragment :
|
|||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
listAdapter = null
|
listAdapter = null
|
||||||
paginationListener = null
|
paginationListener = null
|
||||||
|
selectionDecoration = null
|
||||||
spanSizeLookup.invalidateCache()
|
spanSizeLookup.invalidateCache()
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
@@ -109,22 +127,28 @@ abstract class MangaListFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: Manga, view: View) {
|
override fun onItemClick(item: Manga, view: View) {
|
||||||
|
if (selectionDecoration?.checkedItemsCount != 0) {
|
||||||
|
selectionDecoration?.toggleItemChecked(item.id)
|
||||||
|
if (selectionDecoration?.checkedItemsCount == 0) {
|
||||||
|
actionMode?.finish()
|
||||||
|
} else {
|
||||||
|
actionMode?.invalidate()
|
||||||
|
binding.recyclerView.invalidateItemDecorations()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
startActivity(DetailsActivity.newIntent(context ?: return, item))
|
startActivity(DetailsActivity.newIntent(context ?: return, item))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemLongClick(item: Manga, view: View): Boolean {
|
override fun onItemLongClick(item: Manga, view: View): Boolean {
|
||||||
val menu = PopupMenu(context ?: return false, view)
|
if (actionMode == null) {
|
||||||
onCreatePopupMenu(menu.menuInflater, menu.menu, item)
|
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
|
||||||
return if (menu.menu.hasVisibleItems()) {
|
|
||||||
menu.setOnMenuItemClickListener {
|
|
||||||
onPopupMenuItemSelected(it, item)
|
|
||||||
}
|
|
||||||
menu.gravity = GravityCompat.END or Gravity.TOP
|
|
||||||
menu.show()
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
return actionMode?.also {
|
||||||
|
selectionDecoration?.setItemIsChecked(item.id, true)
|
||||||
|
binding.recyclerView.invalidateItemDecorations()
|
||||||
|
it.invalidate()
|
||||||
|
} != null
|
||||||
}
|
}
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
@@ -238,12 +262,67 @@ abstract class MangaListFragment :
|
|||||||
addOnLayoutChangeListener(spanResolver)
|
addOnLayoutChangeListener(spanResolver)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selectionDecoration?.let { addItemDecoration(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
return menu.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false
|
@CallSuper
|
||||||
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
mode.title = selectionDecoration?.checkedItemsCount?.toString()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
R.id.action_select_all -> {
|
||||||
|
val ids = listAdapter?.items?.mapNotNull {
|
||||||
|
(it as? MangaItemModel)?.id
|
||||||
|
} ?: return false
|
||||||
|
selectionDecoration?.checkAll(ids)
|
||||||
|
binding.recyclerView.invalidateItemDecorations()
|
||||||
|
mode.invalidate()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_share -> {
|
||||||
|
ShareHelper(requireContext()).shareMangaLinks(selectedItems)
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_favourite -> {
|
||||||
|
FavouriteCategoriesDialog.show(childFragmentManager, selectedItems)
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_save -> {
|
||||||
|
DownloadService.confirmAndStart(requireContext(), selectedItems)
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyActionMode(mode: ActionMode) {
|
||||||
|
selectionDecoration?.clearSelection()
|
||||||
|
binding.recyclerView.invalidateItemDecorations()
|
||||||
|
actionMode = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectSelectedItems(): Set<Manga> {
|
||||||
|
val checkedIds = selectionDecoration?.checkedItemsIds ?: return emptySet()
|
||||||
|
val items = listAdapter?.items ?: return emptySet()
|
||||||
|
val result = ArraySet<Manga>(checkedIds.size)
|
||||||
|
for (item in items) {
|
||||||
|
if (item is MangaItemModel && item.id in checkedIds) {
|
||||||
|
result.add(item.manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.view.View
|
||||||
|
import androidx.cardview.widget.CardView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getItem
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
|
||||||
|
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
|
||||||
|
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
|
||||||
|
private val fillColor = ColorUtils.setAlphaComponent(
|
||||||
|
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||||
|
0x74
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
hasBackground = false
|
||||||
|
hasForeground = true
|
||||||
|
isIncludeDecorAndMargins = false
|
||||||
|
|
||||||
|
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
|
||||||
|
checkIcon?.setTint(strokeColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemId(parent: RecyclerView, child: View): Long {
|
||||||
|
val holder = parent.getChildViewHolder(child) ?: return NO_ID
|
||||||
|
val item = holder.getItem(MangaItemModel::class.java) ?: return NO_ID
|
||||||
|
return item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDrawForeground(
|
||||||
|
canvas: Canvas,
|
||||||
|
parent: RecyclerView,
|
||||||
|
child: View,
|
||||||
|
bounds: RectF,
|
||||||
|
state: RecyclerView.State,
|
||||||
|
) {
|
||||||
|
val radius = (child as? CardView)?.radius ?: 0f
|
||||||
|
paint.color = fillColor
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||||
|
paint.color = strokeColor
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||||
|
checkIcon?.run {
|
||||||
|
setBounds(
|
||||||
|
(bounds.left + iconOffset).toInt(),
|
||||||
|
(bounds.top + iconOffset).toInt(),
|
||||||
|
(bounds.left + iconOffset + intrinsicWidth).toInt(),
|
||||||
|
(bounds.top + iconOffset + intrinsicHeight).toInt(),
|
||||||
|
)
|
||||||
|
draw(canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@ package org.koitharu.kotatsu.list.ui.model
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaGridModel(
|
data class MangaGridModel(
|
||||||
val id: Long,
|
override val id: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val coverUrl: String,
|
val coverUrl: String,
|
||||||
val manga: Manga,
|
override val manga: Manga,
|
||||||
val counter: Int,
|
val counter: Int,
|
||||||
) : ListModel
|
) : MangaItemModel
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
sealed interface MangaItemModel : ListModel {
|
||||||
|
|
||||||
|
val id: Long
|
||||||
|
val manga: Manga
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ package org.koitharu.kotatsu.list.ui.model
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaListDetailedModel(
|
data class MangaListDetailedModel(
|
||||||
val id: Long,
|
override val id: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val subtitle: String?,
|
val subtitle: String?,
|
||||||
val tags: String,
|
val tags: String,
|
||||||
val coverUrl: String,
|
val coverUrl: String,
|
||||||
val rating: String?,
|
val rating: String?,
|
||||||
val manga: Manga,
|
override val manga: Manga,
|
||||||
val counter: Int,
|
val counter: Int,
|
||||||
) : ListModel
|
) : MangaItemModel
|
||||||
@@ -3,10 +3,10 @@ package org.koitharu.kotatsu.list.ui.model
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaListModel(
|
data class MangaListModel(
|
||||||
val id: Long,
|
override val id: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val subtitle: String,
|
val subtitle: String,
|
||||||
val coverUrl: String,
|
val coverUrl: String,
|
||||||
val manga: Manga,
|
override val manga: Manga,
|
||||||
val counter: Int,
|
val counter: Int,
|
||||||
) : ListModel
|
) : MangaItemModel
|
||||||
@@ -9,6 +9,9 @@ import android.view.MenuItem
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.result.ActivityResultCallback
|
import androidx.activity.result.ActivityResultCallback
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
@@ -16,8 +19,7 @@ import org.koitharu.kotatsu.BuildConfig
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.utils.ShareHelper
|
||||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
|
||||||
import org.koitharu.kotatsu.utils.progress.Progress
|
import org.koitharu.kotatsu.utils.progress.Progress
|
||||||
|
|
||||||
class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmSuppressWildcards Uri>> {
|
class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmSuppressWildcards Uri>> {
|
||||||
@@ -46,7 +48,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
|
viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() }
|
||||||
viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged)
|
viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,35 +99,41 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
|
|||||||
viewModel.importFiles(result)
|
viewModel.importFiles(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
super.onCreatePopupMenu(inflater, menu, data)
|
mode.menuInflater.inflate(R.menu.mode_local, menu)
|
||||||
inflater.inflate(R.menu.popup_local, menu)
|
return super.onCreateActionMode(mode, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean {
|
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_delete -> {
|
R.id.action_remove -> {
|
||||||
MaterialAlertDialogBuilder(context ?: return false)
|
showDeletionConfirm(selectedItemsIds, mode)
|
||||||
.setTitle(R.string.delete_manga)
|
|
||||||
.setMessage(getString(R.string.text_delete_local_manga, data.title))
|
|
||||||
.setPositiveButton(R.string.delete) { _, _ ->
|
|
||||||
viewModel.delete(data)
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.show()
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onPopupMenuItemSelected(item, data)
|
R.id.action_share -> {
|
||||||
|
val files = selectedItems.map { it.url.toUri().toFile() }
|
||||||
|
ShareHelper(requireContext()).shareCbz(files)
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onActionItemClicked(mode, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onItemRemoved(item: Manga) {
|
private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
|
||||||
Snackbar.make(
|
MaterialAlertDialogBuilder(context ?: return)
|
||||||
binding.recyclerView, getString(
|
.setTitle(R.string.delete_manga)
|
||||||
R.string._s_deleted_from_local_storage,
|
.setMessage(getString(R.string.text_delete_local_manga_batch))
|
||||||
item.title.ellipsize(16)
|
.setPositiveButton(R.string.delete) { _, _ ->
|
||||||
), Snackbar.LENGTH_SHORT
|
viewModel.delete(ids)
|
||||||
).show()
|
mode.finish()
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onItemRemoved() {
|
||||||
|
Snackbar.make(binding.recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onImportProgressChanged(progress: Progress?) {
|
private fun onImportProgressChanged(progress: Progress?) {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.local.ui
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import java.io.IOException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
||||||
@@ -19,7 +21,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
import org.koitharu.kotatsu.utils.progress.Progress
|
import org.koitharu.kotatsu.utils.progress.Progress
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
class LocalListViewModel(
|
class LocalListViewModel(
|
||||||
private val repository: LocalMangaRepository,
|
private val repository: LocalMangaRepository,
|
||||||
@@ -28,7 +29,7 @@ class LocalListViewModel(
|
|||||||
private val shortcutsRepository: ShortcutsRepository,
|
private val shortcutsRepository: ShortcutsRepository,
|
||||||
) : MangaListViewModel(settings) {
|
) : MangaListViewModel(settings) {
|
||||||
|
|
||||||
val onMangaRemoved = SingleLiveEvent<Manga>()
|
val onMangaRemoved = SingleLiveEvent<Unit>()
|
||||||
val importProgress = MutableLiveData<Progress?>(null)
|
val importProgress = MutableLiveData<Progress?>(null)
|
||||||
private val listError = MutableStateFlow<Throwable?>(null)
|
private val listError = MutableStateFlow<Throwable?>(null)
|
||||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||||
@@ -87,18 +88,23 @@ class LocalListViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(manga: Manga) {
|
fun delete(ids: Set<Long>) {
|
||||||
launchJob {
|
launchLoadingJob {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val original = repository.getRemoteManga(manga)
|
val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids }
|
||||||
repository.delete(manga) || throw IOException("Unable to delete file")
|
for (manga in itemsToRemove) {
|
||||||
runCatching {
|
val original = repository.getRemoteManga(manga)
|
||||||
historyRepository.deleteOrSwap(manga, original)
|
repository.delete(manga) || throw IOException("Unable to delete file")
|
||||||
|
runCatching {
|
||||||
|
historyRepository.deleteOrSwap(manga, original)
|
||||||
|
}
|
||||||
|
mangaList.update { list ->
|
||||||
|
list?.filterNot { it.id == manga.id }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mangaList.value = mangaList.value?.filterNot { it.id == manga.id }
|
|
||||||
}
|
}
|
||||||
shortcutsRepository.updateShortcuts()
|
shortcutsRepository.updateShortcuts()
|
||||||
onMangaRemoved.call(manga)
|
onMangaRemoved.call(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,18 +8,15 @@ import android.view.MenuItem
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup.MarginLayoutParams
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.GravityCompat
|
import androidx.core.view.*
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.drawerlayout.widget.DrawerLayout
|
import androidx.drawerlayout.widget.DrawerLayout
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentTransaction
|
import androidx.fragment.app.FragmentTransaction
|
||||||
import androidx.fragment.app.commit
|
import androidx.fragment.app.commit
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.navigation.NavigationView
|
import com.google.android.material.navigation.NavigationView
|
||||||
@@ -134,6 +131,7 @@ class MainActivity :
|
|||||||
viewModel.onOpenReader.observe(this, this::onOpenReader)
|
viewModel.onOpenReader.observe(this, this::onOpenReader)
|
||||||
viewModel.onError.observe(this, this::onError)
|
viewModel.onError.observe(this, this::onError)
|
||||||
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
|
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
|
||||||
|
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
|
||||||
viewModel.remoteSources.observe(this, this::updateSideMenu)
|
viewModel.remoteSources.observe(this, this::updateSideMenu)
|
||||||
viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled)
|
viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled)
|
||||||
}
|
}
|
||||||
@@ -288,6 +286,16 @@ class MainActivity :
|
|||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||||
|
super.onSupportActionModeStarted(mode)
|
||||||
|
adjustDrawerLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSupportActionModeFinished(mode: ActionMode) {
|
||||||
|
super.onSupportActionModeFinished(mode)
|
||||||
|
adjustDrawerLock()
|
||||||
|
}
|
||||||
|
|
||||||
private fun onOpenReader(manga: Manga) {
|
private fun onOpenReader(manga: Manga) {
|
||||||
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
ActivityOptions.makeClipRevealAnimation(
|
ActivityOptions.makeClipRevealAnimation(
|
||||||
@@ -302,23 +310,15 @@ class MainActivity :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onError(e: Throwable) {
|
private fun onError(e: Throwable) {
|
||||||
Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT)
|
Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
binding.fab.isEnabled = !isLoading
|
binding.fab.isEnabled = !isLoading
|
||||||
if (isLoading) {
|
}
|
||||||
binding.fab.setImageDrawable(
|
|
||||||
CircularProgressDrawable(this).also {
|
private fun onResumeEnabledChanged(isEnabled: Boolean) {
|
||||||
it.setColorSchemeColors(R.color.kotatsu_onPrimaryContainer)
|
adjustFabVisibility(isResumeEnabled = isEnabled)
|
||||||
it.strokeWidth = resources.resolveDp(3.5f)
|
|
||||||
it.start()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
binding.fab.setImageResource(R.drawable.ic_read_fill)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSideMenu(remoteSources: List<MangaSource>) {
|
private fun updateSideMenu(remoteSources: List<MangaSource>) {
|
||||||
@@ -372,14 +372,14 @@ class MainActivity :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onSearchOpened() {
|
private fun onSearchOpened() {
|
||||||
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
|
||||||
drawerToggle?.isDrawerIndicatorEnabled = false
|
drawerToggle?.isDrawerIndicatorEnabled = false
|
||||||
|
adjustDrawerLock()
|
||||||
adjustFabVisibility(isSearchOpened = true)
|
adjustFabVisibility(isSearchOpened = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onSearchClosed() {
|
private fun onSearchClosed() {
|
||||||
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
|
|
||||||
drawerToggle?.isDrawerIndicatorEnabled = true
|
drawerToggle?.isDrawerIndicatorEnabled = true
|
||||||
|
adjustDrawerLock()
|
||||||
adjustFabVisibility(isSearchOpened = false)
|
adjustFabVisibility(isSearchOpened = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,9 +397,27 @@ class MainActivity :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun adjustFabVisibility(
|
private fun adjustFabVisibility(
|
||||||
|
isResumeEnabled: Boolean = viewModel.isResumeEnabled.value == true,
|
||||||
topFragment: Fragment? = supportFragmentManager.findFragmentByTag(TAG_PRIMARY),
|
topFragment: Fragment? = supportFragmentManager.findFragmentByTag(TAG_PRIMARY),
|
||||||
isSearchOpened: Boolean = supportFragmentManager.findFragmentByTag(TAG_SEARCH)?.isVisible == true,
|
isSearchOpened: Boolean = supportFragmentManager.findFragmentByTag(TAG_SEARCH)?.isVisible == true,
|
||||||
) {
|
) {
|
||||||
if (!isSearchOpened && topFragment is HistoryListFragment) binding.fab.show() else binding.fab.hide()
|
val fab = binding.fab
|
||||||
|
if (isResumeEnabled && !isSearchOpened && topFragment is HistoryListFragment) {
|
||||||
|
if (!fab.isVisible) {
|
||||||
|
fab.show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (fab.isVisible) {
|
||||||
|
fab.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adjustDrawerLock() {
|
||||||
|
val drawer = drawer ?: return
|
||||||
|
val isLocked = actionModeDelegate.isActionModeStarted || (drawerToggle?.isDrawerIndicatorEnabled == false)
|
||||||
|
drawer.setDrawerLockMode(
|
||||||
|
if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,10 @@ class MainViewModel(
|
|||||||
.map { settings.isSuggestionsEnabled }
|
.map { settings.isSuggestionsEnabled }
|
||||||
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
|
val isResumeEnabled = historyRepository
|
||||||
|
.observeHasItems()
|
||||||
|
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
val remoteSources = settings.observe()
|
val remoteSources = settings.observe()
|
||||||
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN }
|
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN }
|
||||||
.onStart { emit("") }
|
.onStart { emit("") }
|
||||||
@@ -35,9 +39,8 @@ class MainViewModel(
|
|||||||
|
|
||||||
fun openLastReader() {
|
fun openLastReader() {
|
||||||
launchLoadingJob {
|
launchLoadingJob {
|
||||||
val manga = historyRepository.getList(0, 1).firstOrNull()
|
val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException()
|
||||||
?: throw EmptyHistoryException()
|
|
||||||
onOpenReader.call(manga)
|
onOpenReader.call(manga)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
|
|||||||
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
|
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
|
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
|
||||||
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
import org.koitharu.kotatsu.utils.GridTouchHelper
|
import org.koitharu.kotatsu.utils.GridTouchHelper
|
||||||
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
|
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
|
||||||
import org.koitharu.kotatsu.utils.ShareHelper
|
import org.koitharu.kotatsu.utils.ShareHelper
|
||||||
@@ -158,7 +159,7 @@ class ReaderActivity :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
R.id.action_settings -> {
|
R.id.action_settings -> {
|
||||||
startActivity(SimpleSettingsActivity.newReaderSettingsIntent(this))
|
startActivity(SettingsActivity.newReaderSettingsIntent(this))
|
||||||
}
|
}
|
||||||
R.id.action_chapters -> {
|
R.id.action_chapters -> {
|
||||||
ChaptersBottomSheet.show(
|
ChaptersBottomSheet.show(
|
||||||
@@ -409,18 +410,18 @@ class ReaderActivity :
|
|||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga): Intent {
|
fun newIntent(context: Context, manga: Manga): Intent {
|
||||||
return Intent(context, ReaderActivity::class.java)
|
return Intent(context, ReaderActivity::class.java)
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga, branch: String?): Intent {
|
fun newIntent(context: Context, manga: Manga, branch: String?): Intent {
|
||||||
return Intent(context, ReaderActivity::class.java)
|
return Intent(context, ReaderActivity::class.java)
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||||
.putExtra(EXTRA_BRANCH, branch)
|
.putExtra(EXTRA_BRANCH, branch)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
|
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
|
||||||
return Intent(context, ReaderActivity::class.java)
|
return Intent(context, ReaderActivity::class.java)
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||||
.putExtra(EXTRA_STATE, state)
|
.putExtra(EXTRA_STATE, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,8 @@ class ReaderViewModel(
|
|||||||
val page = getCurrentPage() ?: error("Page not found")
|
val page = getCurrentPage() ?: error("Page not found")
|
||||||
externalStorageHelper.savePage(page, destination)
|
externalStorageHelper.savePage(page, destination)
|
||||||
onPageSaved.postCall(destination)
|
onPageSaved.postCall(destination)
|
||||||
} catch (_: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.commit
|
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding
|
|
||||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.settings.*
|
|
||||||
|
|
||||||
class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>(), AppBarOwner {
|
|
||||||
|
|
||||||
override val appBar: AppBarLayout
|
|
||||||
get() = binding.appbar
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(ActivitySettingsSimpleBinding.inflate(layoutInflater))
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
supportFragmentManager.commit {
|
|
||||||
replace(
|
|
||||||
R.id.container,
|
|
||||||
when (intent?.action) {
|
|
||||||
Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment()
|
|
||||||
ACTION_READER -> ReaderSettingsFragment()
|
|
||||||
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
|
|
||||||
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
|
|
||||||
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL
|
|
||||||
)
|
|
||||||
else -> MainSettingsFragment()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
with(binding.toolbar) {
|
|
||||||
updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right
|
|
||||||
)
|
|
||||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
topMargin = insets.top
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val ACTION_READER =
|
|
||||||
"${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
|
|
||||||
private const val ACTION_SUGGESTIONS =
|
|
||||||
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
|
|
||||||
private const val ACTION_SOURCE =
|
|
||||||
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
|
|
||||||
private const val EXTRA_SOURCE = "source"
|
|
||||||
|
|
||||||
fun newReaderSettingsIntent(context: Context) =
|
|
||||||
Intent(context, SimpleSettingsActivity::class.java)
|
|
||||||
.setAction(ACTION_READER)
|
|
||||||
|
|
||||||
fun newSuggestionsSettingsIntent(context: Context) =
|
|
||||||
Intent(context, SimpleSettingsActivity::class.java)
|
|
||||||
.setAction(ACTION_SUGGESTIONS)
|
|
||||||
|
|
||||||
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
|
|
||||||
Intent(context, SimpleSettingsActivity::class.java)
|
|
||||||
.setAction(ACTION_SOURCE)
|
|
||||||
.putExtra(EXTRA_SOURCE, source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -109,7 +109,7 @@ class PageHolderDelegate(
|
|||||||
state = State.LOADED
|
state = State.LOADED
|
||||||
callback.onImageReady(file.toUri())
|
callback.onImageReady(file.toUri())
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
// do nothing
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
state = State.ERROR
|
state = State.ERROR
|
||||||
error = e
|
error = e
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
|||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||||
import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged
|
import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged
|
||||||
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
|
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
|
||||||
import org.koitharu.kotatsu.utils.ext.firstItem
|
import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
|
||||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||||
|
|
||||||
class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
|
class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
|
||||||
@@ -52,7 +52,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
|
|||||||
setItems.await() ?: return@launchWhenCreated
|
setItems.await() ?: return@launchWhenCreated
|
||||||
if (position != -1) {
|
if (position != -1) {
|
||||||
with(binding.recyclerView) {
|
with(binding.recyclerView) {
|
||||||
firstItem = position
|
firstVisibleItemPosition = position
|
||||||
post {
|
post {
|
||||||
(findViewHolderForAdapterPosition(position) as? WebtoonHolder)
|
(findViewHolderForAdapterPosition(position) as? WebtoonHolder)
|
||||||
?.restoreScroll(pendingState.scroll)
|
?.restoreScroll(pendingState.scroll)
|
||||||
@@ -91,6 +91,6 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun switchPageTo(position: Int, smooth: Boolean) {
|
override fun switchPageTo(position: Int, smooth: Boolean) {
|
||||||
binding.recyclerView.firstItem = position
|
binding.recyclerView.firstVisibleItemPosition = position
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,14 @@ package org.koitharu.kotatsu.remotelist.ui
|
|||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet
|
import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
import org.koitharu.kotatsu.utils.ext.serializableArgument
|
import org.koitharu.kotatsu.utils.ext.serializableArgument
|
||||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||||
|
|
||||||
@@ -34,10 +35,7 @@ class RemoteListFragment : MangaListFragment() {
|
|||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_source_settings -> {
|
R.id.action_source_settings -> {
|
||||||
startActivity(
|
startActivity(
|
||||||
SimpleSettingsActivity.newSourceSettingsIntent(
|
SettingsActivity.newSourceSettingsIntent(context ?: return false, source)
|
||||||
context ?: return false,
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -49,6 +47,11 @@ class RemoteListFragment : MangaListFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
mode.menuInflater.inflate(R.menu.mode_remote, menu)
|
||||||
|
return super.onCreateActionMode(mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onFilterClick() {
|
override fun onFilterClick() {
|
||||||
FilterBottomSheet.show(childFragmentManager)
|
FilterBottomSheet.show(childFragmentManager)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.*
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toMangaTag
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -84,7 +86,7 @@ class MangaSearchRepository(
|
|||||||
return when {
|
return when {
|
||||||
query.isNotEmpty() && source != null -> db.tagsDao.findTags(source.name, "%$query%", limit)
|
query.isNotEmpty() && source != null -> db.tagsDao.findTags(source.name, "%$query%", limit)
|
||||||
query.isNotEmpty() -> db.tagsDao.findTags("%$query%", limit)
|
query.isNotEmpty() -> db.tagsDao.findTags("%$query%", limit)
|
||||||
source != null -> db.tagsDao.findTags(source.name, limit)
|
source != null -> db.tagsDao.findPopularTags(source.name, limit)
|
||||||
else -> db.tagsDao.findPopularTags(limit)
|
else -> db.tagsDao.findPopularTags(limit)
|
||||||
}.map {
|
}.map {
|
||||||
it.toMangaTag()
|
it.toMangaTag()
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.search.ui
|
package org.koitharu.kotatsu.search.ui
|
||||||
|
|
||||||
|
import android.view.Menu
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.utils.ext.serializableArgument
|
import org.koitharu.kotatsu.utils.ext.serializableArgument
|
||||||
@@ -21,6 +24,11 @@ class SearchFragment : MangaListFragment() {
|
|||||||
viewModel.loadNextPage()
|
viewModel.loadNextPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
mode.menuInflater.inflate(R.menu.mode_remote, menu)
|
||||||
|
return super.onCreateActionMode(mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val ARG_QUERY = "query"
|
private const val ARG_QUERY = "query"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.search.ui.global
|
package org.koitharu.kotatsu.search.ui.global
|
||||||
|
|
||||||
|
import android.view.Menu
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.utils.ext.stringArgument
|
import org.koitharu.kotatsu.utils.ext.stringArgument
|
||||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||||
|
|
||||||
|
|
||||||
class GlobalSearchFragment : MangaListFragment() {
|
class GlobalSearchFragment : MangaListFragment() {
|
||||||
|
|
||||||
override val viewModel by viewModel<GlobalSearchViewModel> {
|
override val viewModel by viewModel<GlobalSearchViewModel> {
|
||||||
@@ -17,6 +19,11 @@ class GlobalSearchFragment : MangaListFragment() {
|
|||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
override fun onScrolledToEnd() = Unit
|
||||||
|
|
||||||
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
mode.menuInflater.inflate(R.menu.mode_remote, menu)
|
||||||
|
return super.onCreateActionMode(mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val ARG_QUERY = "query"
|
private const val ARG_QUERY = "query"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class SearchSuggestionItemCallback(
|
|||||||
): Boolean = false
|
): Boolean = false
|
||||||
|
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
val item = viewHolder.getItem<SearchSuggestionItem.RecentQuery>() ?: return
|
val item = viewHolder.getItem(SearchSuggestionItem.RecentQuery::class.java) ?: return
|
||||||
listener.onRemoveQuery(item.query)
|
listener.onRemoveQuery(item.query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.utils.ext.areItemsEquals
|
import org.koitharu.kotatsu.parsers.util.areItemsEquals
|
||||||
|
|
||||||
sealed interface SearchSuggestionItem {
|
sealed interface SearchSuggestionItem {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.TwoStatePreference
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
|
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
||||||
|
import org.koitharu.kotatsu.settings.utils.SliderPreference
|
||||||
|
import org.koitharu.kotatsu.utils.ext.names
|
||||||
|
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class AppearanceSettingsFragment :
|
||||||
|
BasePreferenceFragment(R.string.appearance),
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
addPreferencesFromResource(R.xml.pref_appearance)
|
||||||
|
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.run {
|
||||||
|
val pattern = context.getString(R.string.percent_string_pattern)
|
||||||
|
summary = pattern.format(value.toString())
|
||||||
|
setOnPreferenceChangeListener { preference, newValue ->
|
||||||
|
preference.summary = pattern.format(newValue.toString())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preferenceScreen?.findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
|
||||||
|
entryValues = ListMode.values().names()
|
||||||
|
setDefaultValueCompat(ListMode.GRID.name)
|
||||||
|
}
|
||||||
|
findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = AppSettings.isDynamicColorAvailable
|
||||||
|
findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run {
|
||||||
|
entryValues = resources.getStringArray(R.array.date_formats)
|
||||||
|
val now = Date().time
|
||||||
|
entries = entryValues.map { value ->
|
||||||
|
val formattedDate = settings.getDateFormat(value.toString()).format(now)
|
||||||
|
if (value == "") {
|
||||||
|
"${context.getString(R.string.system_default)} ($formattedDate)"
|
||||||
|
} else {
|
||||||
|
formattedDate
|
||||||
|
}
|
||||||
|
}.toTypedArray()
|
||||||
|
setDefaultValueCompat("")
|
||||||
|
summary = "%s"
|
||||||
|
}
|
||||||
|
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||||
|
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
settings.subscribe(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
settings.unsubscribe(this)
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||||
|
when (key) {
|
||||||
|
AppSettings.KEY_THEME -> {
|
||||||
|
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||||
|
}
|
||||||
|
AppSettings.KEY_DYNAMIC_THEME -> {
|
||||||
|
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
|
||||||
|
}
|
||||||
|
AppSettings.KEY_THEME_AMOLED -> {
|
||||||
|
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
|
||||||
|
}
|
||||||
|
AppSettings.KEY_APP_PASSWORD -> {
|
||||||
|
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||||
|
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
|
return when (preference.key) {
|
||||||
|
AppSettings.KEY_PROTECT_APP -> {
|
||||||
|
val pref = (preference as? TwoStatePreference ?: return false)
|
||||||
|
if (pref.isChecked) {
|
||||||
|
pref.isChecked = false
|
||||||
|
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
|
||||||
|
} else {
|
||||||
|
settings.appPassword = null
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||||
|
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||||
|
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||||
|
|
||||||
|
class ContentSettingsFragment :
|
||||||
|
BasePreferenceFragment(R.string.content),
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||||
|
StorageSelectDialog.OnStorageSelectListener {
|
||||||
|
|
||||||
|
private val storageManager by inject<LocalStorageManager>()
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
addPreferencesFromResource(R.xml.pref_content)
|
||||||
|
|
||||||
|
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
||||||
|
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
|
||||||
|
)
|
||||||
|
bindRemoteSourcesSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
||||||
|
settings.subscribe(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
settings.unsubscribe(this)
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||||
|
when (key) {
|
||||||
|
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||||
|
findPreference<Preference>(key)?.bindStorageName()
|
||||||
|
}
|
||||||
|
AppSettings.KEY_SUGGESTIONS -> {
|
||||||
|
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
||||||
|
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppSettings.KEY_SOURCES_HIDDEN -> {
|
||||||
|
bindRemoteSourcesSummary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
|
return when (preference.key) {
|
||||||
|
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||||
|
val ctx = context ?: return false
|
||||||
|
StorageSelectDialog.Builder(ctx, storageManager, this)
|
||||||
|
.setTitle(preference.title ?: "")
|
||||||
|
.setNegativeButton(android.R.string.cancel)
|
||||||
|
.create()
|
||||||
|
.show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStorageSelected(file: File) {
|
||||||
|
settings.mangaStorageDir = file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Preference.bindStorageName() {
|
||||||
|
viewLifecycleScope.launch {
|
||||||
|
val storage = storageManager.getDefaultWriteableDir()
|
||||||
|
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindRemoteSourcesSummary() {
|
||||||
|
findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.run {
|
||||||
|
val total = MangaSource.values().size - 1
|
||||||
|
summary = getString(
|
||||||
|
R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings
|
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.preference.ListPreference
|
|
||||||
import androidx.preference.Preference
|
|
||||||
import androidx.preference.PreferenceScreen
|
|
||||||
import androidx.preference.TwoStatePreference
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.android.ext.android.inject
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
|
||||||
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
|
||||||
import org.koitharu.kotatsu.settings.utils.SliderPreference
|
|
||||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
|
||||||
import org.koitharu.kotatsu.utils.ext.names
|
|
||||||
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
|
|
||||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
|
||||||
import java.io.File
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
|
|
||||||
class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
|
||||||
StorageSelectDialog.OnStorageSelectListener {
|
|
||||||
|
|
||||||
private val storageManager by inject<LocalStorageManager>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setHasOptionsMenu(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
|
||||||
addPreferencesFromResource(R.xml.pref_main)
|
|
||||||
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.run {
|
|
||||||
summary = "%d%%".format(value)
|
|
||||||
setOnPreferenceChangeListener { preference, newValue ->
|
|
||||||
preference.summary = "%d%%".format(newValue)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preferenceScreen?.findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
|
|
||||||
entryValues = ListMode.values().names()
|
|
||||||
setDefaultValueCompat(ListMode.GRID.name)
|
|
||||||
}
|
|
||||||
findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible =
|
|
||||||
AppSettings.isDynamicColorAvailable
|
|
||||||
findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run {
|
|
||||||
entryValues = resources.getStringArray(R.array.date_formats)
|
|
||||||
val now = Date().time
|
|
||||||
entries = entryValues.map { value ->
|
|
||||||
val formattedDate = settings.getDateFormat(value.toString()).format(now)
|
|
||||||
if (value == "") {
|
|
||||||
"${context.getString(R.string.system_default)} ($formattedDate)"
|
|
||||||
} else {
|
|
||||||
formattedDate
|
|
||||||
}
|
|
||||||
}.toTypedArray()
|
|
||||||
setDefaultValueCompat("")
|
|
||||||
summary = "%s"
|
|
||||||
}
|
|
||||||
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
|
||||||
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
|
||||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
|
|
||||||
!settings.appPassword.isNullOrEmpty()
|
|
||||||
settings.subscribe(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
|
||||||
super.onCreateOptionsMenu(menu, inflater)
|
|
||||||
inflater.inflate(R.menu.opt_settings, menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
return when (item.itemId) {
|
|
||||||
R.id.action_leaks -> {
|
|
||||||
val intent = Intent()
|
|
||||||
intent.component = ComponentName(requireContext(), "leakcanary.internal.activity.LeakActivity")
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
startActivity(intent)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
settings.unsubscribe(this)
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
|
|
||||||
when (key) {
|
|
||||||
AppSettings.KEY_THEME -> {
|
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
|
||||||
}
|
|
||||||
AppSettings.KEY_DYNAMIC_THEME -> {
|
|
||||||
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
|
|
||||||
}
|
|
||||||
AppSettings.KEY_THEME_AMOLED -> {
|
|
||||||
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
|
|
||||||
}
|
|
||||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
|
||||||
findPreference<Preference>(key)?.bindStorageName()
|
|
||||||
}
|
|
||||||
AppSettings.KEY_APP_PASSWORD -> {
|
|
||||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
|
||||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
|
||||||
}
|
|
||||||
AppSettings.KEY_SUGGESTIONS -> {
|
|
||||||
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
|
||||||
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
findPreference<PreferenceScreen>(AppSettings.KEY_REMOTE_SOURCES)?.run {
|
|
||||||
val total = MangaSource.values().size - 1
|
|
||||||
summary = getString(
|
|
||||||
R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
|
||||||
return when (preference.key) {
|
|
||||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
|
||||||
val ctx = context ?: return false
|
|
||||||
StorageSelectDialog.Builder(ctx, storageManager, this)
|
|
||||||
.setTitle(preference.title ?: "")
|
|
||||||
.setNegativeButton(android.R.string.cancel)
|
|
||||||
.create()
|
|
||||||
.show()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
AppSettings.KEY_PROTECT_APP -> {
|
|
||||||
val pref = (preference as? TwoStatePreference ?: return false)
|
|
||||||
if (pref.isChecked) {
|
|
||||||
pref.isChecked = false
|
|
||||||
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
|
|
||||||
} else {
|
|
||||||
settings.appPassword = null
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> super.onPreferenceTreeClick(preference)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStorageSelected(file: File) {
|
|
||||||
settings.mangaStorageDir = file
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Preference.bindStorageName() {
|
|
||||||
viewLifecycleScope.launch {
|
|
||||||
val storage = storageManager.getDefaultWriteableDir()
|
|
||||||
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,9 @@ import android.os.Bundle
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||||
|
|
||||||
class NetworkSettingsFragment : BasePreferenceFragment(R.string.settings) {
|
class RootSettingsFragment : BasePreferenceFragment(R.string.settings) {
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
//TODO https://developer.android.com/training/basics/network-ops/managing
|
addPreferencesFromResource(R.xml.pref_root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.settings
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import androidx.fragment.app.FragmentTransaction
|
import androidx.fragment.app.FragmentTransaction
|
||||||
@@ -11,6 +15,7 @@ import androidx.fragment.app.commit
|
|||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
||||||
@@ -34,9 +39,7 @@ class SettingsActivity :
|
|||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
if (supportFragmentManager.findFragmentById(R.id.container) == null) {
|
if (supportFragmentManager.findFragmentById(R.id.container) == null) {
|
||||||
supportFragmentManager.commit {
|
openDefaultFragment()
|
||||||
replace(R.id.container, MainSettingsFragment())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +58,22 @@ class SettingsActivity :
|
|||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||||
|
return super.onCreateOptionsMenu(menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||||
|
R.id.action_leaks -> {
|
||||||
|
val intent = Intent()
|
||||||
|
intent.component = ComponentName(this, "leakcanary.internal.activity.LeakActivity")
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
startActivity(intent)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBackStackChanged() {
|
override fun onBackStackChanged() {
|
||||||
val fragment = supportFragmentManager.findFragmentById(R.id.container) as? RecyclerViewOwner ?: return
|
val fragment = supportFragmentManager.findFragmentById(R.id.container) as? RecyclerViewOwner ?: return
|
||||||
val recyclerView = fragment.recyclerView
|
val recyclerView = fragment.recyclerView
|
||||||
@@ -70,32 +89,66 @@ class SettingsActivity :
|
|||||||
val fm = supportFragmentManager
|
val fm = supportFragmentManager
|
||||||
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false)
|
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false)
|
||||||
fragment.arguments = pref.extras
|
fragment.arguments = pref.extras
|
||||||
fragment.setTargetFragment(caller, 0)
|
// fragment.setTargetFragment(caller, 0)
|
||||||
openFragment(fragment)
|
openFragment(fragment)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openMangaSourceSettings(mangaSource: MangaSource) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
openFragment(SourceSettingsFragment.newInstance(mangaSource))
|
binding.appbar.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
)
|
||||||
|
binding.container.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openNotificationSettingsLegacy() {
|
fun openFragment(fragment: Fragment) {
|
||||||
openFragment(NotificationSettingsLegacyFragment())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openFragment(fragment: Fragment) {
|
|
||||||
supportFragmentManager.commit {
|
supportFragmentManager.commit {
|
||||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
|
|
||||||
replace(R.id.container, fragment)
|
|
||||||
setReorderingAllowed(true)
|
setReorderingAllowed(true)
|
||||||
|
replace(R.id.container, fragment)
|
||||||
|
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||||
addToBackStack(null)
|
addToBackStack(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
private fun openDefaultFragment() {
|
||||||
|
val fragment = when (intent?.action) {
|
||||||
|
ACTION_READER -> ReaderSettingsFragment()
|
||||||
|
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
|
||||||
|
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
|
||||||
|
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL
|
||||||
|
)
|
||||||
|
else -> SettingsHeadersFragment()
|
||||||
|
}
|
||||||
|
supportFragmentManager.commit {
|
||||||
|
setReorderingAllowed(true)
|
||||||
|
replace(R.id.container, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
|
||||||
|
private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
|
||||||
|
private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
|
||||||
|
private const val EXTRA_SOURCE = "source"
|
||||||
|
|
||||||
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)
|
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)
|
||||||
|
|
||||||
|
fun newReaderSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_READER)
|
||||||
|
|
||||||
|
fun newSuggestionsSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_SUGGESTIONS)
|
||||||
|
|
||||||
|
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_SOURCE)
|
||||||
|
.putExtra(EXTRA_SOURCE, source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentTransaction
|
||||||
|
import androidx.fragment.app.commit
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import androidx.preference.PreferenceHeaderFragmentCompat
|
||||||
|
import androidx.slidingpanelayout.widget.SlidingPaneLayout
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLayout.PanelSlideListener {
|
||||||
|
|
||||||
|
private var currentTitle: CharSequence? = null
|
||||||
|
|
||||||
|
override fun onCreatePreferenceHeader(): PreferenceFragmentCompat = RootSettingsFragment()
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
slidingPaneLayout.addPanelSlideListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPanelSlide(panel: View, slideOffset: Float) = Unit
|
||||||
|
|
||||||
|
override fun onPanelOpened(panel: View) {
|
||||||
|
activity?.title = currentTitle ?: getString(R.string.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPanelClosed(panel: View) {
|
||||||
|
activity?.setTitle(R.string.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTitle(title: CharSequence?) {
|
||||||
|
currentTitle = title
|
||||||
|
if (slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen) {
|
||||||
|
activity?.title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFragment(fragment: Fragment) {
|
||||||
|
childFragmentManager.commit {
|
||||||
|
setReorderingAllowed(true)
|
||||||
|
replace(androidx.preference.R.id.preferences_detail, fragment)
|
||||||
|
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||||
|
addToBackStack(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ val settingsModule
|
|||||||
|
|
||||||
single { BackupRepository(get()) }
|
single { BackupRepository(get()) }
|
||||||
single { RestoreRepository(get()) }
|
single { RestoreRepository(get()) }
|
||||||
single { AppSettings(androidContext()) }
|
single(createdAtStart = true) { AppSettings(androidContext()) }
|
||||||
|
|
||||||
viewModel { BackupViewModel(get(), androidContext()) }
|
viewModel { BackupViewModel(get(), androidContext()) }
|
||||||
viewModel { params ->
|
viewModel { params ->
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
activity?.title = source.title
|
setTitle(source.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import android.content.SharedPreferences
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference
|
||||||
|
import org.koitharu.kotatsu.settings.utils.TagsAutoCompleteProvider
|
||||||
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||||
|
|
||||||
@@ -23,6 +26,11 @@ class SuggestionsSettingsFragment : BasePreferenceFragment(R.string.suggestions)
|
|||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
addPreferencesFromResource(R.xml.pref_suggestions)
|
addPreferencesFromResource(R.xml.pref_suggestions)
|
||||||
|
|
||||||
|
findPreference<MultiAutoCompleteTextViewPreference>(AppSettings.KEY_SUGGESTIONS_EXCLUDE_TAGS)?.run {
|
||||||
|
autoCompleteProvider = TagsAutoCompleteProvider(get())
|
||||||
|
summaryProvider = MultiAutoCompleteTextViewPreference.SimpleSummaryProvider(summary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_ch
|
|||||||
append(getString(R.string.read_more))
|
append(getString(R.string.read_more))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
warningPreference
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +42,10 @@ class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_ch
|
|||||||
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
|
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
|
||||||
.putExtra(Settings.EXTRA_CHANNEL_ID, TrackWorker.CHANNEL_ID)
|
.putExtra(Settings.EXTRA_CHANNEL_ID, TrackWorker.CHANNEL_ID)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
(activity as? SettingsActivity)?.openNotificationSettingsLegacy()
|
super.onPreferenceTreeClick(preference)
|
||||||
}
|
}
|
||||||
true
|
|
||||||
}
|
}
|
||||||
else -> super.onPreferenceTreeClick(preference)
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.settings.about
|
package org.koitharu.kotatsu.settings.about
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import androidx.core.net.toUri
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.settings.AppUpdateChecker
|
import org.koitharu.kotatsu.settings.AppUpdateChecker
|
||||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||||
@@ -16,20 +16,16 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
|||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
addPreferencesFromResource(R.xml.pref_about)
|
addPreferencesFromResource(R.xml.pref_about)
|
||||||
}
|
val isUpdateSupported = AppUpdateChecker.isUpdateSupported(requireContext())
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
findPreference<Preference>(AppSettings.KEY_APP_UPDATE_AUTO)?.run {
|
findPreference<Preference>(AppSettings.KEY_APP_UPDATE_AUTO)?.run {
|
||||||
isVisible = AppUpdateChecker.isUpdateSupported(context)
|
isVisible = isUpdateSupported
|
||||||
}
|
}
|
||||||
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
|
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
|
||||||
title = getString(R.string.app_version, BuildConfig.VERSION_NAME)
|
title = getString(R.string.app_version, BuildConfig.VERSION_NAME)
|
||||||
isEnabled = AppUpdateChecker.isUpdateSupported(context)
|
isEnabled = isUpdateSupported
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
return when (preference.key) {
|
return when (preference.key) {
|
||||||
AppSettings.KEY_APP_VERSION -> {
|
AppSettings.KEY_APP_VERSION -> {
|
||||||
@@ -37,39 +33,19 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
AppSettings.KEY_APP_TRANSLATION -> {
|
AppSettings.KEY_APP_TRANSLATION -> {
|
||||||
startActivity(context?.let { BrowserActivity.newIntent(it,
|
openLink(getString(R.string.url_weblate), preference.title)
|
||||||
"https://hosted.weblate.org/engage/kotatsu",
|
|
||||||
resources.getString(R.string.about_app_translation)) })
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
AppSettings.KEY_FEEDBACK_4PDA -> {
|
AppSettings.KEY_FEEDBACK_4PDA -> {
|
||||||
startActivity(context?.let { BrowserActivity.newIntent(it,
|
openLink(getString(R.string.url_forpda), preference.title)
|
||||||
"https://4pda.to/forum/index.php?showtopic=697669",
|
|
||||||
resources.getString(R.string.about_feedback_4pda)) })
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
AppSettings.KEY_FEEDBACK_DISCORD -> {
|
AppSettings.KEY_FEEDBACK_DISCORD -> {
|
||||||
startActivity(context?.let { BrowserActivity.newIntent(it,
|
openLink(getString(R.string.url_discord), preference.title)
|
||||||
"https://discord.gg/NNJ5RgVBC5",
|
|
||||||
"Discord") })
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
AppSettings.KEY_FEEDBACK_GITHUB -> {
|
AppSettings.KEY_FEEDBACK_GITHUB -> {
|
||||||
startActivity(context?.let { BrowserActivity.newIntent(it,
|
openLink(getString(R.string.url_github_issues), preference.title)
|
||||||
"https://github.com/nv95/Kotatsu/issues",
|
|
||||||
"GitHub") })
|
|
||||||
true
|
|
||||||
}
|
|
||||||
AppSettings.KEY_SUPPORT_DEVELOPER -> {
|
|
||||||
startActivity(context?.let { BrowserActivity.newIntent(it,
|
|
||||||
"https://yoomoney.ru/to/410012543938752",
|
|
||||||
resources.getString(R.string.about_support_developer)) })
|
|
||||||
true
|
|
||||||
}
|
|
||||||
AppSettings.KEY_APP_GRATITUDES -> {
|
|
||||||
startActivity(context?.let { BrowserActivity.newIntent(it,
|
|
||||||
"https://github.com/nv95/Kotatsu/graphs/contributors",
|
|
||||||
resources.getString(R.string.about_gratitudes)) })
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onPreferenceTreeClick(preference)
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
@@ -95,4 +71,16 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private fun openLink(url: String, title: CharSequence?) {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.data = url.toUri()
|
||||||
|
startActivity(
|
||||||
|
if (title != null) {
|
||||||
|
Intent.createChooser(intent, title)
|
||||||
|
} else {
|
||||||
|
intent
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings.about
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.SpannableStringBuilder
|
|
||||||
import android.text.method.LinkMovementMethod
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.text.HtmlCompat
|
|
||||||
import androidx.core.text.parseAsHtml
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
|
||||||
import org.koitharu.kotatsu.databinding.FragmentCopyrightBinding
|
|
||||||
|
|
||||||
class LicenseFragment : BaseFragment<FragmentCopyrightBinding>() {
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
binding.textView.apply {
|
|
||||||
text =
|
|
||||||
SpannableStringBuilder(resources.openRawResource(R.raw.copyright).bufferedReader()
|
|
||||||
.readText()
|
|
||||||
.parseAsHtml(HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST))
|
|
||||||
movementMethod = LinkMovementMethod.getInstance()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInflateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?
|
|
||||||
) = FragmentCopyrightBinding.inflate(inflater, container, false)
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
activity?.setTitle(R.string.about_license)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.backup
|
||||||
|
|
||||||
|
import android.app.backup.BackupAgent
|
||||||
|
import android.app.backup.BackupDataInput
|
||||||
|
import android.app.backup.BackupDataOutput
|
||||||
|
import android.app.backup.FullBackupDataOutput
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.core.backup.BackupArchive
|
||||||
|
import org.koitharu.kotatsu.core.backup.BackupEntry
|
||||||
|
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.backup.RestoreRepository
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import java.io.*
|
||||||
|
|
||||||
|
class AppBackupAgent : BackupAgent() {
|
||||||
|
|
||||||
|
override fun onBackup(
|
||||||
|
oldState: ParcelFileDescriptor?,
|
||||||
|
data: BackupDataOutput?,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onRestore(
|
||||||
|
data: BackupDataInput?,
|
||||||
|
appVersionCode: Int,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||||
|
super.onFullBackup(data)
|
||||||
|
val file = createBackupFile()
|
||||||
|
try {
|
||||||
|
fullBackupFile(file, data)
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreFile(
|
||||||
|
data: ParcelFileDescriptor,
|
||||||
|
size: Long,
|
||||||
|
destination: File?,
|
||||||
|
type: Int,
|
||||||
|
mode: Long,
|
||||||
|
mtime: Long
|
||||||
|
) {
|
||||||
|
if (destination?.name?.endsWith(".bak") == true) {
|
||||||
|
restoreBackupFile(data.fileDescriptor, size)
|
||||||
|
destination.delete()
|
||||||
|
} else {
|
||||||
|
super.onRestoreFile(data, size, destination, type, mode, mtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBackupFile() = runBlocking {
|
||||||
|
val repository = BackupRepository(MangaDatabase.create(applicationContext))
|
||||||
|
val backup = BackupArchive.createNew(this@AppBackupAgent)
|
||||||
|
backup.put(repository.createIndex())
|
||||||
|
backup.put(repository.dumpHistory())
|
||||||
|
backup.put(repository.dumpCategories())
|
||||||
|
backup.put(repository.dumpFavourites())
|
||||||
|
backup.flush()
|
||||||
|
backup.cleanup()
|
||||||
|
backup.file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
|
||||||
|
val repository = RestoreRepository(MangaDatabase.create(applicationContext))
|
||||||
|
val tempFile = File.createTempFile("backup_", ".tmp")
|
||||||
|
FileInputStream(fd).use { input ->
|
||||||
|
tempFile.outputStream().use { output ->
|
||||||
|
input.copyLimitedTo(output, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val backup = BackupArchive(tempFile)
|
||||||
|
try {
|
||||||
|
runBlocking {
|
||||||
|
backup.unpack()
|
||||||
|
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
|
||||||
|
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
|
||||||
|
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runBlocking(NonCancellable) {
|
||||||
|
backup.cleanup()
|
||||||
|
}
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.copyLimitedTo(out: OutputStream, limit: Long) {
|
||||||
|
var bytesCopied: Long = 0
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE.coerceAtMost(limit.toInt()))
|
||||||
|
var bytes = read(buffer)
|
||||||
|
while (bytes >= 0) {
|
||||||
|
out.write(buffer, 0, bytes)
|
||||||
|
bytesCopied += bytes
|
||||||
|
val bytesLeft = (limit - bytesCopied).toInt()
|
||||||
|
if (bytesLeft <= 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bytes = read(buffer, 0, buffer.size.coerceAtMost(bytesLeft))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.MutableLiveData
|
|||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
|
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
|
||||||
import org.koitharu.kotatsu.utils.ext.map
|
import org.koitharu.kotatsu.utils.ext.map
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
|||||||
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
|
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
|
||||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
||||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
|
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
|
||||||
|
import org.koitharu.kotatsu.settings.SourceSettingsFragment
|
||||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
|
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
|
||||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||||
@@ -87,7 +89,9 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
|
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
|
||||||
(activity as? SettingsActivity)?.openMangaSourceSettings(item.source)
|
val fragment = SourceSettingsFragment.newInstance(item.source)
|
||||||
|
(parentFragment as? SettingsHeadersFragment)?.openFragment(fragment)
|
||||||
|
?: (activity as? SettingsActivity)?.openFragment(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.Filter
|
||||||
|
import android.widget.MultiAutoCompleteTextView
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.MainThread
|
||||||
|
import androidx.annotation.StyleRes
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||||
|
|
||||||
|
class MultiAutoCompleteTextViewPreference @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = R.attr.multiAutoCompleteTextViewPreferenceStyle,
|
||||||
|
@StyleRes defStyleRes: Int = R.style.Preference_MultiAutoCompleteTextView,
|
||||||
|
) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
|
||||||
|
|
||||||
|
private val autoCompleteBindListener = AutoCompleteBindListener()
|
||||||
|
|
||||||
|
var autoCompleteProvider: AutoCompleteProvider? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
super.setOnBindEditTextListener(autoCompleteBindListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) {
|
||||||
|
autoCompleteBindListener.delegate = onBindEditTextListener
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class AutoCompleteBindListener : OnBindEditTextListener {
|
||||||
|
|
||||||
|
var delegate: OnBindEditTextListener? = null
|
||||||
|
|
||||||
|
override fun onBindEditText(editText: EditText) {
|
||||||
|
delegate?.onBindEditText(editText)
|
||||||
|
if (editText !is MultiAutoCompleteTextView) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editText.setTokenizer(MultiAutoCompleteTextView.CommaTokenizer())
|
||||||
|
editText.setAdapter(
|
||||||
|
autoCompleteProvider?.let {
|
||||||
|
CompletionAdapter(editText.context, it, ArrayList())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
editText.threshold = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoCompleteProvider {
|
||||||
|
|
||||||
|
suspend fun getSuggestions(query: String): List<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
class SimpleSummaryProvider(
|
||||||
|
private val emptySummary: CharSequence?,
|
||||||
|
) : SummaryProvider<MultiAutoCompleteTextViewPreference> {
|
||||||
|
|
||||||
|
override fun provideSummary(preference: MultiAutoCompleteTextViewPreference): CharSequence? {
|
||||||
|
return if (preference.text.isNullOrEmpty()) {
|
||||||
|
emptySummary
|
||||||
|
} else {
|
||||||
|
preference.text?.trimEnd(' ', ',')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CompletionAdapter(
|
||||||
|
context: Context,
|
||||||
|
private val completionProvider: AutoCompleteProvider,
|
||||||
|
private val dataset: MutableList<String>,
|
||||||
|
) : ArrayAdapter<String>(context, android.R.layout.simple_dropdown_item_1line, dataset) {
|
||||||
|
|
||||||
|
override fun getFilter(): Filter {
|
||||||
|
return CompletionFilter(this, completionProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishResults(results: List<String>) {
|
||||||
|
dataset.replaceWith(results)
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CompletionFilter(
|
||||||
|
private val adapter: CompletionAdapter,
|
||||||
|
private val provider: AutoCompleteProvider,
|
||||||
|
) : Filter() {
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||||
|
val query = constraint?.toString().orEmpty()
|
||||||
|
val suggestions = runBlocking { provider.getSuggestions(query) }
|
||||||
|
return CompletionResults(suggestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
|
||||||
|
val completions = (results as CompletionResults).completions
|
||||||
|
adapter.publishResults(completions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CompletionResults(
|
||||||
|
val completions: List<String>,
|
||||||
|
) : FilterResults() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
values = completions
|
||||||
|
count = completions.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user