Compare commits

...

59 Commits

Author SHA1 Message Date
d7181e35e7 Update dependencies 2020-09-05 19:37:40 +03:00
55d824bb94 Remove header from drawer 2020-08-31 19:40:58 +03:00
229d9fa2ae Update launcher icon 2020-08-31 19:37:14 +03:00
3eb68e1ff9 Manual screen rotation in reader 2020-08-28 21:01:59 +03:00
b103589bba Fix unsupported image formats in reader 2020-08-27 19:54:53 +03:00
0726c037a4 Update dependencies 2020-08-26 16:14:32 +03:00
0ff64931e0 Close drawer on back pressed 2020-08-09 16:54:00 +03:00
2374c96009 Fix source preferences summaries 2020-07-19 11:44:12 +03:00
2dd51117e9 Remove unused resources 2020-07-16 19:27:59 +03:00
6c5f3c7d97 Fix readmanga search 2020-07-16 19:20:59 +03:00
626bb20edb Fix global search 2020-07-12 13:48:52 +03:00
d363869dab Fix cbz thumbnails 2020-07-10 07:22:16 +03:00
774f33c63d Get remote manga for local in tracker 2020-07-10 07:14:41 +03:00
079427346a Update dependencies 2020-07-10 07:09:41 +03:00
a1a3125834 Fix crash on manga downloading 2020-07-06 20:05:41 +03:00
fc9c8f8a79 Update readme 2020-07-05 17:30:44 +03:00
c06923dbdf Merge branch 'devel' 2020-07-05 17:14:37 +03:00
66ca51cc73 Fix MangaTown endless search 2020-07-05 17:09:38 +03:00
bf45480366 Update henchan default domain 2020-07-05 17:01:43 +03:00
28618e394e Fix *chan search 2020-07-05 16:59:20 +03:00
9762a466ce Fix Mangalib pages loading 2020-07-05 16:45:34 +03:00
367a97a95b Fix page error processing 2020-07-01 19:37:15 +03:00
c3ab197aa0 Fix some StrictMode warnings 2020-07-01 19:17:08 +03:00
a0aa33a499 Option to clear updates feed 2020-06-29 13:43:01 +03:00
b27bc86141 Fix search history preference 2020-06-29 13:28:25 +03:00
84ef2af82f Fix MangaDetailsPresenter sharing 2020-06-29 13:26:28 +03:00
a2f09d8763 Restore download after network error 2020-06-29 09:48:17 +03:00
79058440a1 Fix grid span count #11 2020-06-25 19:40:50 +03:00
7f9cfdbf7a Show app update in dialog 2020-06-20 12:10:42 +03:00
85f7477450 Cleaning up traks 2020-06-20 11:35:42 +03:00
0e08d75626 Update dependencies 2020-06-14 17:12:30 +03:00
1b4a65f476 Update pages thumbnails list 2020-06-11 19:59:54 +03:00
2e69395ade Update ui 2020-06-08 19:22:56 +03:00
3f61f13b7b Show related manga 2020-06-07 20:14:44 +03:00
10a0f0ad53 Fix empty tracker log records 2020-06-05 19:25:10 +03:00
680fc66f21 Tracker fixes 2020-06-03 18:52:00 +03:00
e01b74ee3d Pagination loading indicator 2020-05-31 12:10:43 +03:00
3539e6a892 Global search 2020-05-30 11:18:14 +03:00
ff56f5a343 Fix updates feed 2020-05-30 10:25:48 +03:00
9ce43a39c8 Global search 2020-05-30 09:48:04 +03:00
0e3aa3f380 Update readme 2020-05-24 13:25:38 +03:00
7927bf0c9a Refactor 2020-05-24 12:58:17 +03:00
aec2d71688 Manga updates feed 2020-05-22 20:28:14 +03:00
140a0f4d66 Log manga tracking 2020-05-22 19:20:08 +03:00
7cf57535ab Update dependencies 2020-05-21 21:27:21 +03:00
31fe924157 Merge branch 'master' into devel 2020-05-21 21:20:39 +03:00
6d193baa69 Increment version 2020-05-21 20:48:01 +03:00
3bd7b54405 Capitalize MangaLib genres 2020-05-21 20:47:10 +03:00
d99450c5a3 Prepopulate favourite categories 2020-05-21 20:43:24 +03:00
6444122c0a Update database: add tracklogs table 2020-05-21 20:30:10 +03:00
fe14ccb5ec Update readme 2020-05-20 20:05:51 +03:00
e38e5fdf0f Small enhancements 2020-05-20 19:51:28 +03:00
c1c2b11bd8 Show local manga size 2020-05-20 19:31:17 +03:00
7d147b3c37 Check wakelock is held in download service 2020-05-20 19:04:31 +03:00
260ff32cd1 Detect chapters in cbz if index missing 2020-05-20 18:59:30 +03:00
ccc5f3e423 Fix webtoon scroll 2020-05-17 17:25:18 +03:00
8b32a60743 Misc fixes 2020-05-16 10:20:55 +03:00
c1faf2fe06 Remember last opened section 2020-05-16 09:07:09 +03:00
3588270742 Show favourites by categories and manager categories order 2020-05-16 08:49:34 +03:00
245 changed files with 3093 additions and 1260 deletions

4
.idea/compiler.xml generated
View File

@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel> <bytecodeTargetLevel target="11" />
<module name="Kotatsu.app" target="1.8" />
</bytecodeTargetLevel>
</component> </component>
</project> </project>

View File

@@ -3,6 +3,7 @@
<words> <words>
<w>chucker</w> <w>chucker</w>
<w>desu</w> <w>desu</w>
<w>failsafe</w>
<w>koin</w> <w>koin</w>
<w>kotatsu</w> <w>kotatsu</w>
<w>manga</w> <w>manga</w>

2
.idea/misc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="false" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

@@ -8,7 +8,7 @@ Kotatsu is a free and open source manga reader for Android.
Latest release: [get here](https://github.com/nv95/Kotatsu/releases/latest) Latest release: [get here](https://github.com/nv95/Kotatsu/releases/latest)
Legacy build (Android 4.1+): [available here](https://github.com/nv95/Kotatsu/releases/tag/v0.3-legacy) Legacy build (Android 4.1+): [available here](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy)
### Main Features ### Main Features
@@ -21,6 +21,8 @@ Legacy build (Android 4.1+): [available here](https://github.com/nv95/Kotatsu/re
* Reading third-party comics from CBZ * Reading third-party comics from CBZ
* Standard and Webtoon-optimized reader * Standard and Webtoon-optimized reader
* Notifications about new chapters * Notifications about new chapters
* Updates feed
* Global search
### Screenshots ### Screenshots
@@ -39,4 +41,4 @@ published by the Free Software Foundation, either version 3 of the License, or
### Disclaimer ### Disclaimer
The developers of this application does not have any affiliation with the content providers available. The developers of this application does not have any affiliation with the content providers available.

View File

@@ -6,7 +6,6 @@ plugins {
} }
def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger() def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger()
def gitBranch = 'git branch --show-current'.execute([], rootDir).text.trim()
android { android {
compileSdkVersion 29 compileSdkVersion 29
@@ -17,9 +16,7 @@ android {
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 29 targetSdkVersion 29
versionCode gitCommits versionCode gitCommits
versionName '0.3.2' versionName '0.5.1'
buildConfigField 'String', 'GIT_BRANCH', "\"${gitBranch}\""
kapt { kapt {
arguments { arguments {
@@ -31,9 +28,6 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
} }
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix = '.debug' applicationIdSuffix = '.debug'
@@ -57,24 +51,31 @@ android {
androidExtensions { androidExtensions {
experimental = true experimental = true
} }
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += ['-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-Xjvm-default=all']
}
}
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.5' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.5' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
implementation 'androidx.core:core-ktx:1.3.0-rc01' implementation 'androidx.core:core-ktx:1.5.0-alpha02'
implementation 'androidx.appcompat:appcompat:1.2.0-beta01' implementation 'androidx.activity:activity-ktx:1.2.0-alpha08'
implementation 'androidx.activity:activity-ktx:1.2.0-alpha04' implementation 'androidx.fragment:fragment-ktx:1.3.0-alpha08'
implementation 'androidx.fragment:fragment-ktx:1.3.0-alpha04' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha07'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha02' implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta5' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01' implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha05'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha03'
implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01' implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.preference:preference-ktx:1.1.1' implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.3.4' implementation 'androidx.work:work-runtime-ktx:2.4.0'
implementation 'com.google.android.material:material:1.2.0-alpha06' implementation 'com.google.android.material:material:1.3.0-alpha02'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.0-alpha07'
implementation 'androidx.room:room-runtime:2.2.5' implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5' implementation 'androidx.room:room-ktx:2.2.5'
@@ -86,12 +87,12 @@ dependencies {
implementation 'com.github.moxy-community:moxy-ktx:2.1.2' implementation 'com.github.moxy-community:moxy-ktx:2.1.2'
kapt 'com.github.moxy-community:moxy-compiler:2.1.2' kapt 'com.github.moxy-community:moxy-compiler:2.1.2'
implementation 'com.squareup.okhttp3:okhttp:4.6.0' implementation 'com.squareup.okhttp3:okhttp:4.8.1'
implementation 'com.squareup.okio:okio:2.6.0' implementation 'com.squareup.okio:okio:2.8.0'
implementation 'org.jsoup:jsoup:1.13.1' implementation 'org.jsoup:jsoup:1.13.1'
implementation 'org.koin:koin-android:2.1.5' implementation 'org.koin:koin-android:2.2.0-beta-1'
implementation 'io.coil-kt:coil:0.10.1' implementation 'io.coil-kt:coil:1.0.0-rc2'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
implementation 'com.tomclaw.cache:cache:1.0' implementation 'com.tomclaw.cache:cache:1.0'
@@ -100,5 +101,5 @@ dependencies {
releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.2.0' releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.2.0'
testImplementation 'junit:junit:4.13' testImplementation 'junit:junit:4.13'
testImplementation 'org.json:json:20190722' testImplementation 'org.json:json:20200518'
} }

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
android:tint="#E6000A">
<group android:scaleX="0.40188664"
android:scaleY="0.40188664"
android:translateX="32.90095"
android:translateY="18.7272">
<group android:translateY="139.39206">
<path android:pathData="M83.796875,-0L105.6875,-0L60.765625,-55.828125L103.09375,-101L82.078125,-101L32.25,-49.1875L32.25,-101L13.53125,-101L13.53125,-0L32.25,-0L32.25,-25.8125L48.234375,-42.265625L83.796875,-0Z"
android:fillColor="#E6000A"/>
</group>
</group>
</vector>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -23,7 +23,7 @@
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<activity android:name=".ui.main.MainActivity"> <activity android:name=".ui.list.MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -59,7 +59,7 @@
android:theme="@android:style/Theme.DeviceDefault.Dialog" android:theme="@android:style/Theme.DeviceDefault.Dialog"
android:windowSoftInputMode="stateAlwaysHidden" /> android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
android:name=".ui.main.list.favourites.categories.CategoriesActivity" android:name="org.koitharu.kotatsu.ui.list.favourites.categories.CategoriesActivity"
android:label="@string/favourites_categories" android:label="@string/favourites_categories"
android:windowSoftInputMode="stateAlwaysHidden" /> android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
@@ -69,6 +69,8 @@
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".ui.search.global.GlobalSearchActivity"
android:label="@string/search" />
<service <service
android:name=".ui.download.DownloadService" android:name=".ui.download.DownloadService"

View File

@@ -1,11 +1,12 @@
package org.koitharu.kotatsu package org.koitharu.kotatsu
import android.app.Application import android.app.Application
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room import androidx.room.Room
import coil.Coil import coil.Coil
import coil.ComponentRegistry import coil.ComponentRegistry
import coil.ImageLoaderBuilder import coil.ImageLoader
import coil.util.CoilUtils import coil.util.CoilUtils
import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor import com.chuckerteam.chucker.api.ChuckerInterceptor
@@ -13,16 +14,17 @@ import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.logger.Level
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.DatabasePrePopulateCallback
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.local.CbzFetcher import org.koitharu.kotatsu.core.local.CbzFetcher
import org.koitharu.kotatsu.core.local.PagesCache import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar
import org.koitharu.kotatsu.core.local.cookies.cache.SetCookieCache import org.koitharu.kotatsu.core.local.cookies.cache.SetCookieCache
import org.koitharu.kotatsu.core.local.cookies.persistence.SharedPrefsCookiePersistor import org.koitharu.kotatsu.core.local.cookies.persistence.SharedPrefsCookiePersistor
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.parser.UserAgentInterceptor import org.koitharu.kotatsu.core.parser.UserAgentInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaLoaderContext import org.koitharu.kotatsu.domain.MangaLoaderContext
@@ -45,6 +47,19 @@ class KotatsuApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build())
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog()
.build())
}
initKoin() initKoin()
initCoil() initCoil()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext)) Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
@@ -59,7 +74,7 @@ class KotatsuApp : Application() {
private fun initKoin() { private fun initKoin() {
startKoin { startKoin {
androidLogger() androidLogger(Level.ERROR)
androidContext(applicationContext) androidContext(applicationContext)
modules( modules(
module { module {
@@ -74,7 +89,7 @@ class KotatsuApp : Application() {
single { single {
MangaLoaderContext() MangaLoaderContext()
} }
factory { single {
AppSettings(applicationContext) AppSettings(applicationContext)
} }
single { single {
@@ -87,7 +102,7 @@ class KotatsuApp : Application() {
private fun initCoil() { private fun initCoil() {
Coil.setImageLoader( Coil.setImageLoader(
ImageLoaderBuilder(applicationContext) ImageLoader.Builder(applicationContext)
.okHttpClient( .okHttpClient(
okHttp() okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext)) .cache(CoilUtils.createDefaultCache(applicationContext))
@@ -124,5 +139,6 @@ class KotatsuApp : Application() {
applicationContext, applicationContext,
MangaDatabase::class.java, MangaDatabase::class.java,
"kotatsu-db" "kotatsu-db"
).addMigrations(Migration1To2, Migration2To3, Migration3To4) ).addMigrations(Migration1To2, Migration2To3, Migration3To4, Migration4To5, Migration5To6)
.addCallback(DatabasePrePopulateCallback(resources))
} }

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.db
import android.content.res.Resources
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.R
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title) VALUES (?,?,?)",
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later))
)
}
}

View File

@@ -9,8 +9,8 @@ import org.koitharu.kotatsu.core.db.entity.FavouriteCategoryEntity
@Dao @Dao
abstract class FavouriteCategoriesDao { abstract class FavouriteCategoriesDao {
@Query("SELECT category_id,title,created_at FROM favourite_categories ORDER BY :orderBy") @Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract suspend fun findAll(orderBy: String): List<FavouriteCategoryEntity> abstract suspend fun findAll(): List<FavouriteCategoryEntity>
@Insert(onConflict = OnConflictStrategy.ABORT) @Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@@ -20,4 +20,14 @@ abstract class FavouriteCategoriesDao {
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id") @Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String) abstract suspend fun update(id: Long, title: String)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun update(id: Long, sortKey: Int)
@Query("SELECT MAX(sort_key) FROM favourite_categories")
protected abstract suspend fun getMaxSortKey(): Int?
suspend fun getNextSortKey(): Int {
return (getMaxSortKey() ?: 0) + 1
}
} }

View File

@@ -8,10 +8,18 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Dao @Dao
abstract class FavouritesDao { abstract class FavouritesDao {
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at")
abstract suspend fun findAll(): List<FavouriteManga>
@Transaction @Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset") @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga> abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at")
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
@Transaction @Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset") @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga> abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>

View File

@@ -7,8 +7,9 @@ import org.koitharu.kotatsu.core.db.entity.*
@Database( @Database(
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
], version = 4 TrackEntity::class, TrackLogEntity::class
], version = 6
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
@@ -25,4 +26,6 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val favouriteCategoriesDao: FavouriteCategoriesDao abstract val favouriteCategoriesDao: FavouriteCategoriesDao
abstract val tracksDao: TracksDao abstract val tracksDao: TracksDao
abstract val trackLogsDao: TrackLogsDao
} }

View File

@@ -4,19 +4,19 @@ import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao @Dao
interface TagsDao { abstract class TagsDao {
@Query("SELECT * FROM tags") @Query("SELECT * FROM tags")
suspend fun getAllTags(): List<TagEntity> abstract suspend fun getAllTags(): List<TagEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(tag: TagEntity): Long abstract suspend fun insert(tag: TagEntity): Long
@Update(onConflict = OnConflictStrategy.IGNORE) @Update(onConflict = OnConflictStrategy.IGNORE)
suspend fun update(tag: TagEntity): Int abstract suspend fun update(tag: TagEntity): Int
@Transaction @Transaction
suspend fun upsert(tags: Iterable<TagEntity>) { open suspend fun upsert(tags: Iterable<TagEntity>) {
tags.forEach { tag -> tags.forEach { tag ->
if (update(tag) <= 0) { if (update(tag) <= 0) {
insert(tag) insert(tag)

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.core.db
import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
import org.koitharu.kotatsu.core.db.entity.TrackLogWithManga
@Dao
interface TrackLogsDao {
@Transaction
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
suspend fun findAll(offset: Int, limit: Int): List<TrackLogWithManga>
@Query("DELETE FROM track_logs")
suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: TrackLogEntity): Long
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
suspend fun removeAll(mangaId: Long)
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun cleanup()
@Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int
}

View File

@@ -25,11 +25,13 @@ abstract class TracksDao {
@Query("DELETE FROM tracks WHERE manga_id = :mangaId") @Query("DELETE FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long) abstract suspend fun delete(mangaId: Long)
@Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)")
abstract suspend fun cleanup()
@Transaction @Transaction
open suspend fun upsert(entity: TrackEntity) { open suspend fun upsert(entity: TrackEntity) {
if (update(entity) == 0) { if (update(entity) == 0) {
insert(entity) insert(entity)
} }
} }
} }

View File

@@ -11,12 +11,14 @@ data class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int, @ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String @ColumnInfo(name = "title") val title: String
) { ) {
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory( fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
id = id ?: categoryId.toLong(), id = id ?: categoryId.toLong(),
title = title, title = title,
sortKey = sortKey,
createdAt = Date(createdAt) createdAt = Date(createdAt)
) )
} }

View File

@@ -32,6 +32,6 @@ data class HistoryEntity(
updatedAt = Date(updatedAt), updatedAt = Date(updatedAt),
chapterId = chapterId, chapterId = chapterId,
page = page, page = page,
scroll = scroll scroll = scroll.toInt()
) )
} }

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "track_logs", foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class TrackLogEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "chapters") val chapters: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.model.TrackingLogItem
import java.util.*
data class TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id"
)
val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
) {
fun toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.map { x -> x.toMangaTag() }.toSet()),
createdAt = Date(trackLog.createdAt)
)
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration4To5 : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration5To6 : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
}
}

View File

@@ -1,5 +1,3 @@
package org.koitharu.kotatsu.core.exceptions package org.koitharu.kotatsu.core.exceptions
import java.lang.NullPointerException
class MangaNotFoundException(s: String? = null) : RuntimeException(s) class MangaNotFoundException(s: String? = null) : RuntimeException(s)

View File

@@ -9,5 +9,6 @@ data class AppVersion(
val name: String, val name: String,
val url: String, val url: String,
val apkSize: Long, val apkSize: Long,
val apkUrl: String val apkUrl: String,
val description: String
) : Parcelable ) : Parcelable

View File

@@ -22,7 +22,8 @@ class GithubRepository : KoinComponent {
url = json.getString("html_url"), url = json.getString("html_url"),
name = json.getString("name").removePrefix("v"), name = json.getString("name").removePrefix("v"),
apkSize = asset.getLong("size"), apkSize = asset.getLong("size"),
apkUrl = asset.getString("browser_download_url") apkUrl = asset.getString("browser_download_url"),
description = json.getString("body")
) )
} }
} }

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.local
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.bitmappool.BitmapPool import coil.bitmap.BitmapPool
import coil.decode.DataSource import coil.decode.DataSource
import coil.decode.Options import coil.decode.Options
import coil.fetch.FetchResult import coil.fetch.FetchResult

View File

@@ -37,7 +37,7 @@ class SetCookieCache : CookieCache {
override fun iterator(): MutableIterator<Cookie> = SetCookieCacheIterator() override fun iterator(): MutableIterator<Cookie> = SetCookieCacheIterator()
private inner class SetCookieCacheIterator() : MutableIterator<Cookie> { private inner class SetCookieCacheIterator : MutableIterator<Cookie> {
private val iterator = cookies.iterator() private val iterator = cookies.iterator()

View File

@@ -8,5 +8,6 @@ import java.util.*
data class FavouriteCategory( data class FavouriteCategory(
val id: Long, val id: Long,
val title: String, val title: String,
val sortKey: Int,
val createdAt: Date val createdAt: Date
) : Parcelable ) : Parcelable

View File

@@ -10,5 +10,5 @@ data class MangaHistory(
val updatedAt: Date, val updatedAt: Date,
val chapterId: Long, val chapterId: Long,
val page: Int, val page: Int,
val scroll: Float val scroll: Int
) : Parcelable ) : Parcelable

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import java.util.*
@Parcelize
data class TrackingLogItem (
val id: Long,
val manga: Manga,
val chapters: List<String>,
val createdAt: Date
): Parcelable

View File

@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.collection.ArraySet
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
@@ -41,69 +44,84 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val file = Uri.parse(chapter.url).toFile() val uri = Uri.parse(chapter.url)
val file = uri.toFile()
val zip = ZipFile(file) val zip = ZipFile(file)
val pattern = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex) val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
?.getChapterNamesPattern(chapter) var entries = zip.entries().asSequence()
val entries = if (pattern != null) { entries = if (index != null) {
zip.entries().asSequence() val pattern = index.getChapterNamesPattern(chapter)
.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
} else { } else {
zip.entries().asSequence().filter { x -> !x.isDirectory } val parent = uri.fragment.orEmpty()
}.toList().sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) entries.filter { x ->
return entries.map { x -> !x.isDirectory && x.name.substringBeforeLast(
val uri = zipUri(file, x.name) File.separatorChar,
MangaPage( ""
id = uri.longHashCode(), ) == parent
url = uri, }
source = MangaSource.LOCAL
)
} }
return entries
.toList()
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
.map { x ->
val entryUri = zipUri(file, x.name)
MangaPage(
id = entryUri.longHashCode(),
url = entryUri,
source = MangaSource.LOCAL
)
}
} }
fun delete(manga: Manga): Boolean { fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile() val file = Uri.parse(manga.url).toFile()
return file.delete() return file.delete()
} }
fun getFromFile(file: File): Manga { @SuppressLint("DefaultLocale")
val zip = ZipFile(file) fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
val fileUri = file.toUri().toString() val fileUri = file.toUri().toString()
val entry = zip.getEntry(MangaZip.INDEX_ENTRY) val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) val index = entry?.let(zip::readText)?.let(::MangaIndex)
return index?.let { val info = index?.getMangaInfo()
it.getMangaInfo()?.let { x -> if (index != null && info != null) {
x.copy( return info.copy(
source = MangaSource.LOCAL,
url = fileUri,
coverUrl = zipUri(
file,
entryName = it.getCoverEntry()
?: findFirstEntry(zip.entries())?.name.orEmpty()
),
chapters = x.chapters?.map { c -> c.copy(url = fileUri) }
)
}
} ?: run {
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
Manga(
id = file.absolutePath.longHashCode(),
title = title,
url = fileUri,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
coverUrl = zipUri(file, findFirstEntry(zip.entries())?.name.orEmpty()), url = fileUri,
chapters = listOf( coverUrl = zipUri(
MangaChapter( file,
id = file.absolutePath.longHashCode(), entryName = index.getCoverEntry()
url = fileUri, ?: findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()
number = 1, ),
source = MangaSource.LOCAL, chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
name = title
)
)
) )
} }
// fallback
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
val chapters = ArraySet<String>()
for (x in zip.entries()) {
if (!x.isDirectory) {
chapters += x.name.substringBeforeLast(File.separatorChar, "")
}
}
val uriBuilder = file.toUri().buildUpon()
Manga(
id = file.absolutePath.longHashCode(),
title = title,
url = fileUri,
source = MangaSource.LOCAL,
coverUrl = zipUri(file, findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()),
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
MangaChapter(
id = "$i$s".longHashCode(),
name = if (s.isEmpty()) title else s,
number = i + 1,
source = MangaSource.LOCAL,
url = uriBuilder.fragment(s).build().toString()
)
}
)
} }
fun getRemoteManga(localManga: Manga): Manga? { fun getRemoteManga(localManga: Manga): Manga? {
@@ -119,11 +137,19 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
private fun zipUri(file: File, entryName: String) = private fun zipUri(file: File, entryName: String) =
Uri.fromParts("cbz", file.path, entryName).toString() Uri.fromParts("cbz", file.path, entryName).toString()
private fun findFirstEntry(entries: Enumeration<out ZipEntry>): ZipEntry? { private fun findFirstEntry(entries: Enumeration<out ZipEntry>, isImage: Boolean): ZipEntry? {
val list = entries.toList() val list = entries.toList()
.filterNot { it.isDirectory } .filterNot { it.isDirectory }
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) .sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
return list.firstOrNull() return if (isImage) {
val map = MimeTypeMap.getSingleton()
list.firstOrNull {
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
?.startsWith("image/") == true
}
} else {
list.firstOrNull()
}
} }
override val sortOrders = emptySet<SortOrder>() override val sortOrders = emptySet<SortOrder>()

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Build import android.os.Build
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import java.util.* import java.util.*

View File

@@ -23,7 +23,12 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
): List<Manga> { ): List<Manga> {
val domain = conf.getDomain(defaultDomain) val domain = conf.getDomain(defaultDomain)
val url = when { val url = when {
query != null -> "https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}" !query.isNullOrEmpty() -> {
if (offset != 0) {
return emptyList()
}
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
}
tag != null -> "https://$domain/tags/${tag.key}&n=${getSortKey2(sortOrder)}?offset=$offset" tag != null -> "https://$domain/tags/${tag.key}&n=${getSortKey2(sortOrder)}?offset=$offset"
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset" else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
} }
@@ -126,7 +131,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
override fun onCreatePreferences() = setOf(R.string.key_parser_domain) override fun onCreatePreferences() = setOf(R.string.key_parser_domain)
private fun getSortKey(sortOrder: SortOrder?) = private fun getSortKey(sortOrder: SortOrder?) =
when (sortOrder ?: sortOrders.minBy { it.ordinal }) { when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
SortOrder.ALPHABETICAL -> "catalog" SortOrder.ALPHABETICAL -> "catalog"
SortOrder.POPULARITY -> "mostfavorites" SortOrder.POPULARITY -> "mostfavorites"
SortOrder.NEWEST -> "manga/new" SortOrder.NEWEST -> "manga/new"
@@ -134,7 +139,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
} }
private fun getSortKey2(sortOrder: SortOrder?) = private fun getSortKey2(sortOrder: SortOrder?) =
when (sortOrder ?: sortOrders.minBy { it.ordinal }) { when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
SortOrder.ALPHABETICAL -> "abcasc" SortOrder.ALPHABETICAL -> "abcasc"
SortOrder.POPULARITY -> "favdesc" SortOrder.POPULARITY -> "favdesc"
SortOrder.NEWEST -> "datedesc" SortOrder.NEWEST -> "datedesc"

View File

@@ -28,7 +28,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
val doc = when { val doc = when {
!query.isNullOrEmpty() -> loaderContext.httpPost( !query.isNullOrEmpty() -> loaderContext.httpPost(
"https://$domain/search", "https://$domain/search",
mapOf("q" to query, "offset" to offset.toString()) mapOf("q" to query.urlEncoded(), "offset" to offset.toString())
) )
tag == null -> loaderContext.httpGet( tag == null -> loaderContext.httpGet(
"https://$domain/list?sortType=${getSortKey( "https://$domain/list?sortType=${getSortKey(
@@ -113,7 +113,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
a.attr("href")?.withDomain(domain) ?: return@mapIndexedNotNull null a.attr("href")?.withDomain(domain) ?: return@mapIndexedNotNull null
MangaChapter( MangaChapter(
id = href.longHashCode(), id = href.longHashCode(),
name = a.ownText(), name = a.ownText().removePrefix(manga.title).trim(),
number = i + 1, number = i + 1,
url = href, url = href,
source = source source = source
@@ -165,7 +165,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
override fun onCreatePreferences() = setOf(R.string.key_parser_domain) override fun onCreatePreferences() = setOf(R.string.key_parser_domain)
private fun getSortKey(sortOrder: SortOrder?) = private fun getSortKey(sortOrder: SortOrder?) =
when (sortOrder ?: sortOrders.minBy { it.ordinal }) { when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
SortOrder.ALPHABETICAL -> "name" SortOrder.ALPHABETICAL -> "name"
SortOrder.POPULARITY -> "rate" SortOrder.POPULARITY -> "rate"
SortOrder.UPDATED -> "updated" SortOrder.UPDATED -> "updated"

View File

@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.utils.ext.withDomain
class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) { class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val defaultDomain = "h-chan.me" override val defaultDomain = "henchan.pro"
override val source = MangaSource.HENCHAN override val source = MangaSource.HENCHAN
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {

View File

@@ -128,7 +128,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
tags = info.getElementsMatchingOwnText("Жанры")?.firstOrNull() tags = info.getElementsMatchingOwnText("Жанры")?.firstOrNull()
?.nextElementSibling()?.select("a")?.mapNotNull { a -> ?.nextElementSibling()?.select("a")?.mapNotNull { a ->
MangaTag( MangaTag(
title = a.text(), title = a.text().capitalize(),
key = a.attr("href").substringAfterLast('='), key = a.attr("href").substringAfterLast('='),
source = source source = source
) )
@@ -182,7 +182,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
result += MangaTag( result += MangaTag(
source = source, source = source,
key = x.getInt("id").toString(), key = x.getInt("id").toString(),
title = x.getString("name") title = x.getString("name").capitalize()
) )
} }
return result return result

View File

@@ -37,7 +37,12 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposi
} }
val page = (offset / 30) + 1 val page = (offset / 30) + 1
val url = when { val url = when {
!query.isNullOrEmpty() -> "$scheme://$domain/search?name=${query.urlEncoded()}" !query.isNullOrEmpty() -> {
if (offset != 0) {
return emptyList()
}
"$scheme://$domain/search?name=${query.urlEncoded()}"
}
tag != null -> "$scheme://$domain/directory/${tag.key}/$page.htm$sortKey" tag != null -> "$scheme://$domain/directory/${tag.key}/$page.htm$sortKey"
else -> "$scheme://$domain/directory/$page.htm$sortKey" else -> "$scheme://$domain/directory/$page.htm$sortKey"
} }

View File

@@ -5,6 +5,6 @@ import org.koitharu.kotatsu.domain.MangaLoaderContext
class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) { class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
override val defaultDomain = "readmanga.me" override val defaultDomain = "readmanga.live"
override val source = MangaSource.READMANGA_RU override val source = MangaSource.READMANGA_RU
} }

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.prefs
enum class AppSection {
LOCAL, FAVOURITES, HISTORY, FEED
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Resources import android.content.res.Resources
import android.os.StatFs
import android.provider.Settings import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit import androidx.core.content.edit
@@ -27,6 +26,12 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
ListMode.DETAILED_LIST ListMode.DETAILED_LIST
) )
var defaultSection by EnumPreferenceDelegate(
AppSection::class.java,
resources.getString(R.string.key_app_section),
AppSection.HISTORY
)
val theme by StringIntPreferenceDelegate( val theme by StringIntPreferenceDelegate(
resources.getString(R.string.key_theme), resources.getString(R.string.key_theme),
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM

View File

@@ -1,13 +1,6 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
enum class ListMode(val id: Int) { enum class ListMode {
LIST(0), LIST, DETAILED_LIST, GRID;
DETAILED_LIST(1),
GRID(2);
companion object {
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
}
} }

View File

@@ -13,7 +13,8 @@ import java.util.*
object MangaProviderFactory : KoinComponent { object MangaProviderFactory : KoinComponent {
private val loaderContext by inject<MangaLoaderContext>() private val loaderContext by inject<MangaLoaderContext>()
private val cache = EnumMap<MangaSource, WeakReference<MangaRepository>>(MangaSource::class.java) private val cache =
EnumMap<MangaSource, WeakReference<MangaRepository>>(MangaSource::class.java)
fun getSources(includeHidden: Boolean): List<MangaSource> { fun getSources(includeHidden: Boolean): List<MangaSource> {
val settings = get<AppSettings>() val settings = get<AppSettings>()
@@ -33,24 +34,37 @@ object MangaProviderFactory : KoinComponent {
} }
} }
fun createLocal(): LocalMangaRepository = fun createLocal(): LocalMangaRepository {
(cache[MangaSource.LOCAL]?.get() as? LocalMangaRepository) var instance = cache[MangaSource.LOCAL]?.get()
?: LocalMangaRepository().also { if (instance == null) {
cache[MangaSource.LOCAL] = WeakReference<MangaRepository>(it) synchronized(cache) {
instance = cache[MangaSource.LOCAL]?.get()
if (instance == null) {
instance = LocalMangaRepository()
cache[MangaSource.LOCAL] = WeakReference<MangaRepository>(instance)
}
} }
}
return instance as LocalMangaRepository
}
@Throws(Throwable::class) @Throws(Throwable::class)
fun create(source: MangaSource): MangaRepository { fun create(source: MangaSource): MangaRepository {
cache[source]?.get()?.let { var instance = cache[source]?.get()
return it if (instance == null) {
synchronized(cache) {
instance = cache[source]?.get()
if (instance == null) {
instance = try {
source.cls.getDeclaredConstructor(MangaLoaderContext::class.java)
.newInstance(loaderContext)
} catch (e: NoSuchMethodException) {
source.cls.newInstance()
}
cache[source] = WeakReference(instance!!)
}
}
} }
val instance = try { return instance!!
source.cls.getDeclaredConstructor(MangaLoaderContext::class.java)
.newInstance(loaderContext)
} catch (e: NoSuchMethodException) {
source.cls.newInstance()
}
cache[source] = WeakReference<MangaRepository>(instance)
return instance
} }
} }

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.SortOrder
import java.util.*
class MangaSearchRepository : KoinComponent {
fun globalSearch(query: String, batchSize: Int = 4): Flow<List<Manga>> = flow {
val sources = MangaProviderFactory.getSources(false)
val lists = EnumMap<MangaSource, List<Manga>>(MangaSource::class.java)
var i = 0
while (true) {
var isEmitted = false
for (source in sources) {
val list = lists.getOrPut(source) {
try {
MangaProviderFactory.create(source).getList(0, query, SortOrder.POPULARITY)
} catch (e: Throwable) {
e.printStackTrace()
emptyList<Manga>()
}
}
if (i < list.size) {
emit(list.subList(i, (i + batchSize).coerceAtMost(list.lastIndex)))
isEmitted = true
}
}
i += batchSize
if (!isEmitted) {
return@flow
}
}
}
}

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.domain package org.koitharu.kotatsu.domain
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size import android.util.Size
import androidx.annotation.WorkerThread
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
@@ -12,6 +14,7 @@ import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.medianOrNull import org.koitharu.kotatsu.utils.ext.medianOrNull
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipFile
object MangaUtils : KoinComponent { object MangaUtils : KoinComponent {
@@ -19,17 +22,28 @@ object MangaUtils : KoinComponent {
* Automatic determine type of manga by page size * Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide * @return ReaderMode.WEBTOON if page is wide
*/ */
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun determineReaderMode(pages: List<MangaPage>): ReaderMode? { suspend fun determineReaderMode(pages: List<MangaPage>): ReaderMode? {
try { try {
val page = pages.medianOrNull() ?: return null val page = pages.medianOrNull() ?: return null
val url = MangaProviderFactory.create(page.source).getPageFullUrl(page) val url = MangaProviderFactory.create(page.source).getPageFullUrl(page)
val client = get<OkHttpClient>() val uri = Uri.parse(url)
val request = Request.Builder() val size = if (uri.scheme == "cbz") {
.url(url) val zip = ZipFile(uri.schemeSpecificPart)
.get() val entry = zip.getEntry(uri.fragment)
.build() zip.getInputStream(entry).use {
val size = client.newCall(request).await().use { getBitmapSize(it)
getBitmapSize(it.body?.byteStream()) }
} else {
val client = get<OkHttpClient>()
val request = Request.Builder()
.url(url)
.get()
.build()
client.newCall(request).await().use {
getBitmapSize(it.body?.byteStream())
}
} }
return when { return when {
size.width * 2 < size.height -> ReaderMode.WEBTOON size.width * 2 < size.height -> ReaderMode.WEBTOON

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.domain.favourites package org.koitharu.kotatsu.domain.favourites
import androidx.collection.ArraySet
import androidx.room.withTransaction import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -12,24 +13,33 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity 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.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import java.util.*
class FavouritesRepository : KoinComponent { class FavouritesRepository : KoinComponent {
private val db: MangaDatabase by inject() private val db: MangaDatabase by inject()
suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getAllManga(offset: Int): List<Manga> { suspend fun getAllManga(offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(offset, 20) val entities = db.favouritesDao.findAll(offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) } return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
} }
suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> { suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId, offset, 20) val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) } return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
} }
suspend fun getAllCategories(): List<FavouriteCategory> { suspend fun getAllCategories(): List<FavouriteCategory> {
val entities = db.favouriteCategoriesDao.findAll("created_at") val entities = db.favouriteCategoriesDao.findAll()
return entities.map { it.toFavouriteCategory() } return entities.map { it.toFavouriteCategory() }
} }
@@ -42,17 +52,32 @@ class FavouritesRepository : KoinComponent {
val entity = FavouriteCategoryEntity( val entity = FavouriteCategoryEntity(
title = title, title = title,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0 categoryId = 0
) )
val id = db.favouriteCategoriesDao.insert(entity) val id = db.favouriteCategoriesDao.insert(entity)
notifyCategoriesChanged()
return entity.toFavouriteCategory(id) return entity.toFavouriteCategory(id)
} }
suspend fun renameCategory(id: Long, title: String) { suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title) db.favouriteCategoriesDao.update(id, title)
notifyCategoriesChanged()
} }
suspend fun removeCategory(id: Long) { suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id) db.favouriteCategoriesDao.delete(id)
notifyCategoriesChanged()
}
suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao
db.withTransaction {
for ((i, id) in orderedIds.withIndex()) {
dao.update(id, i)
}
}
notifyCategoriesChanged()
} }
suspend fun addToCategory(manga: Manga, categoryId: Long) { suspend fun addToCategory(manga: Manga, categoryId: Long) {
@@ -73,14 +98,14 @@ class FavouritesRepository : KoinComponent {
companion object { companion object {
private val listeners = HashSet<OnFavouritesChangeListener>() private val listeners = ArraySet<OnFavouritesChangeListener>()
fun subscribe(listener: OnFavouritesChangeListener) { fun subscribe(listener: OnFavouritesChangeListener) {
listeners += listener listeners += listener
} }
fun unsubscribe(listener: OnFavouritesChangeListener) { fun unsubscribe(listener: OnFavouritesChangeListener) {
listeners += listener listeners -= listener
} }
private suspend fun notifyFavouritesChanged(mangaId: Long) { private suspend fun notifyFavouritesChanged(mangaId: Long) {
@@ -88,5 +113,11 @@ class FavouritesRepository : KoinComponent {
listeners.forEach { x -> x.onFavouritesChanged(mangaId) } listeners.forEach { x -> x.onFavouritesChanged(mangaId) }
} }
} }
private suspend fun notifyCategoriesChanged() {
withContext(Dispatchers.Main) {
listeners.forEach { x -> x.onCategoriesChanged() }
}
}
} }
} }

View File

@@ -3,4 +3,6 @@ package org.koitharu.kotatsu.domain.favourites
interface OnFavouritesChangeListener { interface OnFavouritesChangeListener {
fun onFavouritesChanged(mangaId: Long) fun onFavouritesChanged(mangaId: Long)
fun onCategoriesChanged()
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.domain.history package org.koitharu.kotatsu.domain.history
import androidx.collection.ArraySet
import androidx.room.withTransaction import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -24,7 +25,7 @@ class HistoryRepository : KoinComponent {
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) } return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
} }
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Float) { suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)
@@ -36,7 +37,7 @@ class HistoryRepository : KoinComponent {
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
chapterId = chapterId, chapterId = chapterId,
page = page, page = page,
scroll = scroll scroll = scroll.toFloat() // we migrate to int, but decide to not update database
) )
) )
trackingRepository.upsert(manga) trackingRepository.upsert(manga)
@@ -45,15 +46,7 @@ class HistoryRepository : KoinComponent {
} }
suspend fun getOne(manga: Manga): MangaHistory? { suspend fun getOne(manga: Manga): MangaHistory? {
return db.historyDao.find(manga.id)?.let { return db.historyDao.find(manga.id)?.toMangaHistory()
MangaHistory(
createdAt = Date(it.createdAt),
updatedAt = Date(it.updatedAt),
chapterId = it.chapterId,
page = it.page,
scroll = it.scroll
)
}
} }
suspend fun clear() { suspend fun clear() {
@@ -79,14 +72,14 @@ class HistoryRepository : KoinComponent {
companion object { companion object {
private val listeners = HashSet<OnHistoryChangeListener>() private val listeners = ArraySet<OnHistoryChangeListener>()
fun subscribe(listener: OnHistoryChangeListener) { fun subscribe(listener: OnHistoryChangeListener) {
listeners += listener listeners += listener
} }
fun unsubscribe(listener: OnHistoryChangeListener) { fun unsubscribe(listener: OnHistoryChangeListener) {
listeners += listener listeners -= listener
} }
private suspend fun notifyHistoryChanged() { private suspend fun notifyHistoryChanged() {

View File

@@ -1,11 +1,13 @@
package org.koitharu.kotatsu.domain.tracking package org.koitharu.kotatsu.domain.tracking
import androidx.room.withTransaction
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koin.core.inject import org.koin.core.inject
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.TrackEntity import org.koitharu.kotatsu.core.db.entity.TrackEntity
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
import org.koitharu.kotatsu.core.model.MangaTracking import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.domain.MangaProviderFactory
import java.util.* import java.util.*
class TrackingRepository : KoinComponent { class TrackingRepository : KoinComponent {
@@ -20,12 +22,16 @@ class TrackingRepository : KoinComponent {
suspend fun getAllTracks(): List<MangaTracking> { suspend fun getAllTracks(): List<MangaTracking> {
val favourites = db.favouritesDao.findAllManga() val favourites = db.favouritesDao.findAllManga()
val history = db.historyDao.findAllManga() val history = db.historyDao.findAllManga()
val manga = (favourites + history).distinctBy { it.id } val mangas = (favourites + history).distinctBy { it.id }
val tracks = db.tracksDao.findAll().groupBy { it.mangaId } val tracks = db.tracksDao.findAll().groupBy { it.mangaId }
return manga.map { m -> return mangas.mapNotNull { me ->
val track = tracks[m.id]?.singleOrNull() var manga = me.toManga()
if (manga.source == MangaSource.LOCAL) {
manga = MangaProviderFactory.createLocal().getRemoteManga(manga) ?: return@mapNotNull null
}
val track = tracks[manga.id]?.singleOrNull()
MangaTracking( MangaTracking(
manga = m.toManga(), manga = manga,
knownChaptersCount = track?.totalChapters ?: -1, knownChaptersCount = track?.totalChapters ?: -1,
lastChapterId = track?.lastChapterId ?: 0L, lastChapterId = track?.lastChapterId ?: 0L,
lastNotifiedChapterId = track?.lastNotifiedChapterId ?: 0L, lastNotifiedChapterId = track?.lastNotifiedChapterId ?: 0L,
@@ -34,22 +40,50 @@ class TrackingRepository : KoinComponent {
} }
} }
suspend fun getTrackingLog(offset: Int, limit: Int): List<TrackingLogItem> {
return db.trackLogsDao.findAll(offset, limit).map { x ->
x.toTrackingLogItem()
}
}
suspend fun count() = db.trackLogsDao.count()
suspend fun clearLogs() = db.trackLogsDao.clear()
suspend fun cleanup() {
db.withTransaction {
db.tracksDao.cleanup()
db.trackLogsDao.cleanup()
}
}
suspend fun storeTrackResult( suspend fun storeTrackResult(
mangaId: Long, mangaId: Long,
knownChaptersCount: Int, knownChaptersCount: Int,
lastChapterId: Long, lastChapterId: Long,
newChapters: Int, newChapters: List<MangaChapter>,
lastNotifiedChapterId: Long previousTrackChapterId: Long
) { ) {
val entity = TrackEntity( db.withTransaction {
mangaId = mangaId, val entity = TrackEntity(
newChapters = newChapters, mangaId = mangaId,
lastCheck = System.currentTimeMillis(), newChapters = newChapters.size,
lastChapterId = lastChapterId, lastCheck = System.currentTimeMillis(),
totalChapters = knownChaptersCount, lastChapterId = lastChapterId,
lastNotifiedChapterId = lastNotifiedChapterId totalChapters = knownChaptersCount,
) lastNotifiedChapterId = newChapters.lastOrNull()?.id ?: previousTrackChapterId
db.tracksDao.upsert(entity) )
db.tracksDao.upsert(entity)
val foundChapters = newChapters.takeLastWhile { x -> x.id != previousTrackChapterId }
if (foundChapters.isNotEmpty()) {
val logEntity = TrackLogEntity(
mangaId = mangaId,
chapters = foundChapters.joinToString("\n") { x -> x.name },
createdAt = System.currentTimeMillis()
)
db.trackLogsDao.insert(logEntity)
}
}
} }
suspend fun upsert(manga: Manga) { suspend fun upsert(manga: Manga) {

View File

@@ -29,7 +29,7 @@ class BrowserActivity : BaseActivity(), BrowserCallback {
webView.webViewClient = BrowserClient(this) webView.webViewClient = BrowserClient(this)
val url = intent?.dataString val url = intent?.dataString
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
finish() finishAfterTransition()
} else { } else {
webView.loadUrl(url) webView.loadUrl(url)
} }
@@ -43,7 +43,7 @@ class BrowserActivity : BaseActivity(), BrowserCallback {
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
webView.stopLoading() webView.stopLoading()
finish() finishAfterTransition()
true true
} }
R.id.action_browser -> { R.id.action_browser -> {

View File

@@ -5,7 +5,7 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder
import moxy.MvpAppCompatDialogFragment import moxy.MvpAppCompatDialogFragment
abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : MvpAppCompatDialogFragment() { abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : MvpAppCompatDialogFragment() {
@@ -18,7 +18,7 @@ abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : Mv
if (view != null) { if (view != null) {
onViewCreated(view, savedInstanceState) onViewCreated(view, savedInstanceState)
} }
return AlertDialog.Builder(requireContext(), theme) return MaterialAlertDialogBuilder(requireContext(), theme)
.setView(view) .setView(view)
.also(::onBuildDialog) .also(::onBuildDialog)
.create() .create()

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.ui.common package org.koitharu.kotatsu.ui.common
import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import moxy.MvpAppCompatActivity import moxy.MvpAppCompatActivity
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent { abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
@@ -29,13 +27,4 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
onBackPressed() onBackPressed()
true true
} else super.onOptionsItemSelected(item) } else super.onOptionsItemSelected(item)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
//TODO remove. Just for testing
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
recreate()
return true
}
return super.onKeyDown(keyCode, event)
}
} }

View File

@@ -6,7 +6,7 @@ import androidx.annotation.DrawableRes
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
class ChipsFactory(private val context: Context) { class ChipsFactory(val context: Context) {
fun create(convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0, fun create(convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0,
tag: Any? = null, onClickListener: View.OnClickListener? = null): Chip { tag: Any? = null, onClickListener: View.OnClickListener? = null): Chip {

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.ui.common
import android.util.ArrayMap
import moxy.MvpPresenter
import java.lang.ref.WeakReference
abstract class SharedPresenterHolder<T : MvpPresenter<*>> {
private val cache = ArrayMap<Int, WeakReference<T>>(3)
fun getInstance(key: Int): T {
var instance = cache[key]?.get()
if (instance == null) {
instance = onCreatePresenter(key)
cache[key] = WeakReference(instance)
}
return instance
}
fun clear(key: Int) {
cache.remove(key)
}
protected abstract fun onCreatePresenter(key: Int): T
}

View File

@@ -8,6 +8,7 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.checkbox.MaterialCheckBox import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) : class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) :
@@ -22,7 +23,7 @@ class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog)
.inflate(R.layout.dialog_checkbox, null, false) .inflate(R.layout.dialog_checkbox, null, false)
private val checkBox = view.findViewById<MaterialCheckBox>(android.R.id.checkbox) private val checkBox = view.findViewById<MaterialCheckBox>(android.R.id.checkbox)
private val delegate = AlertDialog.Builder(context) private val delegate = MaterialAlertDialogBuilder(context)
.setView(view) .setView(view)
fun setTitle(@StringRes titleResId: Int): Builder { fun setTitle(@StringRes titleResId: Int): Builder {

View File

@@ -7,6 +7,7 @@ import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.item_storage.view.* import kotlinx.android.synthetic.main.item_storage.view.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.LocalMangaRepository import org.koitharu.kotatsu.core.parser.LocalMangaRepository
@@ -23,7 +24,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) { class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
private val adapter = VolumesAdapter(context) private val adapter = VolumesAdapter(context)
private val delegate = AlertDialog.Builder(context) private val delegate = MaterialAlertDialogBuilder(context)
init { init {
if (adapter.isEmpty) { if (adapter.isEmpty) {

View File

@@ -7,6 +7,7 @@ import android.text.InputFilter
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.dialog_input.view.* import kotlinx.android.synthetic.main.dialog_input.view.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -20,7 +21,7 @@ class TextInputDialog private constructor(private val delegate: AlertDialog) :
@SuppressLint("InflateParams") @SuppressLint("InflateParams")
private val view = LayoutInflater.from(context).inflate(R.layout.dialog_input, null, false) private val view = LayoutInflater.from(context).inflate(R.layout.dialog_input, null, false)
private val delegate = AlertDialog.Builder(context) private val delegate = MaterialAlertDialogBuilder(context)
.setView(view) .setView(view)
fun setTitle(@StringRes titleResId: Int): Builder { fun setTitle(@StringRes titleResId: Int): Builder {

View File

@@ -87,16 +87,23 @@ abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecy
final override fun getItemCount() = dataSet.size final override fun getItemCount() = dataSet.size
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T, E> { final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T, E> {
return onCreateViewHolder(parent).setOnItemClickListener(onItemClickListener) return onCreateViewHolder(parent)
.also(this::onViewHolderCreated) }
override fun onViewDetachedFromWindow(holder: BaseViewHolder<T, E>) {
holder.setOnItemClickListener(null)
super.onViewDetachedFromWindow(holder)
}
override fun onViewAttachedToWindow(holder: BaseViewHolder<T, E>) {
super.onViewAttachedToWindow(holder)
holder.setOnItemClickListener(onItemClickListener)
} }
protected open fun onDataSetChanged() = Unit protected open fun onDataSetChanged() = Unit
protected abstract fun getExtra(item: T, position: Int): E protected abstract fun getExtra(item: T, position: Int): E
protected open fun onViewHolderCreated(holder: BaseViewHolder<T, E>) = Unit
protected abstract fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<T, E> protected abstract fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<T, E>
protected abstract fun onGetItemId(item: T): Long protected abstract fun onGetItemId(item: T): Long

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.ui.common.list package org.koitharu.kotatsu.ui.common.list
import android.os.Build
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
@@ -27,26 +26,29 @@ abstract class BaseViewHolder<T, E> protected constructor(view: View) :
onBind(data, extra) onBind(data, extra)
} }
fun requireData() = boundData ?: throw IllegalStateException("Calling requireData() before bind()") fun requireData(): T {
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
}
fun setOnItemClickListener(listener: OnRecyclerItemClickListener<T>?): BaseViewHolder<T, E> { fun setOnItemClickListener(listener: OnRecyclerItemClickListener<T>?) {
if (listener != null) { val listenersAdapter = listener?.let { HolderListenersAdapter(it) }
itemView.setOnClickListener { itemView.setOnClickListener(listenersAdapter)
listener.onItemClick(boundData ?: return@setOnClickListener, bindingAdapterPosition, it) itemView.setOnLongClickListener(listenersAdapter)
}
itemView.setOnLongClickListener {
listener.onItemLongClick(boundData ?: return@setOnLongClickListener false, bindingAdapterPosition, it)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
itemView.setOnContextClickListener {
listener.onItemLongClick(boundData ?: return@setOnContextClickListener false, bindingAdapterPosition, it)
}
}
}
return this
} }
open fun onRecycled() = Unit open fun onRecycled() = Unit
abstract fun onBind(data: T, extra: E) abstract fun onBind(data: T, extra: E)
private inner class HolderListenersAdapter(private val listener: OnRecyclerItemClickListener<T>) :
View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
listener.onItemClick(boundData ?: return, bindingAdapterPosition, v)
}
override fun onLongClick(v: View): Boolean {
return listener.onItemLongClick(boundData ?: return false, bindingAdapterPosition, v)
}
}
} }

View File

@@ -12,13 +12,15 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
return
}
if (firstVisibleItemPosition <= offsetTop) { if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView) onScrolledToStart(recyclerView)
return
} }
val visibleItemCount = layoutManager.childCount val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount val totalItemCount = layoutManager.itemCount
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom && firstVisibleItemPosition >= 0) { if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
onScrolledToEnd(recyclerView) onScrolledToEnd(recyclerView)
} }
} }

View File

@@ -2,24 +2,31 @@ package org.koitharu.kotatsu.ui.common.list
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
class PaginationScrollListener(offset: Int, private val callback: Callback) : BoundsScrollListener(0, offset) { class PaginationScrollListener(offset: Int, private val callback: Callback) :
BoundsScrollListener(0, offset) {
private var lastTotalCount = 0 private var lastTotalCount = 0
override fun onScrolledToStart(recyclerView: RecyclerView) = Unit override fun onScrolledToStart(recyclerView: RecyclerView) = Unit
override fun onScrolledToEnd(recyclerView: RecyclerView) { override fun onScrolledToEnd(recyclerView: RecyclerView) {
val total = recyclerView.adapter?.itemCount ?: 0 val total = callback.getItemsCount()
if (total > lastTotalCount) { if (total > lastTotalCount) {
callback.onRequestMoreItems(total)
lastTotalCount = total lastTotalCount = total
callback.onRequestMoreItems(total)
} else if (total < lastTotalCount) { } else if (total < lastTotalCount) {
lastTotalCount = total lastTotalCount = total
} }
} }
fun reset() {
lastTotalCount = 0
}
interface Callback { interface Callback {
fun onRequestMoreItems(offset: Int) fun onRequestMoreItems(offset: Int)
fun getItemsCount(): Int
} }
} }

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.ui.common.list
import android.view.ViewGroup
class ProgressBarAdapter : BaseRecyclerAdapter<Boolean, Unit>() {
var isProgressVisible: Boolean
get() = dataSet.isNotEmpty()
set(value) {
if (value == dataSet.isEmpty()) {
if (value) {
appendItem(true)
} else {
removeItemAt(0)
}
}
}
override fun getExtra(item: Boolean, position: Int) = Unit
override fun onCreateViewHolder(parent: ViewGroup) = ProgressBarHolder(parent)
override fun onGetItemId(item: Boolean) = -1L
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.ui.common.list
import android.view.View
import android.view.ViewGroup
import kotlinx.android.synthetic.main.item_progress.*
import org.koitharu.kotatsu.R
class ProgressBarHolder(parent: ViewGroup) :
BaseViewHolder<Boolean, Unit>(parent, R.layout.item_progress) {
private var pendingVisibility: Int = View.GONE
private val action = Runnable {
progressBar?.visibility = pendingVisibility
pendingVisibility = View.GONE
}
override fun onBind(data: Boolean, extra: Unit) {
val visibility = if (data) {
View.VISIBLE
} else {
View.INVISIBLE
}
if (visibility != progressBar.visibility && visibility != pendingVisibility) {
progressBar.removeCallbacks(action)
pendingVisibility = visibility
progressBar.postDelayed(action, 400)
}
}
override fun onRecycled() {
progressBar.removeCallbacks(action)
super.onRecycled()
}
}

View File

@@ -28,14 +28,14 @@ class ChapterHolder(parent: ViewGroup) :
} }
ChapterExtra.CURRENT -> { ChapterExtra.CURRENT -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_outline_accent) textView_number.setBackgroundResource(R.drawable.bg_badge_outline_accent)
textView_number.setTextColor(context.getThemeColor(R.attr.colorAccent)) textView_number.setTextColor(context.getThemeColor(androidx.appcompat.R.attr.colorAccent))
} }
ChapterExtra.NEW -> { ChapterExtra.NEW -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_accent) textView_number.setBackgroundResource(R.drawable.bg_badge_accent)
textView_number.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse)) textView_number.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
} }
ChapterExtra.CHECKED -> { ChapterExtra.CHECKED -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_accent) textView_number.background = null
textView_number.setTextColor(Color.TRANSPARENT) textView_number.setTextColor(Color.TRANSPARENT)
} }
} }

View File

@@ -25,7 +25,9 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
OnRecyclerItemClickListener<MangaChapter>, ActionMode.Callback { OnRecyclerItemClickListener<MangaChapter>, ActionMode.Callback {
@Suppress("unused") @Suppress("unused")
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance) private val presenter by moxyPresenter {
MangaDetailsPresenter.getInstance(activity.hashCode())
}
private var manga: Manga? = null private var manga: Manga? = null
@@ -123,6 +125,7 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
manga ?: return false, manga ?: return false,
adapter.checkedItemsIds adapter.checkedItemsIds
) )
mode.finish()
true true
} }
R.id.action_select_all -> { R.id.action_select_all -> {

View File

@@ -7,11 +7,12 @@ import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
@@ -31,11 +32,14 @@ import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.utils.MangaShortcut import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getThemeColor
class MangaDetailsActivity : BaseActivity(), MangaDetailsView, class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
TabLayoutMediator.TabConfigurationStrategy { TabLayoutMediator.TabConfigurationStrategy {
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance) private val presenter by moxyPresenter {
MangaDetailsPresenter.getInstance(hashCode())
}
private var manga: Manga? = null private var manga: Manga? = null
@@ -50,7 +54,7 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
presenter.loadDetails(it, true) presenter.loadDetails(it, true)
} ?: intent?.getLongExtra(EXTRA_MANGA_ID, 0)?.takeUnless { it == 0L }?.let { } ?: intent?.getLongExtra(EXTRA_MANGA_ID, 0)?.takeUnless { it == 0L }?.let {
presenter.findMangaById(it) presenter.findMangaById(it)
} ?: finish() } ?: finishAfterTransition()
} }
} }
@@ -71,13 +75,13 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
this, getString(R.string._s_deleted_from_local_storage, manga.title), this, getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
finish() finishAfterTransition()
} }
override fun onError(e: Throwable) { override fun onError(e: Throwable) {
if (manga == null) { if (manga == null) {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finish() finishAfterTransition()
} else { } else {
Snackbar.make(pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() Snackbar.make(pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
} }
@@ -104,6 +108,8 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
manga?.source != null && manga?.source != MangaSource.LOCAL manga?.source != null && manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible = menu.findItem(R.id.action_delete).isVisible =
manga?.source == MangaSource.LOCAL manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible =
manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = menu.findItem(R.id.action_shortcut).isVisible =
ShortcutManagerCompat.isRequestPinShortcutSupported(this) ShortcutManagerCompat.isRequestPinShortcutSupported(this)
return super.onPrepareOptionsMenu(menu) return super.onPrepareOptionsMenu(menu)
@@ -122,7 +128,7 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
} }
R.id.action_delete -> { R.id.action_delete -> {
manga?.let { m -> manga?.let { m ->
AlertDialog.Builder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_manga) .setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title)) .setMessage(getString(R.string.text_delete_local_manga, m.title))
.setPositiveButton(R.string.delete) { _, _ -> .setPositiveButton(R.string.delete) { _, _ ->
@@ -137,9 +143,18 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
manga?.let { manga?.let {
val chaptersCount = it.chapters?.size ?: 0 val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) { if (chaptersCount > 5) {
AlertDialog.Builder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.save_manga) .setTitle(R.string.save_manga)
.setMessage(getString(R.string.large_manga_save_confirm, chaptersCount)) .setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(
R.plurals.chapters,
chaptersCount,
chaptersCount
)
)
)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ -> .setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, it) DownloadService.start(this, it)
@@ -174,9 +189,10 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
} }
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.text = when(position) { tab.text = when (position) {
0 -> getString(R.string.details) 0 -> getString(R.string.details)
1 -> getString(R.string.chapters) 1 -> getString(R.string.chapters)
2 -> getString(R.string.related)
else -> null else -> null
} }
} }
@@ -184,11 +200,13 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
override fun onSupportActionModeStarted(mode: ActionMode) { override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode) super.onSupportActionModeStarted(mode)
pager.isUserInputEnabled = false pager.isUserInputEnabled = false
window?.statusBarColor = ContextCompat.getColor(this, R.color.grey_dark)
} }
override fun onSupportActionModeFinished(mode: ActionMode) { override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode) super.onSupportActionModeFinished(mode)
pager.isUserInputEnabled = true pager.isUserInputEnabled = true
window?.statusBarColor = getThemeColor(androidx.appcompat.R.attr.colorPrimaryDark)
} }
companion object { companion object {

View File

@@ -6,11 +6,12 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getItemCount() = 2 override fun getItemCount() = 3
override fun createFragment(position: Int): Fragment = when(position) { override fun createFragment(position: Int): Fragment = when(position) {
0 -> MangaDetailsFragment() 0 -> MangaDetailsFragment()
1 -> ChaptersFragment() 1 -> ChaptersFragment()
2 -> RelatedMangaFragment()
else -> throw IndexOutOfBoundsException("No fragment for position $position") else -> throw IndexOutOfBoundsException("No fragment for position $position")
} }
} }

View File

@@ -2,23 +2,30 @@ package org.koitharu.kotatsu.ui.details
import android.text.Spanned import android.text.Spanned
import android.view.View import android.view.View
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible import androidx.core.view.isVisible
import coil.api.load import androidx.lifecycle.lifecycleScope
import coil.load
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import kotlinx.android.synthetic.main.fragment_details.* import kotlinx.android.synthetic.main.fragment_details.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.ktx.moxyPresenter import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseFragment import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.main.list.favourites.categories.select.FavouriteCategoriesDialog import org.koitharu.kotatsu.ui.list.favourites.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.ui.reader.ReaderActivity import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.ui.search.MangaSearchSheet import org.koitharu.kotatsu.ui.search.MangaSearchSheet
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.addChips import org.koitharu.kotatsu.utils.ext.addChips
import org.koitharu.kotatsu.utils.ext.showPopupMenu import org.koitharu.kotatsu.utils.ext.showPopupMenu
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import kotlin.math.roundToInt import kotlin.math.roundToInt
class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView, class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView,
@@ -26,7 +33,9 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
View.OnLongClickListener { View.OnLongClickListener {
@Suppress("unused") @Suppress("unused")
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance) private val presenter by moxyPresenter {
MangaDetailsPresenter.getInstance(activity.hashCode())
}
private var manga: Manga? = null private var manga: Manga? = null
private var history: MangaHistory? = null private var history: MangaHistory? = null
@@ -67,6 +76,21 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
onClickListener = this@MangaDetailsFragment onClickListener = this@MangaDetailsFragment
) )
} }
manga.url.toUri().toFileOrNull()?.let { f ->
lifecycleScope.launch {
val size = withContext(Dispatchers.IO) {
f.length()
}
chips_tags.addChips(listOf(f)) {
create(
text = FileSizeUtils.formatBytes(context, size),
iconRes = R.drawable.ic_chip_storage,
tag = it,
onClickListener = this@MangaDetailsFragment
)
}
}
}
imageView_favourite.setOnClickListener(this) imageView_favourite.setOnClickListener(this)
button_read.setOnClickListener(this) button_read.setOnClickListener(this)
button_read.setOnLongClickListener(this) button_read.setOnLongClickListener(this)
@@ -123,8 +147,8 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
} }
override fun onLongClick(v: View): Boolean { override fun onLongClick(v: View): Boolean {
when { when (v.id) {
v.id == R.id.button_read -> { R.id.button_read -> {
if (history == null) { if (history == null) {
return false return false
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.ui.details
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import moxy.InjectViewState import moxy.InjectViewState
@@ -13,23 +14,25 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.domain.MangaDataRepository import org.koitharu.kotatsu.domain.MangaDataRepository
import org.koitharu.kotatsu.domain.MangaProviderFactory import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.domain.MangaSearchRepository
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.domain.favourites.OnFavouritesChangeListener import org.koitharu.kotatsu.domain.favourites.OnFavouritesChangeListener
import org.koitharu.kotatsu.domain.history.HistoryRepository import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.domain.history.OnHistoryChangeListener import org.koitharu.kotatsu.domain.history.OnHistoryChangeListener
import org.koitharu.kotatsu.domain.tracking.TrackingRepository import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import org.koitharu.kotatsu.ui.common.BasePresenter import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.ui.common.SharedPresenterHolder
import org.koitharu.kotatsu.utils.ext.safe import org.koitharu.kotatsu.utils.ext.safe
import java.io.IOException import java.io.IOException
@InjectViewState @InjectViewState
class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsView>(), class MangaDetailsPresenter private constructor(private val key: Int) :
OnHistoryChangeListener, BasePresenter<MangaDetailsView>(), OnHistoryChangeListener, OnFavouritesChangeListener {
OnFavouritesChangeListener {
private lateinit var historyRepository: HistoryRepository private lateinit var historyRepository: HistoryRepository
private lateinit var favouritesRepository: FavouritesRepository private lateinit var favouritesRepository: FavouritesRepository
private lateinit var trackingRepository: TrackingRepository private lateinit var trackingRepository: TrackingRepository
private lateinit var searchRepository: MangaSearchRepository
private var manga: Manga? = null private var manga: Manga? = null
@@ -37,6 +40,7 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
historyRepository = HistoryRepository() historyRepository = HistoryRepository()
favouritesRepository = FavouritesRepository() favouritesRepository = FavouritesRepository()
trackingRepository = TrackingRepository() trackingRepository = TrackingRepository()
searchRepository = MangaSearchRepository()
super.onFirstViewAttach() super.onFirstViewAttach()
HistoryRepository.subscribe(this) HistoryRepository.subscribe(this)
FavouritesRepository.subscribe(this) FavouritesRepository.subscribe(this)
@@ -51,7 +55,7 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
} ?: throw MangaNotFoundException("Cannot find manga by id") } ?: throw MangaNotFoundException("Cannot find manga by id")
viewState.onMangaUpdated(manga) viewState.onMangaUpdated(manga)
loadDetails(manga, true) loadDetails(manga, true)
} catch (_: CancellationException){ } catch (_: CancellationException) {
} catch (e: Throwable) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
@@ -79,7 +83,7 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
viewState.onMangaUpdated(data) viewState.onMangaUpdated(data)
this@MangaDetailsPresenter.manga = data this@MangaDetailsPresenter.manga = data
viewState.onNewChaptersChanged(trackingRepository.getNewChaptersCount(manga.id)) viewState.onNewChaptersChanged(trackingRepository.getNewChaptersCount(manga.id))
} catch (_: CancellationException){ } catch (_: CancellationException) {
} catch (e: Throwable) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
@@ -147,6 +151,38 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
} }
} }
fun loadRelated() {
val manga = this.manga ?: return
presenterScope.launch {
viewState.onLoadingStateChanged(isLoading = true)
var isFirstCall = true
searchRepository.globalSearch(manga.title)
.map { list ->
list.filter { x -> x.id != manga.id }
}.filterNot { x -> x.isEmpty() }
.flowOn(Dispatchers.IO)
.catch { e ->
if (e is IOException) {
viewState.onError(e)
}
}
.onEmpty {
viewState.onListChanged(emptyList())
viewState.onLoadingStateChanged(isLoading = false)
}.onCompletion {
viewState.onListAppended(emptyList())
}.collect {
if (isFirstCall) {
isFirstCall = false
viewState.onListChanged(it)
viewState.onLoadingStateChanged(isLoading = false)
} else {
viewState.onListAppended(it)
}
}
}
}
override fun onHistoryChanged() { override fun onHistoryChanged() {
loadHistory(manga ?: return) loadHistory(manga ?: return)
} }
@@ -157,21 +193,17 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
} }
} }
override fun onCategoriesChanged() = Unit
override fun onDestroy() { override fun onDestroy() {
HistoryRepository.unsubscribe(this) HistoryRepository.unsubscribe(this)
FavouritesRepository.unsubscribe(this) FavouritesRepository.unsubscribe(this)
instance = null clear(key)
super.onDestroy() super.onDestroy()
} }
companion object { companion object Holder : SharedPresenterHolder<MangaDetailsPresenter>() {
private var instance: MangaDetailsPresenter? = null override fun onCreatePresenter(key: Int) = MangaDetailsPresenter(key)
fun getInstance(): MangaDetailsPresenter = instance ?: synchronized(this) {
MangaDetailsPresenter().also {
instance = it
}
}
} }
} }

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.ui.details package org.koitharu.kotatsu.ui.details
import moxy.viewstate.strategy.AddToEndSingleTagStrategy
import moxy.viewstate.strategy.AddToEndStrategy
import moxy.viewstate.strategy.StateStrategyType
import moxy.viewstate.strategy.alias.AddToEndSingle import moxy.viewstate.strategy.alias.AddToEndSingle
import moxy.viewstate.strategy.alias.SingleState import moxy.viewstate.strategy.alias.SingleState
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
@@ -23,4 +26,13 @@ interface MangaDetailsView : BaseMvpView {
@AddToEndSingle @AddToEndSingle
fun onNewChaptersChanged(newChapters: Int) fun onNewChaptersChanged(newChapters: Int)
@StateStrategyType(AddToEndSingleTagStrategy::class, tag = "content")
fun onListChanged(list: List<Manga>) = Unit
@StateStrategyType(AddToEndStrategy::class, tag = "content")
fun onListAppended(list: List<Manga>) = Unit
@StateStrategyType(AddToEndSingleTagStrategy::class, tag = "content")
fun onListError(e: Throwable) = Unit
} }

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.ui.details
import android.os.Bundle
import android.view.View
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.list.MangaListFragment
class RelatedMangaFragment : MangaListFragment<Unit>(), MangaDetailsView {
private val presenter by moxyPresenter {
MangaDetailsPresenter.getInstance(activity.hashCode())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isSwipeRefreshEnabled = false
}
override fun onRequestMoreItems(offset: Int) {
if (offset == 0) {
presenter.loadRelated()
}
}
override fun onMangaUpdated(manga: Manga) = Unit
override fun onHistoryChanged(history: MangaHistory?) = Unit
override fun onFavouriteChanged(categories: List<FavouriteCategory>) = Unit
override fun onMangaRemoved(manga: Manga) = Unit
override fun onNewChaptersChanged(newChapters: Int) = Unit
override fun onListChanged(list: List<Manga>) = super<MangaListFragment>.onListChanged(list)
override fun onListAppended(list: List<Manga>) = super<MangaListFragment>.onListAppended(list)
override fun onListError(e: Throwable) = super<MangaListFragment>.onListError(e)
}

View File

@@ -13,7 +13,6 @@ import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.utils.ext.clearActions
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -92,6 +91,11 @@ class DownloadNotification(private val context: Context) {
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
} }
fun setWaitingForNetwork() {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network))
}
fun setPostProcessing() { fun setPostProcessing() {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_)) builder.setContentText(context.getString(R.string.processing_))

View File

@@ -8,11 +8,12 @@ import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil.Coil import coil.Coil
import coil.request.GetRequestBuilder import coil.request.ImageRequest
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okio.IOException
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -24,10 +25,7 @@ import org.koitharu.kotatsu.domain.local.MangaZip
import org.koitharu.kotatsu.ui.common.BaseService import org.koitharu.kotatsu.ui.common.BaseService
import org.koitharu.kotatsu.ui.common.dialog.CheckBoxAlertDialog import org.koitharu.kotatsu.ui.common.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.retryUntilSuccess
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.set import kotlin.collections.set
@@ -37,6 +35,7 @@ class DownloadService : BaseService() {
private lateinit var notification: DownloadNotification private lateinit var notification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var connectivityManager: ConnectivityManager
private val okHttp by inject<OkHttpClient>() private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>() private val cache by inject<PagesCache>()
@@ -47,6 +46,7 @@ class DownloadService : BaseService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notification = DownloadNotification(this) notification = DownloadNotification(this)
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
} }
@@ -76,10 +76,10 @@ class DownloadService : BaseService() {
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job { private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
return launch(Dispatchers.IO) { return launch(Dispatchers.IO) {
mutex.lock() mutex.lock()
wakeLock.acquire(TimeUnit.MINUTES.toMillis(20)) wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
notification.fillFrom(manga)
notification.setCancelId(startId)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
notification.fillFrom(manga)
notification.setCancelId(startId)
startForeground(DownloadNotification.NOTIFICATION_ID, notification()) startForeground(DownloadNotification.NOTIFICATION_ID, notification())
} }
val destination = settings.getStorageDir(this@DownloadService) val destination = settings.getStorageDir(this@DownloadService)
@@ -88,14 +88,14 @@ class DownloadService : BaseService() {
try { try {
val repo = MangaProviderFactory.create(manga.source) val repo = MangaProviderFactory.create(manga.source)
val cover = safe { val cover = safe {
Coil.execute(GetRequestBuilder(this@DownloadService) Coil.execute(
.data(manga.coverUrl) ImageRequest.Builder(this@DownloadService)
.build()).drawable .data(manga.coverUrl)
} .build()
withContext(Dispatchers.Main) { ).drawable
notification.setLargeIcon(cover)
notification.update()
} }
notification.setLargeIcon(cover)
notification.update()
val data = if (manga.chapters == null) repo.getDetails(manga) else manga val data = if (manga.chapters == null) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data) output = MangaZip.findInDir(destination, data)
output.prepare(data) output.prepare(data)
@@ -112,51 +112,52 @@ class DownloadService : BaseService() {
if (chaptersIds == null || chapter.id in chaptersIds) { 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()) {
val url = repo.getPageFullUrl(page) failsafe@ do {
val file = cache[url] ?: downloadPage(url, destination) try {
output.addPage( val url = repo.getPageFullUrl(page)
chapter, val file = cache[url] ?: downloadPage(url, destination)
file, output.addPage(
pageIndex, chapter,
MimeTypeMap.getFileExtensionFromUrl(url) file,
pageIndex,
MimeTypeMap.getFileExtensionFromUrl(url)
)
} catch (e: IOException) {
notification.setWaitingForNetwork()
notification.update()
connectivityManager.waitForNetwork()
continue@failsafe
}
} while (false)
notification.setProgress(
chapters.size,
pages.size,
chapterIndex,
pageIndex
) )
withContext(Dispatchers.Main) { notification.update()
notification.setProgress(
chapters.size,
pages.size,
chapterIndex,
pageIndex
)
notification.update()
}
} }
} }
} }
withContext(Dispatchers.Main) { notification.setCancelId(0)
notification.setCancelId(0) notification.setPostProcessing()
notification.setPostProcessing() notification.update()
notification.update()
}
output.compress() output.compress()
val result = MangaProviderFactory.createLocal().getFromFile(output.file) val result = MangaProviderFactory.createLocal().getFromFile(output.file)
withContext(Dispatchers.Main) { notification.setDone(result)
notification.setDone(result) notification.dismiss()
notification.dismiss() notification.update(manga.id.toInt().absoluteValue)
notification.update(manga.id.toInt().absoluteValue)
}
} catch (_: CancellationException) { } catch (_: CancellationException) {
withContext(Dispatchers.Main + NonCancellable) { withContext(NonCancellable) {
notification.setCancelling() notification.setCancelling()
notification.setCancelId(0) notification.setCancelId(0)
notification.update() notification.update()
} }
} catch (e: Throwable) { } catch (e: Throwable) {
withContext(Dispatchers.Main) { notification.setError(e)
notification.setError(e) notification.setCancelId(0)
notification.setCancelId(0) notification.dismiss()
notification.dismiss() notification.update(manga.id.toInt().absoluteValue)
notification.update(manga.id.toInt().absoluteValue)
}
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {
jobs.remove(startId) jobs.remove(startId)
@@ -167,7 +168,9 @@ class DownloadService : BaseService() {
notification.dismiss() notification.dismiss()
stopSelf(startId) stopSelf(startId)
} }
wakeLock.release() if (wakeLock.isHeld) {
wakeLock.release()
}
mutex.unlock() mutex.unlock()
} }
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@@ -55,6 +55,9 @@ class ListModeSelectDialog : AlertDialogFragment(R.layout.dialog_list_mode), Vie
private const val TAG = "ListModeSelectDialog" private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG) fun show(fm: FragmentManager) = ListModeSelectDialog()
.show(fm,
TAG
)
} }
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main package org.koitharu.kotatsu.ui.list
import android.app.ActivityOptions import android.app.ActivityOptions
import android.content.SharedPreferences import android.content.SharedPreferences
@@ -11,7 +11,6 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
@@ -21,20 +20,24 @@ import moxy.ktx.moxyPresenter
import org.koin.core.inject import org.koin.core.inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaProviderFactory import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BaseActivity import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.main.list.favourites.FavouritesListFragment import org.koitharu.kotatsu.ui.list.favourites.FavouritesContainerFragment
import org.koitharu.kotatsu.ui.main.list.history.HistoryListFragment import org.koitharu.kotatsu.ui.list.feed.FeedFragment
import org.koitharu.kotatsu.ui.main.list.local.LocalListFragment import org.koitharu.kotatsu.ui.list.history.HistoryListFragment
import org.koitharu.kotatsu.ui.main.list.remote.RemoteListFragment import org.koitharu.kotatsu.ui.list.local.LocalListFragment
import org.koitharu.kotatsu.ui.list.remote.RemoteListFragment
import org.koitharu.kotatsu.ui.reader.ReaderActivity import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.ui.reader.ReaderState import org.koitharu.kotatsu.ui.reader.ReaderState
import org.koitharu.kotatsu.ui.settings.AppUpdateService import org.koitharu.kotatsu.ui.search.SearchHelper
import org.koitharu.kotatsu.ui.settings.AppUpdateChecker
import org.koitharu.kotatsu.ui.settings.SettingsActivity import org.koitharu.kotatsu.ui.settings.SettingsActivity
import org.koitharu.kotatsu.ui.tracker.TrackWorker import org.koitharu.kotatsu.ui.tracker.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import java.io.Closeable
class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener, class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener,
SharedPreferences.OnSharedPreferenceChangeListener, MainView { SharedPreferences.OnSharedPreferenceChangeListener, MainView {
@@ -43,6 +46,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
private val settings by inject<AppSettings>() private val settings by inject<AppSettings>()
private lateinit var drawerToggle: ActionBarDrawerToggle private lateinit var drawerToggle: ActionBarDrawerToggle
private var closeable: Closeable? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -65,16 +69,14 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
supportFragmentManager.findFragmentById(R.id.container)?.let { supportFragmentManager.findFragmentById(R.id.container)?.let {
fab.isVisible = it is HistoryListFragment fab.isVisible = it is HistoryListFragment
} ?: run { } ?: run {
navigationView.setCheckedItem(R.id.nav_history) openDefaultSection()
setPrimaryFragment(HistoryListFragment.newInstance())
}
drawer.postDelayed(2000) {
AppUpdateService.startIfRequired(applicationContext)
} }
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
AppUpdateChecker(this).invoke()
} }
override fun onDestroy() { override fun onDestroy() {
closeable?.close()
settings.unsubscribe(this) settings.unsubscribe(this)
super.onDestroy() super.onDestroy()
} }
@@ -90,8 +92,19 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
drawerToggle.onConfigurationChanged(newConfig) drawerToggle.onConfigurationChanged(newConfig)
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onBackPressed() {
if (drawer.isDrawerOpen(navigationView)) {
drawer.closeDrawer(navigationView)
} else {
super.onBackPressed()
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_main, menu) menuInflater.inflate(R.menu.opt_main, menu)
menu.findItem(R.id.action_search)?.let { menuItem ->
closeable = SearchHelper.setupSearchView(menuItem)
}
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@@ -106,9 +119,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
val source = MangaSource.values().getOrNull(item.itemId) ?: return false val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source)) setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) { } else when (item.itemId) {
R.id.nav_history -> setPrimaryFragment(HistoryListFragment.newInstance()) R.id.nav_history -> {
R.id.nav_favourites -> setPrimaryFragment(FavouritesListFragment.newInstance()) settings.defaultSection = AppSection.HISTORY
R.id.nav_local_storage -> setPrimaryFragment(LocalListFragment.newInstance()) setPrimaryFragment(HistoryListFragment.newInstance())
}
R.id.nav_favourites -> {
settings.defaultSection = AppSection.FAVOURITES
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
R.id.nav_local_storage -> {
settings.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_feed -> {
settings.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance())
}
R.id.nav_action_settings -> { R.id.nav_action_settings -> {
startActivity(SettingsActivity.newIntent(this)) startActivity(SettingsActivity.newIntent(this))
return true return true
@@ -167,6 +193,27 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
private fun openDefaultSection() {
when(settings.defaultSection) {
AppSection.LOCAL -> {
navigationView.setCheckedItem(R.id.nav_local_storage)
setPrimaryFragment(LocalListFragment.newInstance())
}
AppSection.FAVOURITES -> {
navigationView.setCheckedItem(R.id.nav_favourites)
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
AppSection.HISTORY -> {
navigationView.setCheckedItem(R.id.nav_history)
setPrimaryFragment(HistoryListFragment.newInstance())
}
AppSection.FEED -> {
navigationView.setCheckedItem(R.id.nav_feed)
setPrimaryFragment(FeedFragment.newInstance())
}
}
}
private fun setPrimaryFragment(fragment: Fragment) { private fun setPrimaryFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment) .replace(R.id.container, fragment)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main package org.koitharu.kotatsu.ui.list
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.ui.main package org.koitharu.kotatsu.ui.list
import moxy.viewstate.strategy.alias.OneExecution import moxy.viewstate.strategy.alias.OneExecution
import org.koitharu.kotatsu.core.model.MangaState
import org.koitharu.kotatsu.ui.common.BaseMvpView import org.koitharu.kotatsu.ui.common.BaseMvpView
import org.koitharu.kotatsu.ui.reader.ReaderState import org.koitharu.kotatsu.ui.reader.ReaderState

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.view.ViewGroup import android.view.ViewGroup
import coil.api.clear import coil.clear
import coil.api.load import coil.load
import kotlinx.android.synthetic.main.item_manga_grid.* import kotlinx.android.synthetic.main.item_manga_grid.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.view.ViewGroup import android.view.ViewGroup
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
@@ -14,7 +14,9 @@ class MangaListAdapter(onItemClickListener: OnRecyclerItemClickListener<Manga>)
override fun onCreateViewHolder(parent: ViewGroup) = when(listMode) { override fun onCreateViewHolder(parent: ViewGroup) = when(listMode) {
ListMode.LIST -> MangaListHolder(parent) ListMode.LIST -> MangaListHolder(parent)
ListMode.DETAILED_LIST -> MangaListDetailsHolder(parent) ListMode.DETAILED_LIST -> MangaListDetailsHolder(
parent
)
ListMode.GRID -> MangaGridHolder(parent) ListMode.GRID -> MangaGridHolder(parent)
} }

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import coil.api.clear import coil.clear
import coil.api.load import coil.load
import kotlinx.android.synthetic.main.item_manga_list_details.* import kotlinx.android.synthetic.main.item_manga_list_details.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
@@ -9,10 +9,7 @@ import androidx.core.view.GravityCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.*
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_list.* import kotlinx.android.synthetic.main.fragment_list.*
@@ -28,23 +25,31 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BaseFragment import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener
import org.koitharu.kotatsu.ui.common.list.ProgressBarAdapter
import org.koitharu.kotatsu.ui.common.list.decor.ItemTypeDividerDecoration import org.koitharu.kotatsu.ui.common.list.decor.ItemTypeDividerDecoration
import org.koitharu.kotatsu.ui.common.list.decor.SectionItemDecoration import org.koitharu.kotatsu.ui.common.list.decor.SectionItemDecoration
import org.koitharu.kotatsu.ui.common.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.ui.common.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.ui.main.list.filter.FilterAdapter import org.koitharu.kotatsu.ui.list.filter.FilterAdapter
import org.koitharu.kotatsu.ui.main.list.filter.OnFilterChangedListener import org.koitharu.kotatsu.ui.list.filter.OnFilterChangedListener
import org.koitharu.kotatsu.utils.UiUtils import org.koitharu.kotatsu.utils.UiUtils
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), MangaListView<E>, abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list),
PaginationScrollListener.Callback, OnRecyclerItemClickListener<Manga>, MangaListView<E>, PaginationScrollListener.Callback, OnRecyclerItemClickListener<Manga>,
SharedPreferences.OnSharedPreferenceChangeListener, OnFilterChangedListener, SharedPreferences.OnSharedPreferenceChangeListener, OnFilterChangedListener,
SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener { SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener {
private val settings by inject<AppSettings>() private val settings by inject<AppSettings>()
private val adapterConfig = ConcatAdapter.Config.Builder()
.setIsolateViewTypes(true)
.setStableIdMode(ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS)
.build()
private var adapter: MangaListAdapter? = null private var adapter: MangaListAdapter? = null
private var progressAdapter: ProgressBarAdapter? = null
private var paginationListener : PaginationScrollListener? = null
protected var isSwipeRefreshEnabled = true
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -55,10 +60,11 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
adapter = MangaListAdapter(this) adapter = MangaListAdapter(this)
progressAdapter = ProgressBarAdapter()
paginationListener = PaginationScrollListener(4, this)
recyclerView.setHasFixedSize(true) recyclerView.setHasFixedSize(true)
initListMode(settings.listMode) initListMode(settings.listMode)
recyclerView.adapter = adapter recyclerView.addOnScrollListener(paginationListener!!)
recyclerView.addOnScrollListener(PaginationScrollListener(4, this))
swipeRefreshLayout.setOnRefreshListener(this) swipeRefreshLayout.setOnRefreshListener(this)
recyclerView_filter.setHasFixedSize(true) recyclerView_filter.setHasFixedSize(true)
recyclerView_filter.addItemDecoration(ItemTypeDividerDecoration(view.context)) recyclerView_filter.addItemDecoration(ItemTypeDividerDecoration(view.context))
@@ -71,6 +77,8 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
override fun onDestroyView() { override fun onDestroyView() {
adapter = null adapter = null
progressAdapter = null
paginationListener = null
settings.unsubscribe(this) settings.unsubscribe(this)
super.onDestroyView() super.onDestroyView()
} }
@@ -118,10 +126,12 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
} }
final override fun onRefresh() { final override fun onRefresh() {
swipeRefreshLayout.isRefreshing = true
onRequestMoreItems(0) onRequestMoreItems(0)
} }
override fun onListChanged(list: List<Manga>) { override fun onListChanged(list: List<Manga>) {
paginationListener?.reset()
adapter?.replaceData(list) adapter?.replaceData(list)
if (list.isEmpty()) { if (list.isEmpty()) {
setUpEmptyListHolder() setUpEmptyListHolder()
@@ -129,11 +139,16 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
} else { } else {
layout_holder.isVisible = false layout_holder.isVisible = false
} }
progressAdapter?.isProgressVisible = list.isNotEmpty()
recyclerView.callOnScrollListeners() recyclerView.callOnScrollListeners()
} }
override fun onListAppended(list: List<Manga>) { override fun onListAppended(list: List<Manga>) {
adapter?.appendData(list) adapter?.appendData(list)
progressAdapter?.isProgressVisible = list.isNotEmpty()
if (list.isNotEmpty()) {
layout_holder.isVisible = false
}
recyclerView.callOnScrollListeners() recyclerView.callOnScrollListeners()
} }
@@ -174,10 +189,11 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
override fun onLoadingStateChanged(isLoading: Boolean) { override fun onLoadingStateChanged(isLoading: Boolean) {
val hasItems = recyclerView.hasItems val hasItems = recyclerView.hasItems
progressBar.isVisible = isLoading && !hasItems progressBar.isVisible = isLoading && !hasItems
swipeRefreshLayout.isRefreshing = isLoading && hasItems swipeRefreshLayout.isEnabled = isSwipeRefreshEnabled && !progressBar.isVisible
swipeRefreshLayout.isEnabled = !progressBar.isVisible
if (isLoading) { if (isLoading) {
layout_holder.isVisible = false layout_holder.isVisible = false
} else {
swipeRefreshLayout.isRefreshing = false
} }
} }
@@ -230,11 +246,18 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
recyclerView.removeOnLayoutChangeListener(UiUtils.SpanCountResolver) recyclerView.removeOnLayoutChangeListener(UiUtils.SpanCountResolver)
adapter?.listMode = mode adapter?.listMode = mode
recyclerView.layoutManager = when (mode) { recyclerView.layoutManager = when (mode) {
ListMode.GRID -> GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx)) ListMode.GRID -> {
GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx)).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = if (position < getItemsCount())
1 else this@apply.spanCount
}
}
}
else -> LinearLayoutManager(ctx) else -> LinearLayoutManager(ctx)
} }
recyclerView.recycledViewPool.clear() recyclerView.recycledViewPool.clear()
recyclerView.adapter = adapter recyclerView.adapter = ConcatAdapter(adapterConfig, adapter, progressAdapter)
recyclerView.addItemDecoration( recyclerView.addItemDecoration(
when (mode) { when (mode) {
ListMode.LIST -> DividerItemDecoration(ctx, RecyclerView.VERTICAL) ListMode.LIST -> DividerItemDecoration(ctx, RecyclerView.VERTICAL)
@@ -251,6 +274,8 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
recyclerView.firstItem = position recyclerView.firstItem = position
} }
override fun getItemsCount() = adapter?.itemCount ?: 0
final override fun isSection(position: Int): Boolean { final override fun isSection(position: Int): Boolean {
return position == 0 || recyclerView_filter.adapter?.run { return position == 0 || recyclerView_filter.adapter?.run {
getItemViewType(position) != getItemViewType(position - 1) getItemViewType(position) != getItemViewType(position - 1)

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.view.ViewGroup import android.view.ViewGroup
import coil.api.clear import coil.clear
import coil.api.load import coil.load
import kotlinx.android.synthetic.main.item_manga_list.* import kotlinx.android.synthetic.main.item_manga_list.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
@@ -7,10 +7,7 @@ import android.view.View
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.*
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@@ -27,22 +24,30 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BaseBottomSheet import org.koitharu.kotatsu.ui.common.BaseBottomSheet
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener
import org.koitharu.kotatsu.ui.common.list.ProgressBarAdapter
import org.koitharu.kotatsu.ui.common.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.ui.common.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.utils.UiUtils import org.koitharu.kotatsu.utils.UiUtils
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaListView<E>, abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list),
MangaListView<E>,
PaginationScrollListener.Callback, OnRecyclerItemClickListener<Manga>, PaginationScrollListener.Callback, OnRecyclerItemClickListener<Manga>,
SharedPreferences.OnSharedPreferenceChangeListener, Toolbar.OnMenuItemClickListener { SharedPreferences.OnSharedPreferenceChangeListener, Toolbar.OnMenuItemClickListener {
private val settings by inject<AppSettings>() private val settings by inject<AppSettings>()
private val adapterConfig = ConcatAdapter.Config.Builder()
.setIsolateViewTypes(true)
.setStableIdMode(ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS)
.build()
private var adapter: MangaListAdapter? = null private var adapter: MangaListAdapter? = null
private var progressAdapter: ProgressBarAdapter? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
adapter = MangaListAdapter(this) adapter = MangaListAdapter(this)
progressAdapter = ProgressBarAdapter()
initListMode(settings.listMode) initListMode(settings.listMode)
recyclerView.adapter = adapter recyclerView.adapter = adapter
recyclerView.addOnScrollListener(PaginationScrollListener(4, this)) recyclerView.addOnScrollListener(PaginationScrollListener(4, this))
@@ -65,6 +70,7 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
override fun onDestroyView() { override fun onDestroyView() {
settings.unsubscribe(this) settings.unsubscribe(this)
adapter = null adapter = null
progressAdapter = null
super.onDestroyView() super.onDestroyView()
} }
@@ -122,6 +128,7 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
override fun onListChanged(list: List<Manga>) { override fun onListChanged(list: List<Manga>) {
adapter?.replaceData(list) adapter?.replaceData(list)
textView_holder.isVisible = list.isEmpty() textView_holder.isVisible = list.isEmpty()
progressAdapter?.isProgressVisible = list.isNotEmpty()
recyclerView.callOnScrollListeners() recyclerView.callOnScrollListeners()
} }
@@ -130,6 +137,7 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
if (list.isNotEmpty()) { if (list.isNotEmpty()) {
textView_holder.isVisible = false textView_holder.isVisible = false
} }
progressAdapter?.isProgressVisible = list.isNotEmpty()
recyclerView.callOnScrollListeners() recyclerView.callOnScrollListeners()
} }
@@ -137,6 +145,8 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
Snackbar.make(recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show() Snackbar.make(recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
} }
override fun getItemsCount() = adapter?.itemCount ?: 0
override fun onInitFilter( override fun onInitFilter(
sortOrders: List<SortOrder>, sortOrders: List<SortOrder>,
tags: List<MangaTag>, tags: List<MangaTag>,
@@ -170,10 +180,17 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
recyclerView.removeOnLayoutChangeListener(UiUtils.SpanCountResolver) recyclerView.removeOnLayoutChangeListener(UiUtils.SpanCountResolver)
adapter?.listMode = mode adapter?.listMode = mode
recyclerView.layoutManager = when (mode) { recyclerView.layoutManager = when (mode) {
ListMode.GRID -> GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx)) ListMode.GRID -> {
GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx)).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = if (position < getItemsCount())
1 else this@apply.spanCount
}
}
}
else -> LinearLayoutManager(ctx) else -> LinearLayoutManager(ctx)
} }
recyclerView.adapter = adapter recyclerView.adapter = ConcatAdapter(adapterConfig, adapter, progressAdapter)
recyclerView.addItemDecoration( recyclerView.addItemDecoration(
when (mode) { when (mode) {
ListMode.LIST -> DividerItemDecoration(ctx, RecyclerView.VERTICAL) ListMode.LIST -> DividerItemDecoration(ctx, RecyclerView.VERTICAL)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import moxy.viewstate.strategy.AddToEndSingleTagStrategy import moxy.viewstate.strategy.AddToEndSingleTagStrategy
import moxy.viewstate.strategy.AddToEndStrategy import moxy.viewstate.strategy.AddToEndStrategy

View File

@@ -0,0 +1,88 @@
package org.koitharu.kotatsu.ui.list.favourites
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.android.synthetic.main.fragment_favourites.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.domain.favourites.OnFavouritesChangeListener
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.list.favourites.categories.CategoriesActivity
import org.koitharu.kotatsu.ui.list.favourites.categories.FavouriteCategoriesPresenter
import org.koitharu.kotatsu.ui.list.favourites.categories.FavouriteCategoriesView
import java.util.*
import kotlin.collections.ArrayList
class FavouritesContainerFragment : BaseFragment(R.layout.fragment_favourites), FavouriteCategoriesView,
OnFavouritesChangeListener {
private val presenter by moxyPresenter(factory = ::FavouriteCategoriesPresenter)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = FavouritesPagerAdapter(this)
pager.adapter = adapter
TabLayoutMediator(tabs, pager, adapter).attach()
FavouritesRepository.subscribe(this)
}
override fun onDestroyView() {
FavouritesRepository.unsubscribe(this)
super.onDestroyView()
}
override fun onCategoriesChanged(categories: List<FavouriteCategory>) {
val data = ArrayList<FavouriteCategory>(categories.size + 1)
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, Date())
data += categories
(pager.adapter as? FavouritesPagerAdapter)?.replaceData(data)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_favourites, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_categories -> {
context?.let {
startActivity(CategoriesActivity.newIntent(it))
}
true
}
else -> super.onOptionsItemSelected(item)
}
override fun getTitle(): CharSequence? {
return getString(R.string.favourites)
}
override fun onCheckedCategoriesChanged(checkedIds: Set<Int>) = Unit
override fun onError(e: Throwable) {
Snackbar.make(pager, e.message ?: return, Snackbar.LENGTH_LONG).show()
}
override fun onFavouritesChanged(mangaId: Long) = Unit
override fun onCategoriesChanged() {
presenter.loadAllCategories()
}
companion object {
fun newInstance() = FavouritesContainerFragment()
}
}

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.ui.list.favourites
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.list.MangaListFragment
import org.koitharu.kotatsu.ui.list.MangaListView
import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment<Unit>(),
MangaListView<Unit> {
private val presenter by moxyPresenter(factory = ::FavouritesListPresenter)
private val categoryId: Long
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: 0L
override fun onRequestMoreItems(offset: Int) {
presenter.loadList(categoryId, offset)
}
override fun setUpEmptyListHolder() {
textView_holder.setText(if (categoryId == 0L) {
R.string.you_have_not_favourites_yet
} else {
R.string.favourites_category_empty
})
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}
companion object {
private const val ARG_CATEGORY_ID = "category_id"
fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) {
putLong(ARG_CATEGORY_ID, categoryId)
}
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list.favourites package org.koitharu.kotatsu.ui.list.favourites
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -9,7 +9,7 @@ import moxy.presenterScope
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.ui.common.BasePresenter import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.ui.main.list.MangaListView import org.koitharu.kotatsu.ui.list.MangaListView
@InjectViewState @InjectViewState
class FavouritesListPresenter : BasePresenter<MangaListView<Unit>>() { class FavouritesListPresenter : BasePresenter<MangaListView<Unit>>() {
@@ -21,12 +21,16 @@ class FavouritesListPresenter : BasePresenter<MangaListView<Unit>>() {
super.onFirstViewAttach() super.onFirstViewAttach()
} }
fun loadList(offset: Int) { fun loadList(categoryId: Long, offset: Int) {
presenterScope.launch { presenterScope.launch {
viewState.onLoadingStateChanged(true) viewState.onLoadingStateChanged(true)
try { try {
val list = withContext(Dispatchers.IO) { val list = withContext(Dispatchers.IO) {
repository.getAllManga(offset = offset) if (categoryId == 0L) {
repository.getAllManga(offset = offset)
} else {
repository.getManga(categoryId = categoryId, offset = offset)
}
} }
if (offset == 0) { if (offset == 0) {
viewState.onListChanged(list) viewState.onListChanged(list)

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.ui.list.favourites
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.ui.common.list.AdapterUpdater
import org.koitharu.kotatsu.utils.ext.replaceWith
class FavouritesPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment), TabLayoutMediator.TabConfigurationStrategy {
private val dataSet = ArrayList<FavouriteCategory>()
override fun getItemCount() = dataSet.size
override fun createFragment(position: Int): Fragment {
val item = dataSet[position]
return FavouritesListFragment.newInstance(item.id)
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = dataSet[position]
tab.text = item.title
}
fun replaceData(data: List<FavouriteCategory>) {
val updater = AdapterUpdater(dataSet, data, FavouriteCategory::id)
dataSet.replaceWith(data)
updater(this)
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list.favourites.categories package org.koitharu.kotatsu.ui.list.favourites.categories
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -7,10 +7,11 @@ import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.InputType import android.text.InputType
import android.view.View import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_categories.* import kotlinx.android.synthetic.main.activity_categories.*
import moxy.ktx.moxyPresenter import moxy.ktx.moxyPresenter
@@ -28,6 +29,7 @@ class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<Favourite
private val presenter by moxyPresenter(factory = ::FavouriteCategoriesPresenter) private val presenter by moxyPresenter(factory = ::FavouriteCategoriesPresenter)
private lateinit var adapter: CategoriesAdapter private lateinit var adapter: CategoriesAdapter
private lateinit var reorderHelper: ItemTouchHelper
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -38,6 +40,8 @@ class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<Favourite
recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
recyclerView.adapter = adapter recyclerView.adapter = adapter
fab_add.setOnClickListener(this) fab_add.setOnClickListener(this)
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
reorderHelper.attachToRecyclerView(recyclerView)
} }
override fun onClick(v: View) { override fun onClick(v: View) {
@@ -56,6 +60,11 @@ class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<Favourite
} }
} }
override fun onItemLongClick(item: FavouriteCategory, position: Int, view: View): Boolean {
reorderHelper.startDrag(recyclerView.findViewHolderForAdapterPosition(position) ?: return false)
return true
}
override fun onCategoriesChanged(categories: List<FavouriteCategory>) { override fun onCategoriesChanged(categories: List<FavouriteCategory>) {
adapter.replaceData(categories) adapter.replaceData(categories)
textView_holder.isVisible = categories.isEmpty() textView_holder.isVisible = categories.isEmpty()
@@ -69,7 +78,7 @@ class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<Favourite
} }
private fun deleteCategory(category: FavouriteCategory) { private fun deleteCategory(category: FavouriteCategory) {
AlertDialog.Builder(this) MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.category_delete_confirm, category.title)) .setMessage(getString(R.string.category_delete_confirm, category.title))
.setTitle(R.string.remove_category) .setTitle(R.string.remove_category)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
@@ -106,6 +115,25 @@ class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<Favourite
.show() .show()
} }
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val oldPos = viewHolder.bindingAdapterPosition
val newPos = target.bindingAdapterPosition
adapter.moveItem(oldPos, newPos)
presenter.storeCategoriesOrder(adapter.items.map { it.id })
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
}
companion object { companion object {
fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java) fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java)

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