Compare commits

..

56 Commits

Author SHA1 Message Date
Koitharu
5d1a2fcf77 Statistics filters 2024-03-04 16:31:39 +02:00
Koitharu
876675445d Stats chart for single manga 2024-03-04 14:42:31 +02:00
Koitharu
f7a70680bd Timeline stats per manga 2024-03-01 15:00:38 +02:00
Koitharu
8e82db441c Empty stats state 2024-03-01 10:34:31 +02:00
Koitharu
f2626c668d Switch and click preference 2024-02-29 16:15:44 +02:00
Koitharu
4694215ccc Statistics periods 2024-02-29 15:28:57 +02:00
Koitharu
096f5b15dc Clearing stats 2024-02-29 14:27:52 +02:00
Koitharu
101d357eff Stats activity 2024-02-29 14:01:31 +02:00
Koitharu
11cd5609bb Use stats for reading time estimation 2024-02-29 12:12:09 +02:00
Koitharu
fda59996aa Improve stats ui 2024-02-29 12:01:09 +02:00
Koitharu
20461112d2 Merge branch 'devel' into feature/stats 2024-02-29 11:20:31 +02:00
Koitharu
f98bb87d6e Use numeric keyboard if app password is numeric 2024-02-29 11:20:10 +02:00
Koitharu
c451952a1e Merge branch 'devel' into feature/stats 2024-02-29 10:00:49 +02:00
Koitharu
f8cbc9692f Fix local manga directories chapters 2024-02-28 16:12:49 +02:00
Koitharu
9f3113363b Merge remote-tracking branch 'weblate/devel' into devel 2024-02-28 14:40:02 +02:00
Koitharu
dba36838d4 Download format preference 2024-02-28 14:28:59 +02:00
Koitharu
f6de1b02d7 Fix download item ui 2024-02-28 14:06:08 +02:00
abc0922001
d6b8e2fd9e Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
a
5227240478 Translated using Weblate (Portuguese)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: a <cooki3yt2004@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Infy's Tagalog Translations
8f65ea6535 Translated using Weblate (Filipino)
Currently translated at 99.8% (596 of 597 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Kyoya
7d7a6eadd2 Translated using Weblate (Turkish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Kyoya <thelol9181@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Scrambled777
40f1ad3181 Translated using Weblate (Hindi)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Anon
a28c9447d7 Translated using Weblate (Serbian)
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-28 12:59:46 +01:00
gallegonovato
a84cf97982 Translated using Weblate (Spanish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Lokmane Abdelhakim Djilani
3a8eb58fd1 Translated using Weblate (Arabic)
Currently translated at 58.1% (347 of 597 strings)

Co-authored-by: Lokmane Abdelhakim Djilani <lokdabdo@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
gekka
5d75e9af4a Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Oğuz Ersen
d4684e7462 Translated using Weblate (Turkish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Çınar
c0a2f0b533 Translated using Weblate (Turkish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Çınar <cinardogan110@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Макар Разин
40867dd2b6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Koitharu
c3294e6459 Fix double pages mode enabling 2024-02-28 13:58:23 +02:00
Koitharu
5139feb51a Fix pages saving 2024-02-28 13:55:02 +02:00
Koitharu
6b1240fccb Fix crashes 2024-02-24 14:26:31 +02:00
Koitharu
e00a5b7505 Fix open Kitsu auth #773 2024-02-24 13:50:48 +02:00
Koitharu
2c07d2c8e1 Increase Kitsu password max length #774 2024-02-24 13:17:03 +02:00
Koitharu
45c3c05f01 Fix updating history in incognito mode #783 2024-02-24 13:13:35 +02:00
Koitharu
e97a745713 Fix filter ui issue #779 2024-02-24 12:50:59 +02:00
Koitharu
2dc4de0a3c Update dependencies 2024-02-24 12:25:44 +02:00
Koitharu
3cf2c58058 Local manga info dialog 2024-02-24 12:16:26 +02:00
Scrambled777
1e19f32fc5 Translated using Weblate (Hindi)
Currently translated at 22.6% (135 of 596 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Naga
99e4359523 Translated using Weblate (English)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Naga <yz2000.pro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Alex Georgiou
04868488cc Translated using Weblate (English)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Naga
2b3b406b84 Translated using Weblate (French)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (French)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (French)

Currently translated at 99.4% (593 of 596 strings)

Co-authored-by: Naga <yz2000.pro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-24 12:14:04 +02:00
Infy's Tagalog Translations
7ab3c75232 Translated using Weblate (Filipino)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-24 12:14:04 +02:00
Макар Разин
61f7755465 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
GpixeL
9389015ab9 Translated using Weblate (Indonesian)
Currently translated at 97.6% (582 of 596 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Сергій
bc56a94aa6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Madaraki
7cfcaec6dd Translated using Weblate (Russian)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
LaFouine-38
39c7ae31cd Translated using Weblate (French)
Currently translated at 92.6% (552 of 596 strings)

Co-authored-by: LaFouine-38 <thomasjb0208@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Sergio Varela
9349eccc0c Translated using Weblate (Spanish)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Sergio Varela <sergitroll9@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
gekka
8204934359 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (595 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.4% (592 of 595 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Oğuz Ersen
b5497c571e Translated using Weblate (Turkish)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (595 of 595 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Koitharu
35a2ac4b04 Simple reading stats display 2024-02-21 09:49:47 +02:00
Koitharu
b4d52f1367 Fix track worker notification behavior 2024-02-20 12:46:12 +02:00
Zakhar Timoshenko
325a8be484 Fix wrong sources count if NSFW sources are disabled 2024-02-18 23:56:19 +03:00
Koitharu
f39ccb6223 Stats settings 2024-02-18 13:38:46 +02:00
Koitharu
6cb6c891dd Collecting reading stats 2024-02-18 13:11:41 +02:00
135 changed files with 3624 additions and 1574 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 624
versionName = '6.7.2'
versionCode = 626
versionName = '6.7.4'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:3ff028c4e9') {
implementation('com.github.KotatsuApp:kotatsu-parsers:103f578c61') {
exclude group: 'org.json', module: 'json'
}
@@ -126,13 +126,13 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.50'
kapt 'com.google.dagger:hilt-compiler:2.50'
implementation 'androidx.hilt:hilt-work:1.1.0'
kapt 'androidx.hilt:hilt-compiler:1.1.0'
implementation 'com.google.dagger:hilt-android:2.51'
kapt 'com.google.dagger:hilt-compiler:2.51'
implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0'
implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'io.coil-kt:coil-base:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
@@ -160,6 +160,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51'
}

View File

@@ -57,6 +57,7 @@ class AppShortcutManagerTest {
page = 4,
scroll = 2,
percent = 0.3f,
force = false,
)
awaitUpdate()

View File

@@ -7,7 +7,9 @@ import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -61,6 +63,7 @@ class AppBackupAgentTest {
page = 3,
scroll = 40,
percent = 0.2f,
force = false,
)
val history = checkNotNull(historyRepository.getOne(SampleData.manga))

View File

@@ -239,6 +239,9 @@
<data android:scheme="kotatsu+kitsu" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.stats.ui.StatsActivity"
android:label="@string/reading_stats" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"

View File

@@ -14,7 +14,6 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import com.google.android.material.R as materialR
@@ -26,7 +25,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
supportActionBar?.run {

View File

@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject
@@ -45,13 +44,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability {
setContentView(
ActivityBrowserBinding.inflate(
layoutInflater,
),
)
}) {
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
supportActionBar?.run {

View File

@@ -54,6 +54,7 @@ class JsonDeserializer(private val json: JSONObject) {
page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
chaptersCount = json.getIntOrDefault("chapters", -1),
deletedAt = 0L,
)

View File

@@ -41,6 +41,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("page", e.page)
put("scroll", e.scroll)
put("percent", e.percent)
put("chapters", e.chaptersCount)
},
)

View File

@@ -30,6 +30,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@@ -48,20 +49,22 @@ import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.stats.data.StatsDao
import org.koitharu.kotatsu.stats.data.StatsEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 18
const val DATABASE_VERSION = 19
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
],
version = DATABASE_VERSION,
)
@@ -90,6 +93,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getScrobblingDao(): ScrobblingDao
abstract fun getSourcesDao(): MangaSourcesDao
abstract fun getStatsDao(): StatsDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -110,6 +115,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration15To16(),
Migration16To17(context),
Migration17To18(),
Migration18To19(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration18To19 : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.isNumeric
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
@@ -191,11 +192,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit {
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
KEY_APP_PASSWORD,
)
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD)
}
var isAppPasswordNumeric: Boolean
get() = prefs.getBoolean(KEY_APP_PASSWORD_NUMERIC, false)
set(value) = prefs.edit { putBoolean(KEY_APP_PASSWORD_NUMERIC, value) }
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
@@ -277,6 +280,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
val preferredDownloadFormat: DownloadFormat
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
var isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
@@ -416,6 +422,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isPagesSavingAskEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true)
val isStatsEnabled: Boolean
get() = prefs.getBoolean(KEY_STATS_ENABLED, false)
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
@@ -430,7 +439,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
fun getPagesSaveDir(context: Context): DocumentFile? =
prefs.getString(KEY_PAGES_SAVE_DIR, null)?.toUriOrNull()?.let {
DocumentFile.fromTreeUri(context, it)
DocumentFile.fromTreeUri(context, it)?.takeIf { it.canWrite() }
}
fun setPagesSaveDir(uri: Uri?) {
@@ -525,6 +534,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password"
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app"
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version"
@@ -552,6 +562,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_MAL = "mal"
const val KEY_KITSU = "kitsu"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_DOWNLOADS_FORMAT = "downloads_format"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
const val KEY_EXIT_CONFIRM = "exit_confirm"
@@ -606,8 +617,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READING_TIME = "reading_time"
const val KEY_PAGES_SAVE_DIR = "pages_dir"
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
// About
const val KEY_STATS_ENABLED = "stats_on"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.prefs
enum class DownloadFormat {
AUTOMATIC,
SINGLE_CBZ,
MULTIPLE_CBZ,
}

View File

@@ -8,6 +8,7 @@ import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
@@ -29,6 +30,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
@Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> :
@@ -164,6 +166,21 @@ abstract class BaseActivity<B : ViewBinding> :
intent?.putExtra(EXTRA_DATA, intent.data)
}
protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean {
return try {
setContentView(viewBindingProducer())
true
} catch (e: Exception) {
if (e.isWebViewUnavailable()) {
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
finishAfterTransition()
false
} else {
throw e
}
}
}
companion object {
const val EXTRA_DATA = "data"

View File

@@ -29,8 +29,9 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
return this
}
fun addListListener(listListener: ListListener<T>) {
fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> {
differ.addListListener(listListener)
return this
}
fun removeListListener(listListener: ListListener<T>) {

View File

@@ -68,6 +68,13 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.call(error)
}
protected inline suspend fun <T> withLoading(block: () -> T): T = try {
loadingCounter.increment()
block()
} finally {
loadingCounter.decrement()
}
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }

View File

@@ -68,6 +68,14 @@ class RecyclerViewAlertDialog private constructor(
return this
}
fun setNeutralButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener,
): Builder<T> {
delegate.setNeutralButton(textId, listener)
return this
}
fun setCancelable(isCancelable: Boolean): Builder<T> {
delegate.setCancelable(isCancelable)
return this

View File

@@ -12,11 +12,10 @@ import android.graphics.RectF
import android.graphics.drawable.Drawable
import androidx.annotation.StyleRes
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.withClip
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import kotlin.math.absoluteValue
import org.koitharu.kotatsu.core.util.KotatsuColors
class FaviconDrawable(
context: Context,
@@ -44,7 +43,7 @@ class FaviconDrawable(
}
paint.textAlign = Paint.Align.CENTER
paint.isFakeBoldText = true
colorForeground = MaterialColors.harmonize(colorOfString(name), colorBackground)
colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
}
override fun draw(canvas: Canvas) {
@@ -104,9 +103,4 @@ class FaviconDrawable(
paint.getTextBounds(text, 0, text.length, tempRect)
return testTextSize * width / tempRect.width()
}
private fun colorOfString(str: String): Int {
val hue = (str.hashCode() % 360).absoluteValue.toFloat()
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
}
}

View File

@@ -110,7 +110,7 @@ class ChipsView @JvmOverloads constructor(
chip.isChipIconVisible = false
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
chip.setEnsureMinTouchTargetSize(false) // TODO remove
chip.setOnClickListener(chipOnClickListener)
addView(chip)
return chip

View File

@@ -1,397 +0,0 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.CornerPathEffect
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Build
import android.os.Parcelable
import android.text.Layout
import android.text.StaticLayout
import android.text.TextDirectionHeuristic
import android.text.TextDirectionHeuristics
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import androidx.annotation.RequiresApi
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.draw
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.resolveSp
class PieChart @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), PieChartInterface {
private var marginTextFirst: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_1)
private var marginTextSecond: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_2)
private var marginTextThird: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_3)
private var marginSmallCircle: Float = context.resources.resolveDp(DEFAULT_MARGIN_SMALL_CIRCLE)
private val marginText: Float = marginTextFirst + marginTextSecond
private val circleRect = RectF()
private var circleStrokeWidth: Float = context.resources.resolveDp(6f)
private var circleRadius: Float = 0f
private var circlePadding: Float = context.resources.resolveDp(8f)
private var circlePaintRoundSize: Boolean = true
private var circleSectionSpace: Float = 3f
private var circleCenterX: Float = 0f
private var circleCenterY: Float = 0f
private var numberTextPaint: TextPaint = TextPaint()
private var descriptionTextPain: TextPaint = TextPaint()
private var amountTextPaint: TextPaint = TextPaint()
private var textStartX: Float = 0f
private var textStartY: Float = 0f
private var textHeight: Int = 0
private var textCircleRadius: Float = context.resources.resolveDp(4f)
private var textAmountStr: String = ""
private var textAmountY: Float = 0f
private var textAmountXNumber: Float = 0f
private var textAmountXDescription: Float = 0f
private var textAmountYDescription: Float = 0f
private var totalAmount: Int = 0
private var pieChartColors: List<String> = listOf()
private var percentageCircleList: List<PieChartModel> = listOf()
private var textRowList: MutableList<StaticLayout> = mutableListOf()
private var dataList: List<Pair<Int, String>> = listOf()
private var animationSweepAngle: Int = 0
init {
var textAmountSize: Float = context.resources.resolveSp(22f)
var textNumberSize: Float = context.resources.resolveSp(20f)
var textDescriptionSize: Float = context.resources.resolveSp(14f)
var textAmountColor: Int = Color.WHITE
var textNumberColor: Int = Color.WHITE
var textDescriptionColor: Int = Color.GRAY
if (attrs != null) {
val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart)
val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0)
pieChartColors = typeArray.resources.getStringArray(colorResId).toList()
marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst)
marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond)
marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird)
marginSmallCircle =
typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle)
circleStrokeWidth =
typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth)
circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding)
circlePaintRoundSize =
typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize)
circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace)
textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius)
textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize)
textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize)
textDescriptionSize =
typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize)
textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor)
textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor)
textDescriptionColor =
typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor)
textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: ""
typeArray.recycle()
}
circlePadding += circleStrokeWidth
// Инициализация кистей View
initPaints(amountTextPaint, textAmountSize, textAmountColor)
initPaints(numberTextPaint, textNumberSize, textNumberColor)
initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true)
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
textRowList.clear()
val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH)
val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT)
val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt())
textStartX = initSizeWidth - textTextWidth.toFloat()
textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2
calculateCircleRadius(initSizeWidth, initSizeHeight)
setMeasuredDimension(initSizeWidth, initSizeHeight)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawCircle(canvas)
drawText(canvas)
}
override fun onRestoreInstanceState(state: Parcelable?) {
val pieChartState = state as? PieChartState
super.onRestoreInstanceState(pieChartState?.superState ?: state)
dataList = pieChartState?.dataList ?: listOf()
}
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return PieChartState(superState, dataList)
}
override fun setDataChart(list: List<Pair<Int, String>>) {
dataList = list
calculatePercentageOfData()
}
override fun startAnimation() {
val animator = ValueAnimator.ofInt(0, 360).apply {
duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
interpolator = FastOutSlowInInterpolator()
addUpdateListener { valueAnimator ->
animationSweepAngle = valueAnimator.animatedValue as Int
invalidate()
}
}
animator.start()
}
private fun drawCircle(canvas: Canvas) {
for (percent in percentageCircleList) {
if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) {
canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint)
} else if (animationSweepAngle > percent.percentToStartAt) {
canvas.drawArc(
circleRect,
percent.percentToStartAt,
animationSweepAngle - percent.percentToStartAt,
false,
percent.paint,
)
}
}
}
private fun drawText(canvas: Canvas) {
var textBuffY = textStartY
textRowList.forEachIndexed { index, staticLayout ->
if (index % 2 == 0) {
staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY)
canvas.drawCircle(
textStartX + marginSmallCircle / 2,
textBuffY + staticLayout.height / 2 + textCircleRadius / 2,
textCircleRadius,
Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) },
)
textBuffY += staticLayout.height + marginTextFirst
} else {
staticLayout.draw(canvas, textStartX, textBuffY)
textBuffY += staticLayout.height + marginTextSecond
}
}
canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint)
canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain)
}
private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) {
textPaint.color = textColor
textPaint.textSize = textSize
textPaint.isAntiAlias = true
if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
private fun resolveDefaultSize(spec: Int, defValue: Int): Int {
return when (MeasureSpec.getMode(spec)) {
MeasureSpec.UNSPECIFIED -> resources.resolveDp(defValue)
else -> MeasureSpec.getSize(spec)
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int {
val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT)
textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt()
val textHeightWithPadding = textHeight + paddingTop + paddingBottom
return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight
}
private fun calculateCircleRadius(width: Int, height: Int) {
val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT)
circleRadius = if (circleViewWidth > height) {
(height.toFloat() - circlePadding) / 2
} else {
circleViewWidth.toFloat() / 2
}
with(circleRect) {
left = circlePadding
top = height / 2 - circleRadius
right = circleRadius * 2 + circlePadding
bottom = height / 2 + circleRadius
}
circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2
circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2
textAmountY = circleCenterY
val sizeTextAmountNumber = getWidthOfAmountText(
totalAmount.toString(),
amountTextPaint,
)
textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2
textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2
textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getTextViewHeight(maxWidth: Int): Int {
var textHeight = 0
dataList.forEach {
val textLayoutNumber = getMultilineText(
text = it.first.toString(),
textPaint = numberTextPaint,
width = maxWidth,
)
val textLayoutDescription = getMultilineText(
text = it.second,
textPaint = descriptionTextPain,
width = maxWidth,
)
textRowList.apply {
add(textLayoutNumber)
add(textLayoutDescription)
}
textHeight += textLayoutNumber.height + textLayoutDescription.height
}
return textHeight
}
private fun calculatePercentageOfData() {
totalAmount = dataList.fold(0) { res, value -> res + value.first }
var startAt = circleSectionSpace
percentageCircleList = dataList.mapIndexed { index, pair ->
var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace
percent = if (percent < 0f) 0f else percent
val resultModel = PieChartModel(
percentOfCircle = percent,
percentToStartAt = startAt,
colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]),
stroke = circleStrokeWidth,
paintRound = circlePaintRoundSize,
)
if (percent != 0f) startAt += percent + circleSectionSpace
resultModel
}
}
private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect {
val bounds = Rect()
textPaint.getTextBounds(text, 0, text.length, bounds)
return bounds
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getMultilineText(
text: CharSequence,
textPaint: TextPaint,
width: Int,
start: Int = 0,
end: Int = text.length,
alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR,
spacingMult: Float = 1f,
spacingAdd: Float = 0f
): StaticLayout {
return StaticLayout.Builder
.obtain(text, start, end, textPaint, width)
.setAlignment(alignment)
.setTextDirection(textDir)
.setLineSpacing(spacingAdd, spacingMult)
.build()
}
companion object {
private const val DEFAULT_MARGIN_TEXT_1 = 2f
private const val DEFAULT_MARGIN_TEXT_2 = 10f
private const val DEFAULT_MARGIN_TEXT_3 = 2f
private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12f
private const val TEXT_WIDTH_PERCENT = 0.40
private const val CIRCLE_WIDTH_PERCENT = 0.50
const val DEFAULT_VIEW_SIZE_HEIGHT = 150
const val DEFAULT_VIEW_SIZE_WIDTH = 250
}
}
interface PieChartInterface {
fun setDataChart(list: List<Pair<Int, String>>)
fun startAnimation()
}
data class PieChartModel(
var percentOfCircle: Float = 0f,
var percentToStartAt: Float = 0f,
var colorOfLine: Int = 0,
var stroke: Float = 0f,
var paint: Paint = Paint(),
var paintRound: Boolean = true
) {
init {
if (percentOfCircle < 0 || percentOfCircle > 100) {
percentOfCircle = 100f
}
percentOfCircle = 360 * percentOfCircle / 100
if (percentToStartAt < 0 || percentToStartAt > 100) {
percentToStartAt = 0f
}
percentToStartAt = 360 * percentToStartAt / 100
if (colorOfLine == 0) {
colorOfLine = Color.parseColor("#000000")
}
paint = Paint()
paint.color = colorOfLine
paint.isAntiAlias = true
paint.style = Paint.Style.STROKE
paint.strokeWidth = stroke
paint.isDither = true
if (paintRound) {
paint.strokeJoin = Paint.Join.ROUND
paint.strokeCap = Paint.Cap.ROUND
paint.pathEffect = CornerPathEffect(8f)
}
}
}
class PieChartState(
superSavedState: Parcelable?,
val dataList: List<Pair<Int, String>>
) : View.BaseSavedState(superSavedState), Parcelable

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.core.util
import android.content.Context
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
import com.google.android.material.R
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.math.absoluteValue
object KotatsuColors {
@ColorInt
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
val colorHex = String.format("%06x", context.getThemeColor(resId))
val hue = getHue(colorHex)
val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
@ColorInt
fun random(seed: Any): Int {
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
}
@ColorInt
fun ofManga(context: Context, manga: Manga?): Int {
val color = if (manga != null) {
val hue = (manga.id.absoluteValue % 360).toFloat()
ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
} else {
context.getThemeColor(R.attr.colorSurface)
}
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
private fun getHue(hex: String): Float {
val r = (hex.substring(0, 2).toInt(16)).toFloat()
val g = (hex.substring(2, 4).toInt(16)).toFloat()
val b = (hex.substring(4, 6).toInt(16)).toFloat()
var hue = 0F
if ((r >= g) && (g >= b)) {
hue = 60 * (g - b) / (r - b)
} else if ((g > r) && (r >= b)) {
hue = 60 * (2 - (r - b) / (g - b))
}
return hue
}
}

View File

@@ -27,7 +27,6 @@ import android.provider.Settings
import android.view.View
import android.view.ViewPropertyAnimator
import android.view.Window
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes
import androidx.annotation.WorkerThread
@@ -216,21 +215,6 @@ fun Context.findActivity(): Activity? = when (this) {
else -> null
}
inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean {
return try {
block()
true
} catch (e: Exception) {
if (e.isWebViewUnavailable()) {
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
finishAfterTransition()
false
} else {
throw e
}
}
}
fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
} else {

View File

@@ -19,6 +19,7 @@ import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.PathWalkOption
import kotlin.io.path.readAttributes
import kotlin.io.path.walk
@@ -72,7 +73,7 @@ fun ContentResolver.resolveName(uri: Uri): String? {
}
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
walkCompat().sumOf { it.length() }
walkCompat(includeDirectories = false).sumOf { it.length() }
}
fun File.children() = FileSequence(this)
@@ -87,10 +88,16 @@ val File.creationTime
}
@OptIn(ExperimentalPathApi::class)
fun File.walkCompat() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
fun File.walkCompat(includeDirectories: Boolean): Sequence<File> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Use lazy loading on Android 8.0 and later
toPath().walk().map { it.toFile() }
val walk = if (includeDirectories) {
toPath().walk(PathWalkOption.INCLUDE_DIRECTORIES)
} else {
toPath().walk()
}
walk.map { it.toFile() }
} else {
// Directories are excluded by default in Path.walk(), so do it here as well
walk().filter { it.isFile }
val walk = walk()
if (includeDirectories) walk else walk.filter { it.isFile }
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.ActivityNotFoundException
import android.content.res.Resources
import android.util.AndroidRuntimeException
import androidx.annotation.DrawableRes
import androidx.collection.arraySetOf
import coil.network.HttpException
@@ -115,8 +114,8 @@ private val reportableExceptions = arraySetOf<Class<*>>(
)
fun Throwable.isWebViewUnavailable(): Boolean {
return (this is AndroidRuntimeException && message?.contains("WebView") == true) ||
cause?.isWebViewUnavailable() == true
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")
}
@Suppress("FunctionName")

View File

@@ -10,6 +10,7 @@ data class ReadingTime(
) {
fun format(resources: Resources): String = when {
hours == 0 && minutes == 0 -> resources.getString(R.string.less_than_minute)
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
else -> resources.getString(

View File

@@ -5,25 +5,27 @@ import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.stats.data.StatsRepository
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.math.roundToInt
class ReadingTimeUseCase @Inject constructor(
private val settings: AppSettings,
private val statsRepository: StatsRepository,
) {
fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
suspend fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
if (!settings.isReadingTimeEstimationEnabled) {
return null
}
// FIXME MAXIMUM HARDCODE!!! To do calculation with user's page read speed and his favourites/history mangas average pages in chapter
val chapters = manga?.chapters?.get(branch)
if (chapters.isNullOrEmpty()) {
return null
}
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
// Impossible task, I guess. Good luck on this.
var averageTimeSec: Int = 20 * 10 * chapters.size // 20 pages, 10 seconds per page
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
if (isOnHistoryBranch) {
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
}
@@ -36,4 +38,16 @@ class ReadingTimeUseCase @Inject constructor(
isContinue = isOnHistoryBranch,
)
}
private suspend fun getSecondsPerPage(mangaId: Long): Int {
var time = if (settings.isStatsEnabled) {
TimeUnit.MILLISECONDS.toSeconds(statsRepository.getTimePerPage(mangaId)).toInt()
} else {
0
}
if (time == 0) {
time = 10 // default
}
return time
}
}

View File

@@ -138,7 +138,10 @@ class DetailsActivity :
},
),
)
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom))
viewModel.onActionDone.observeEvent(
this,
ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom),
)
viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) {
@@ -150,6 +153,7 @@ class DetailsActivity :
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.isStatsEnabled.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1

View File

@@ -62,6 +62,7 @@ import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -102,6 +103,7 @@ class DetailsFragment :
binding.buttonScrobblingMore.setOnClickListener(this)
binding.buttonRelatedMore.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.infoLayout.textViewSize.setOnClickListener(this)
binding.textViewDescription.addOnLayoutChangeListener(this)
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
@@ -324,6 +326,10 @@ class DetailsFragment :
)
}
R.id.textView_size -> {
LocalInfoDialog.show(parentFragmentManager, manga)
}
R.id.imageView_cover -> {
startActivity(
ImageActivity.newIntent(

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
class DetailsMenuProvider(
private val activity: FragmentActivity,
@@ -43,6 +44,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsEnabled.value
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
@@ -101,6 +103,12 @@ class DetailsMenuProvider(
}
}
R.id.action_stats -> {
viewModel.manga.value?.let {
MangaStatsSheet.show(activity.supportFragmentManager, it)
}
}
R.id.action_scrobbling -> {
viewModel.manga.value?.let {
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)

View File

@@ -100,6 +100,10 @@ class DetailsViewModel @Inject constructor(
val favouriteCategories = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val isStatsEnabled = settings.observeAsStateFlow(viewModelScope + Dispatchers.Default, AppSettings.KEY_STATS_ENABLED) {
isStatsEnabled
}
val remoteManga = MutableStateFlow<Manga?>(null)
val newChaptersCount = details.flatMapLatest { d ->
@@ -320,6 +324,7 @@ class DetailsViewModel @Inject constructor(
page = 0,
scroll = 0,
percent = percent,
force = true,
)
}
}

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadDialogHelper(
private val host: View,
@@ -57,6 +58,9 @@ class DownloadDialogHelper(
.setCancelable(true)
.setTitle(R.string.download)
.setNegativeButton(android.R.string.cancel)
.setNeutralButton(R.string.settings) { _, _ ->
host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context))
}
.setItems(options)
.create()
.also { it.show() }

View File

@@ -91,6 +91,7 @@ class DownloadWorker @AssistedInject constructor(
private val localMangaRepository: LocalMangaRepository,
private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) {
@@ -182,7 +183,7 @@ class DownloadWorker @AssistedInject constructor(
}
val repo = mangaRepositoryFactory.create(manga.source)
val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, mangaDetails)
output = LocalMangaOutput.getOrCreate(destination, mangaDetails, settings.preferredDownloadFormat)
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
if (coverUrl.isNotEmpty()) {
downloadFile(coverUrl, destination, repo.source).let { file ->

View File

@@ -57,7 +57,7 @@ class MangaSourcesRepository @Inject constructor(
observeIsNsfwDisabled(),
dao.observeEnabled(SourcesSortOrder.MANUAL),
) { skipNsfw, sources ->
sources.count { skipNsfw || !MangaSource(it.source).isNsfw() }
sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() }
}.distinctUntilChanged()
}

View File

@@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
)
]
),
],
)
data class HistoryEntity(
@PrimaryKey(autoGenerate = false)
@@ -28,4 +28,5 @@ data class HistoryEntity(
@ColumnInfo(name = "scroll") val scroll: Float,
@ColumnInfo(name = "percent") val percent: Float,
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
@ColumnInfo(name = "chapters") val chaptersCount: Int,
)

View File

@@ -90,10 +90,11 @@ class HistoryRepository @Inject constructor(
.distinctUntilChanged()
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
if (shouldSkip(manga)) {
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float, force: Boolean) {
if (!force && shouldSkip(manga)) {
return
}
assert(manga.chapters != null)
db.withTransaction {
mangaRepository.storeManga(manga)
db.getHistoryDao().upsert(
@@ -105,6 +106,7 @@ class HistoryRepository @Inject constructor(
page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
percent = percent,
chaptersCount = manga.chapters?.size ?: -1,
deletedAt = 0L,
),
)

View File

@@ -24,6 +24,7 @@ class HistoryUpdateUseCase @Inject constructor(
page = readerState.page,
scroll = readerState.scroll,
percent = percent,
force = false,
)
}

View File

@@ -30,6 +30,7 @@ class MarkAsReadUseCase @Inject constructor(
page = pages.lastIndex,
scroll = 0,
percent = 1f,
force = true,
)
}

View File

@@ -11,7 +11,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
@@ -27,6 +29,7 @@ class HistoryListFragment : MangaListFragment() {
super.onViewBindingCreated(binding, savedInstanceState)
RecyclerScrollKeeper(binding.recyclerView).attach()
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
}
override fun onScrolledToEnd() = Unit

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.history.ui
import android.content.Context
import android.content.Intent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
@@ -9,6 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.stats.ui.StatsActivity
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -24,6 +26,11 @@ class HistoryListMenuProvider(
menuInflater.inflate(R.menu.opt_history, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_stats)?.isVisible = viewModel.isStatsEnabled.value
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_clear_history -> {
@@ -31,6 +38,11 @@ class HistoryListMenuProvider(
true
}
R.id.action_stats -> {
context.startActivity(Intent(context, StatsActivity::class.java))
true
}
else -> false
}
}

View File

@@ -71,6 +71,12 @@ class HistoryListViewModel @Inject constructor(
g && s.isGroupingSupported()
}
val isStatsEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_STATS_ENABLED,
valueProducer = { isStatsEnabled },
)
override val content = combine(
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
isGroupingEnabled,

View File

@@ -18,3 +18,5 @@ fun File.hasCbzExtension() = isCbzExtension(extension)
fun Uri.isZipUri() = scheme.let {
it == URI_SCHEME_ZIP || it == "cbz" || it == "zip"
}
fun Uri.isFileUri() = scheme == "file"

View File

@@ -5,6 +5,7 @@ import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.creationTime
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted
@@ -100,8 +101,8 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
val file = chapter.url.toUri().toFile()
if (file.isDirectory) {
file.walkCompat()
.filter { hasImageExtension(it) }
file.children()
.filter { it.isFile && hasImageExtension(it) }
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map {
val pageUri = it.toUri().toString()
@@ -129,14 +130,16 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles() = root.walkCompat()
.filter { it.hasCbzExtension() }
private fun getChaptersFiles() = root.walkCompat(includeDirectories = true)
.filter { it != root && it.isChapterDirectory() || it.hasCbzExtension() }
.associateByTo(TreeMap(AlphanumComparator())) { it.name }
private fun findFirstImageEntry(): String? {
return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
return root.walkCompat(includeDirectories = false)
.firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
?: run {
val cbz = root.walkCompat().firstOrNull { it.hasCbzExtension() } ?: return null
val cbz = root.walkCompat(includeDirectories = false)
.firstOrNull { it.hasCbzExtension() } ?: return null
ZipFile(cbz).use { zip ->
zip.entries().asSequence()
.firstOrNull { !it.isDirectory && hasImageExtension(it.name) }
@@ -148,4 +151,8 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun fileUri(base: File, name: String): String {
return File(base, name).toUri().toString()
}
private fun File.isChapterDirectory(): Boolean {
return isDirectory && children().any { hasImageExtension(it) }
}
}

View File

@@ -4,7 +4,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.internal.format
import okio.Closeable
import org.koitharu.kotatsu.core.prefs.DownloadFormat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.parsers.model.Manga
@@ -35,22 +37,32 @@ sealed class LocalMangaOutput(
const val SUFFIX_TMP = ".tmp"
private val mutex = Mutex()
suspend fun getOrCreate(root: File, manga: Manga): LocalMangaOutput = withContext(Dispatchers.IO) {
val preferSingleCbz = manga.chapters.let {
it != null && it.size <= 3
suspend fun getOrCreate(
root: File,
manga: Manga,
format: DownloadFormat,
): LocalMangaOutput = withContext(Dispatchers.IO) {
val targetFormat = if (format == DownloadFormat.AUTOMATIC) {
if (manga.chapters.let { it != null && it.size <= 3 }) {
DownloadFormat.SINGLE_CBZ
} else {
DownloadFormat.MULTIPLE_CBZ
}
} else {
format
}
checkNotNull(getImpl(root, manga, onlyIfExists = false, preferSingleCbz))
checkNotNull(getImpl(root, manga, onlyIfExists = false, format = targetFormat))
}
suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) {
getImpl(root, manga, onlyIfExists = true, preferSingleCbz = false)
getImpl(root, manga, onlyIfExists = true, format = DownloadFormat.AUTOMATIC)
}
private suspend fun getImpl(
root: File,
manga: Manga,
onlyIfExists: Boolean,
preferSingleCbz: Boolean,
format: DownloadFormat,
): LocalMangaOutput? {
mutex.withLock {
var i = 0
@@ -75,10 +87,10 @@ sealed class LocalMangaOutput(
continue
}
!onlyIfExists -> if (preferSingleCbz) {
LocalMangaZipOutput(zip, manga)
} else {
LocalMangaDirOutput(dir, manga)
!onlyIfExists -> when (format) {
DownloadFormat.AUTOMATIC -> null
DownloadFormat.SINGLE_CBZ -> LocalMangaZipOutput(zip, manga)
DownloadFormat.MULTIPLE_CBZ -> LocalMangaDirOutput(dir, manga)
}
else -> null

View File

@@ -0,0 +1,90 @@
package org.koitharu.kotatsu.local.ui.info
import android.content.res.ColorStateList
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.widget.TextViewCompat
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
import org.koitharu.kotatsu.parsers.model.Manga
import com.google.android.material.R as materialR
@AndroidEntryPoint
class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>() {
private val viewModel: LocalInfoViewModel by viewModels()
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.saved_manga)
.setNegativeButton(R.string.close, null)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogLocalInfoBinding {
return DialogLocalInfoBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: DialogLocalInfoBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
viewModel.path.observe(this) {
binding.textViewPath.text = it
}
combine(viewModel.size, viewModel.availableSize, ::Pair).observe(this) {
if (it.first >= 0 && it.second >= 0) {
setSegments(it.first, it.second)
} else {
binding.barView.animateSegments(emptyList())
}
}
}
private fun setSegments(size: Long, available: Long) {
val view = viewBinding?.barView ?: return
val total = size + available
val segment = SegmentedBarView.Segment(
percent = (size.toDouble() / total.toDouble()).toFloat(),
color = KotatsuColors.segmentColor(view.context, materialR.attr.colorPrimary),
)
requireViewBinding().labelUsed.text = view.context.getString(
R.string.memory_usage_pattern,
getString(R.string.this_manga),
FileSize.BYTES.format(view.context, size),
)
requireViewBinding().labelAvailable.text = view.context.getString(
R.string.memory_usage_pattern,
getString(R.string.available),
FileSize.BYTES.format(view.context, available),
)
TextViewCompat.setCompoundDrawableTintList(
requireViewBinding().labelUsed,
ColorStateList.valueOf(segment.color),
)
view.animateSegments(listOf(segment))
}
companion object {
const val ARG_MANGA = "manga"
private const val TAG = "LocalInfoDialog"
fun show(fm: FragmentManager, manga: Manga) {
LocalInfoDialog().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG)
}
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.local.ui.info
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageManager
import javax.inject.Inject
@HiltViewModel
class LocalInfoViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val localMangaRepository: LocalMangaRepository,
private val storageManager: LocalStorageManager,
) : BaseViewModel() {
private val manga = savedStateHandle.require<ParcelableManga>(LocalInfoDialog.ARG_MANGA).manga
val path = MutableStateFlow<String?>(null)
val size = MutableStateFlow(-1L)
val availableSize = MutableStateFlow(-1L)
init {
launchLoadingJob(Dispatchers.Default) {
val file = manga.url.toUri().toFileOrNull() ?: localMangaRepository.findSavedManga(manga)?.file
requireNotNull(file)
path.value = file.path
size.value = file.computeSize()
availableSize.value = storageManager.computeAvailableSize()
}
}
}

View File

@@ -44,6 +44,12 @@ class ProtectActivity :
viewBinding.buttonNext.setOnClickListener(this)
viewBinding.buttonCancel.setOnClickListener(this)
viewBinding.editPassword.inputType = if (viewModel.isNumericPassword) {
EditorInfo.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_VARIATION_PASSWORD
} else {
EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
}
viewModel.onError.observeEvent(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onUnlockSuccess.observeEvent(this) {

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.parsers.util.isNumeric
import org.koitharu.kotatsu.parsers.util.md5
import javax.inject.Inject
@@ -26,6 +27,9 @@ class ProtectViewModel @Inject constructor(
val isBiometricEnabled
get() = settings.isBiometricProtectionEnabled
val isNumericPassword
get() = settings.isAppPasswordNumeric
fun tryUnlock(password: String) {
if (job?.isActive == true) {
return

View File

@@ -47,6 +47,7 @@ import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -203,20 +204,23 @@ class PageLoader @Inject constructor(
val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" }
val uri = Uri.parse(pageUrl)
return if (uri.isZipUri()) {
if (uri.scheme == URI_SCHEME_ZIP) {
return when {
uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) {
uri
} else { // legacy uri
uri.buildUpon().scheme(URI_SCHEME_ZIP).build()
}
} else {
val request = createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) { "Null response body" }
body.withProgress(progress).use {
cache.put(pageUrl, it.source())
}
}.toUri()
uri.isFileUri() -> uri
else -> {
val request = createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) { "Null response body" }
body.withProgress(progress).use {
cache.put(pageUrl, it.source())
}
}.toUri()
}
}
}

View File

@@ -14,6 +14,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.view.WindowManager
import androidx.activity.result.ActivityResultCallback
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
@@ -74,6 +75,7 @@ class ReaderActivity :
ReaderControlDelegate.OnInteractionListener,
OnApplyWindowInsetsListener,
IdlingDetector.Callback,
ActivityResultCallback<Uri?>,
ZoomControl.ZoomControlListener {
@Inject
@@ -83,6 +85,7 @@ class ReaderActivity :
lateinit var tapGridSettings: TapGridSettings
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
private val viewModel: ReaderViewModel by viewModels()
@@ -158,6 +161,10 @@ class ReaderActivity :
viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel))
}
override fun onActivityResult(result: Uri?) {
viewModel.onActivityResult(result)
}
override fun getParentActivityIntent(): Intent? {
val manga = viewModel.manga?.toManga() ?: return null
return DetailsActivity.newIntent(this, manga)
@@ -169,6 +176,11 @@ class ReaderActivity :
idlingDetector.onUserInteraction()
}
override fun onPause() {
super.onPause()
viewModel.onPause()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
}
@@ -371,6 +383,11 @@ class ReaderActivity :
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
}
override fun onSavePageClick() {
val page = viewModel.getCurrentPage() ?: return
viewModel.saveCurrentPage(page, savePageRequest)
}
private fun onReaderBarChanged(isBarEnabled: Boolean) {
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
}

View File

@@ -25,8 +25,7 @@ class ReaderManager(
private val modeMap = EnumMap<ReaderMode, Class<out BaseReaderFragment<*>>>(ReaderMode::class.java)
init {
val useDoublePages = container.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& settings.isReaderDoubleOnLandscape
val useDoublePages = isLandscape() && settings.isReaderDoubleOnLandscape
invalidateTypesMap(useDoublePages)
}
@@ -49,7 +48,7 @@ class ReaderManager(
fun setDoubleReaderMode(isEnabled: Boolean) {
val prevMode = currentMode
invalidateTypesMap(isEnabled)
invalidateTypesMap(isEnabled && isLandscape())
val newMode = currentMode ?: return
if (newMode != prevMode) {
replace(newMode)
@@ -70,4 +69,6 @@ class ReaderManager(
modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java
modeMap[ReaderMode.VERTICAL] = VerticalReaderFragment::class.java
}
private fun isLandscape() = container.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
}

View File

@@ -58,6 +58,7 @@ import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.stats.domain.StatsCollector
import java.time.Instant
import javax.inject.Inject
@@ -78,11 +79,11 @@ class ReaderViewModel @Inject constructor(
private val detailsLoadUseCase: DetailsLoadUseCase,
private val historyUpdateUseCase: HistoryUpdateUseCase,
private val detectReaderModeUseCase: DetectReaderModeUseCase,
private val statsCollector: StatsCollector,
) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle)
private val preselectedBranch = savedStateHandle.get<String>(ReaderActivity.EXTRA_BRANCH)
private val isIncognito = savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) ?: false
private var loadingJob: Job? = null
private var pageSaveJob: Job? = null
@@ -98,7 +99,7 @@ class ReaderViewModel @Inject constructor(
val onShowToast = MutableEventFlow<Int>()
val uiState = MutableStateFlow<ReaderUiState?>(null)
val incognitoMode = if (isIncognito) {
val incognitoMode = if (savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) == true) {
MutableStateFlow(true)
} else mangaFlow.map {
it != null && historyRepository.shouldSkip(it)
@@ -190,6 +191,12 @@ class ReaderViewModel @Inject constructor(
loadImpl()
}
fun onPause() {
manga?.let {
statsCollector.onPause(it.id)
}
}
fun switchMode(newMode: ReaderMode) {
launchJob {
val manga = checkNotNull(mangaData.value?.toManga())
@@ -208,7 +215,7 @@ class ReaderViewModel @Inject constructor(
if (state != null) {
currentState.value = state
}
if (isIncognito) {
if (incognitoMode.value) {
return
}
val readerState = state ?: currentState.value ?: return
@@ -377,7 +384,7 @@ class ReaderViewModel @Inject constructor(
chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId)
// save state
if (!isIncognito) {
if (!incognitoMode.value) {
currentState.value?.let {
val percent = computePercent(it.chapterId, it.page)
historyUpdateUseCase.invoke(manga, it, percent)
@@ -426,6 +433,9 @@ class ReaderViewModel @Inject constructor(
percent = computePercent(state.chapterId, state.page),
)
uiState.value = newState
if (!incognitoMode.value) {
statsCollector.onStateChanged(m.id, state)
}
}
private fun computePercent(chapterId: Long, pageIndex: Int): Float {

View File

@@ -39,14 +39,12 @@ import javax.inject.Inject
@AndroidEntryPoint
class ReaderConfigSheet :
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
ActivityResultCallback<Uri?>,
View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener,
Slider.OnChangeListener,
CompoundButton.OnCheckedChangeListener {
private val viewModel by activityViewModels<ReaderViewModel>()
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
@@ -115,8 +113,7 @@ class ReaderConfigSheet :
}
R.id.button_save_page -> {
val page = viewModel.getCurrentPage() ?: return
viewModel.saveCurrentPage(page, savePageRequest)
findCallback()?.onSavePageClick() ?: return
dismissAllowingStateLoss()
}
@@ -181,11 +178,6 @@ class ReaderConfigSheet :
(viewBinding ?: return).labelTimerValue.text = getString(R.string.speed_value, value * 10f)
}
override fun onActivityResult(result: Uri?) {
viewModel.onActivityResult(result)
dismissAllowingStateLoss()
}
private fun observeScreenOrientation() {
orientationHelper.observeAutoOrientation()
.onEach {
@@ -215,6 +207,8 @@ class ReaderConfigSheet :
fun onReaderModeChanged(mode: ReaderMode)
fun onDoubleModeChanged(isEnabled: Boolean)
fun onSavePageClick()
}
companion object {

View File

@@ -32,7 +32,8 @@ class TapGridDispatcher(
if (!isDispatching) {
return true
}
return listener.onGridTouch(getArea(event.rawX, event.rawY))
val area = getArea(event.rawX, event.rawY) ?: return false
return listener.onGridTouch(area)
}
override fun onDoubleTapEvent(e: MotionEvent): Boolean {
@@ -42,11 +43,12 @@ class TapGridDispatcher(
override fun onLongPress(event: MotionEvent) {
if (isDispatching) {
listener.onGridLongTouch(getArea(event.rawX, event.rawY))
val area = getArea(event.rawX, event.rawY) ?: return
listener.onGridLongTouch(area)
}
}
private fun getArea(x: Float, y: Float): TapGridArea {
private fun getArea(x: Float, y: Float): TapGridArea? {
val xIndex = (x * 2f / width).roundToInt()
val yIndex = (y * 2f / height).roundToInt()
val area = when (xIndex) {
@@ -73,7 +75,8 @@ class TapGridDispatcher(
else -> null
}
return checkNotNull(area) { "Invalid area ($xIndex, $yIndex)" }
assert(area != null) { "Invalid area ($xIndex, $yIndex)" }
return area
}
interface OnGridTouchListener {

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
import android.content.Context
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import androidx.core.net.toUri
import coil.ImageLoader
import coil.decode.DataSource
@@ -20,6 +22,7 @@ import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaPage
@@ -56,8 +59,8 @@ class MangaPageFetcher(
private suspend fun loadPage(pageUrl: String): SourceResult {
val uri = pageUrl.toUri()
return if (uri.isZipUri()) {
runInterruptible(Dispatchers.IO) {
return when {
uri.isZipUri() -> runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
SourceResult(
@@ -66,32 +69,48 @@ class MangaPageFetcher(
context = context,
metadata = MangaPageMetadata(page),
),
mimeType = null,
mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(entry.name.substringAfterLast('.', "")),
dataSource = DataSource.DISK,
)
}
} else {
val request = PageLoader.createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
check(response.isSuccessful) {
"Invalid response: ${response.code} ${response.message} at $pageUrl"
}
val body = checkNotNull(response.body) {
"Null response"
}
val mimeType = response.mimeType
val file = body.use {
pagesCache.put(pageUrl, it.source())
}
uri.isFileUri() -> runInterruptible(Dispatchers.IO) {
val file = uri.toFile()
SourceResult(
source = ImageSource(
file = file.toOkioPath(),
source = file.source().buffer(),
context = context,
metadata = MangaPageMetadata(page),
),
mimeType = mimeType,
dataSource = DataSource.NETWORK,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension),
dataSource = DataSource.DISK,
)
}
else -> {
val request = PageLoader.createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
check(response.isSuccessful) {
"Invalid response: ${response.code} ${response.message} at $pageUrl"
}
val body = checkNotNull(response.body) {
"Null response"
}
val mimeType = response.mimeType
val file = body.use {
pagesCache.put(pageUrl, it.source())
}
SourceResult(
source = ImageSource(
file = file.toOkioPath(),
metadata = MangaPageMetadata(page),
),
mimeType = mimeType,
dataSource = DataSource.NETWORK,
)
}
}
}
}

View File

@@ -54,7 +54,7 @@ class AppearanceSettingsFragment :
}
summaryProvider = Preference.SummaryProvider<ActivityListPreference> {
val locale = AppCompatDelegate.getApplicationLocales().get(0)
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.automatic)
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system)
}
setDefaultValueCompat("")
}
@@ -105,7 +105,7 @@ class AppearanceSettingsFragment :
.sortedWithSafe(LocaleComparator())
preference.entries = Array(locales.size + 1) { i ->
if (i == 0) {
getString(R.string.automatic)
getString(R.string.follow_system)
} else {
val lc = locales[i - 1]
lc.getDisplayName(lc).toTitleCase(lc)

View File

@@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import androidx.preference.ListPreference
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
@@ -15,13 +16,17 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.DownloadFormat
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
import org.koitharu.kotatsu.settings.utils.DozeHelper
@@ -46,6 +51,10 @@ class DownloadsSettingsFragment :
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_downloads)
findPreference<ListPreference>(AppSettings.KEY_DOWNLOADS_FORMAT)?.run {
entryValues = DownloadFormat.entries.names()
setDefaultValueCompat(DownloadFormat.AUTOMATIC.name)
}
dozeHelper.updatePreference()
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.settings
import android.accounts.AccountManager
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
@@ -17,6 +16,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
@@ -28,6 +28,9 @@ import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.scrobbling.kitsu.ui.KitsuAuthActivity
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
import org.koitharu.kotatsu.stats.ui.StatsActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -51,11 +54,18 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_services)
bindSuggestionsSummary()
findPreference<SplitSwitchPreference>(AppSettings.KEY_STATS_ENABLED)?.let {
it.onContainerClickListener = Preference.OnPreferenceClickListener {
it.context.startActivity(Intent(it.context, StatsActivity::class.java))
true
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindSuggestionsSummary()
bindStatsSummary()
settings.subscribe(this)
}
@@ -76,6 +86,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_SUGGESTIONS -> bindSuggestionsSummary()
AppSettings.KEY_STATS_ENABLED -> bindStatsSummary()
}
}
@@ -111,7 +122,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
AppSettings.KEY_KITSU -> {
if (!kitsuRepository.isAuthorized) {
launchScrobblerAuth(kitsuRepository)
startActivity(Intent(preference.context, KitsuAuthActivity::class.java))
} else {
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.KITSU))
}
@@ -194,4 +205,10 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled,
)
}
private fun bindStatsSummary() {
findPreference<Preference>(AppSettings.KEY_STATS_ENABLED)?.setSummary(
if (settings.isStatsEnabled) R.string.enabled else R.string.disabled,
)
}
}

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.parsers.util.isNumeric
import org.koitharu.kotatsu.parsers.util.md5
import javax.inject.Inject
@@ -39,6 +40,7 @@ class ProtectSetupViewModel @Inject constructor(
} else {
if (firstPassword.value == password) {
settings.appPassword = password.md5()
settings.isAppPasswordNumeric = password.isNumeric()
onPasswordSet.call(Unit)
} else {
onPasswordMismatch.call(Unit)

View File

@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
@@ -43,7 +42,7 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
val source = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)

View File

@@ -3,19 +3,15 @@ package org.koitharu.kotatsu.settings.userdata
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding
import com.google.android.material.R as materialR
@@ -38,15 +34,15 @@ class StorageUsagePreference @JvmOverloads constructor(
val binding = PreferenceMemoryUsageBinding.bind(holder.itemView)
val storageSegment = SegmentedBarView.Segment(
usage?.savedManga?.percent ?: 0f,
segmentColor(materialR.attr.colorPrimary),
KotatsuColors.segmentColor(context, materialR.attr.colorPrimary),
)
val pagesSegment = SegmentedBarView.Segment(
usage?.pagesCache?.percent ?: 0f,
segmentColor(materialR.attr.colorSecondary),
KotatsuColors.segmentColor(context, materialR.attr.colorSecondary),
)
val otherSegment = SegmentedBarView.Segment(
usage?.otherCache?.percent ?: 0f,
segmentColor(materialR.attr.colorTertiary),
KotatsuColors.segmentColor(context, materialR.attr.colorTertiary),
)
with(binding) {
@@ -81,27 +77,4 @@ class StorageUsagePreference @JvmOverloads constructor(
context.getString(emptyResId)
}
}
private fun getHue(hex: String): Float {
val r = (hex.substring(0, 2).toInt(16)).toFloat()
val g = (hex.substring(2, 4).toInt(16)).toFloat()
val b = (hex.substring(4, 6).toInt(16)).toFloat()
var hue = 0F
if ((r >= g) && (g >= b)) {
hue = 60 * (g - b) / (r - b)
} else if ((g > r) && (r >= b)) {
hue = 60 * (2 - (r - b) / (g - b))
}
return hue
}
@ColorInt
private fun segmentColor(@AttrRes resId: Int): Int {
val colorHex = String.format("%06x", context.getThemeColor(resId))
val hue = getHue(colorHex)
val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
val backgroundColor = context.getThemeColor(materialR.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
}

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.settings.utils
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.preference.PreferenceViewHolder
import androidx.preference.SwitchPreferenceCompat
import org.koitharu.kotatsu.R
class SplitSwitchPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.switchPreferenceCompatStyle,
defStyleRes: Int = 0
) : SwitchPreferenceCompat(context, attrs, defStyleAttr, defStyleRes) {
init {
layoutResource = R.layout.preference_split_switch
}
var onContainerClickListener: OnPreferenceClickListener? = null
private val containerClickListener = View.OnClickListener { v ->
onContainerClickListener?.onPreferenceClick(this)
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
holder.findViewById(R.id.press_container)?.setOnClickListener(containerClickListener)
}
}

View File

@@ -0,0 +1,69 @@
package org.koitharu.kotatsu.stats.data
import android.database.sqlite.SQLiteQueryBuilder
import androidx.room.Dao
import androidx.room.MapColumn
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.HistoryWithManga
@Dao
abstract class StatsDao {
@Query("SELECT * FROM stats ORDER BY started_at")
abstract suspend fun findAll(): List<StatsEntity>
@Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at")
abstract suspend fun findAll(mangaId: Long): List<StatsEntity>
@Query("SELECT IFNULL(SUM(pages),0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getReadPagesCount(mangaId: Long): Int
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getAverageTimePerPage(mangaId: Long): Long
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats")
abstract suspend fun getAverageTimePerPage(): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getReadingTime(mangaId: Long): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats")
abstract suspend fun getTotalReadingTime(): Long
@Query("DELETE FROM stats")
abstract suspend fun clear()
@Upsert
abstract suspend fun upsert(entity: StatsEntity)
suspend fun getDurationStats(fromDate: Long, isNsfw: Boolean?, favouriteCategories: Set<Long>): Map<MangaEntity, Long> {
val conditions = ArrayList<String>()
conditions.add("stats.started_at >= $fromDate")
if (favouriteCategories.isNotEmpty()) {
val ids = favouriteCategories.joinToString(",")
conditions.add("stats.manga_id IN (SELECT manga_id FROM favourites WHERE category_id IN ($ids))")
}
if (isNsfw != null) {
val flag = if (isNsfw) 1 else 0
conditions.add("manga.nsfw = $flag")
}
val where = conditions.joinToString(separator = " AND ")
val query = SimpleSQLiteQuery(
"SELECT manga.*, SUM(duration) AS d FROM stats LEFT JOIN manga ON manga.manga_id = stats.manga_id WHERE $where GROUP BY manga.manga_id ORDER BY d DESC",
)
return getDurationStatsImpl(query)
}
@RawQuery
protected abstract fun getDurationStatsImpl(
query: SupportSQLiteQuery
): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long>
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.stats.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
@Entity(
tableName = "stats",
primaryKeys = ["manga_id", "started_at"],
foreignKeys = [
ForeignKey(
entity = HistoryEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class StatsEntity(
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "started_at") val startedAt: Long,
@ColumnInfo(name = "duration") val duration: Long,
@ColumnInfo(name = "pages") val pages: Int,
)

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.stats.data
import androidx.collection.LongIntMap
import androidx.collection.MutableLongIntMap
import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.stats.domain.StatsPeriod
import org.koitharu.kotatsu.stats.domain.StatsRecord
import java.util.NavigableMap
import java.util.TreeMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class StatsRepository @Inject constructor(
private val db: MangaDatabase,
) {
suspend fun getReadingStats(period: StatsPeriod, categories: Set<Long>): List<StatsRecord> {
val fromDate = if (period == StatsPeriod.ALL) {
0L
} else {
System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong())
}
val stats = db.getStatsDao().getDurationStats(fromDate, null, categories)
val result = ArrayList<StatsRecord>(stats.size)
var other = StatsRecord(null, 0)
val total = stats.values.sum()
for ((mangaEntity, duration) in stats) {
val manga = mangaEntity.toManga(emptySet())
val percent = duration.toDouble() / total
if (percent < 0.05) {
other = other.copy(duration = other.duration + duration)
} else {
result += StatsRecord(
manga = manga,
duration = duration,
)
}
}
if (other.duration != 0L) {
result += other
}
return result
}
suspend fun getTimePerPage(mangaId: Long): Long = db.withTransaction {
val dao = db.getStatsDao()
val pages = dao.getReadPagesCount(mangaId)
val time = if (pages >= 10) {
dao.getAverageTimePerPage(mangaId)
} else {
dao.getAverageTimePerPage()
}
time
}
suspend fun getTotalPagesRead(mangaId: Long): Int {
return db.getStatsDao().getReadPagesCount(mangaId)
}
suspend fun getMangaTimeline(mangaId: Long): NavigableMap<Long, Int> {
val entities = db.getStatsDao().findAll(mangaId)
val map = TreeMap<Long, Int>()
for (e in entities) {
map[e.startedAt] = e.pages
}
return map
}
suspend fun clearStats() {
db.getStatsDao().clear()
}
}

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.stats.domain
import androidx.collection.LongSparseArray
import androidx.collection.set
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.stats.data.StatsEntity
import javax.inject.Inject
@ViewModelScoped
class StatsCollector @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
lifecycle: ViewModelLifecycle,
) {
private val viewModelScope = RetainedLifecycleCoroutineScope(lifecycle)
private val stats = LongSparseArray<Entry>(1)
@Synchronized
fun onStateChanged(mangaId: Long, state: ReaderState) {
if (!settings.isStatsEnabled) {
return
}
val now = System.currentTimeMillis()
val entry = stats[mangaId]
if (entry == null) {
stats[mangaId] = Entry(
state = state,
stats = StatsEntity(
mangaId = mangaId,
startedAt = now,
duration = 0,
pages = 0,
),
)
return
}
val pagesDelta = if (entry.state.page != state.page || entry.state.chapterId != state.chapterId) 1 else 0
val newEntry = entry.copy(
stats = StatsEntity(
mangaId = mangaId,
startedAt = entry.stats.startedAt,
duration = now - entry.stats.startedAt,
pages = entry.stats.pages + pagesDelta,
),
)
stats[mangaId] = newEntry
commit(newEntry.stats)
}
@Synchronized
fun onPause(mangaId: Long) {
stats.remove(mangaId)
}
private fun commit(entity: StatsEntity) {
viewModelScope.launch(Dispatchers.Default) {
db.getStatsDao().upsert(entity)
}
}
private data class Entry(
val state: ReaderState,
val stats: StatsEntity,
)
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.stats.domain
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class StatsPeriod(
@StringRes val titleResId: Int,
val days: Int,
) {
DAY(R.string.day, 1),
WEEK(R.string.week, 7),
MONTH(R.string.month, 30),
MONTHS_3(R.string.three_months, 90),
ALL(R.string.all_time, Int.MAX_VALUE),
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.stats.domain
import android.content.Context
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import com.google.android.material.R
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue
data class StatsRecord(
val manga: Manga?,
val duration: Long,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is StatsRecord && other.manga == manga
}
val time: ReadingTime
init {
val minutes = TimeUnit.MILLISECONDS.toMinutes(duration).toInt()
time = ReadingTime(
minutes = minutes % 60,
hours = minutes / 60,
isContinue = false,
)
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.stats.ui
import android.content.res.ColorStateList
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.databinding.ItemStatsBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.stats.domain.StatsRecord
fun statsAD(
listener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<StatsRecord, StatsRecord, ItemStatsBinding>(
{ layoutInflater, parent -> ItemStatsBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v ->
listener.onItemClick(item.manga ?: return@setOnClickListener, v)
}
bind {
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.other_manga)
binding.textViewSummary.text = item.time.format(context.resources)
binding.imageViewBadge.imageTintList = ColorStateList.valueOf(KotatsuColors.ofManga(context, item.manga))
binding.root.isClickable = item.manga != null
}
}

View File

@@ -0,0 +1,197 @@
package org.koitharu.kotatsu.stats.ui
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewStub
import android.widget.CompoundButton
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.AsyncListDiffer
import coil.ImageLoader
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.ActivityStatsBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.stats.domain.StatsPeriod
import org.koitharu.kotatsu.stats.domain.StatsRecord
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import org.koitharu.kotatsu.stats.ui.views.PieChartView
import javax.inject.Inject
@AndroidEntryPoint
class StatsActivity : BaseActivity<ActivityStatsBinding>(),
OnListItemClickListener<Manga>,
PieChartView.OnSegmentClickListener,
AsyncListDiffer.ListListener<StatsRecord>,
ViewStub.OnInflateListener, View.OnClickListener, CompoundButton.OnCheckedChangeListener {
@Inject
lateinit var coil: ImageLoader
private val viewModel: StatsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityStatsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = BaseListAdapter<StatsRecord>()
.addDelegate(ListItemType.FEED, statsAD(this))
.addListListener(this)
viewBinding.recyclerView.adapter = adapter
viewBinding.chart.onSegmentClickListener = this
viewBinding.stubEmpty.setOnInflateListener(this)
viewBinding.chipPeriod.setOnClickListener(this)
viewModel.isLoading.observe(this) {
viewBinding.progressBar.showOrHide(it)
}
viewModel.period.observe(this) {
viewBinding.chipPeriod.setText(it.titleResId)
}
viewModel.favoriteCategories.observe(this, ::createCategoriesChips)
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView))
viewModel.readingStats.observe(this) {
val sum = it.sumOf { it.duration }
viewBinding.chart.setData(
it.map { v ->
PieChartView.Segment(
value = (v.duration / 1000).toInt(),
label = v.manga?.title ?: getString(R.string.other_manga),
percent = (v.duration.toDouble() / sum).toFloat(),
color = KotatsuColors.ofManga(this, v.manga),
tag = v.manga,
)
},
)
adapter.emit(it)
}
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onClick(v: View) {
when (v.id) {
R.id.chip_period -> showPeriodSelector()
}
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
val category = buttonView?.tag as? FavouriteCategory ?: return
viewModel.setCategoryChecked(category, isChecked)
}
override fun onItemClick(item: Manga, view: View) {
MangaStatsSheet.show(supportFragmentManager, item)
}
override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) {
val manga = segment.tag as? Manga ?: return
onItemClick(manga, view)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_stats, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_clear -> {
showClearConfirmDialog()
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onCurrentListChanged(previousList: MutableList<StatsRecord>, currentList: MutableList<StatsRecord>) {
val isEmpty = currentList.isEmpty()
with(viewBinding) {
chart.isGone = isEmpty
recyclerView.isGone = isEmpty
stubEmpty.isVisible = isEmpty
}
}
override fun onInflate(stub: ViewStub?, inflated: View) {
val stubBinding = ItemEmptyStateBinding.bind(inflated)
stubBinding.icon.newImageRequest(this, R.drawable.ic_empty_history)?.enqueueWith(coil)
stubBinding.textPrimary.setText(R.string.text_empty_holder_primary)
stubBinding.textSecondary.setTextAndVisible(R.string.empty_stats_text)
stubBinding.buttonRetry.isVisible = false
}
private fun createCategoriesChips(categories: List<FavouriteCategory>) {
val container = viewBinding.layoutChips
if (container.childCount > 1) {
// avoid duplication
return
}
val checkedIds = viewModel.selectedCategories.value
for (category in categories) {
val chip = Chip(this)
val drawable = ChipDrawable.createFromAttributes(this, null, 0, R.style.Widget_Kotatsu_Chip_Filter)
chip.setChipDrawable(drawable)
chip.text = category.title
chip.tag = category
chip.isChecked = category.id in checkedIds
chip.setOnCheckedChangeListener(this)
container.addView(chip)
}
}
private fun showClearConfirmDialog() {
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
.setMessage(R.string.clear_stats_confirm)
.setTitle(R.string.clear_stats)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clear()
}.show()
}
private fun showPeriodSelector() {
val menu = PopupMenu(this, viewBinding.chipPeriod)
val selected = viewModel.period.value
for ((i, branch) in StatsPeriod.entries.withIndex()) {
val item = menu.menu.add(R.id.group_period, Menu.NONE, i, branch.titleResId)
item.isCheckable = true
item.isChecked = selected.ordinal == i
}
menu.menu.setGroupCheckable(R.id.group_period, true, true)
menu.setOnMenuItemClickListener {
StatsPeriod.entries.getOrNull(it.order)?.also {
viewModel.period.value = it
} != null
}
menu.show()
}
}

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.stats.ui
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.stats.data.StatsRepository
import org.koitharu.kotatsu.stats.domain.StatsPeriod
import org.koitharu.kotatsu.stats.domain.StatsRecord
import javax.inject.Inject
@HiltViewModel
class StatsViewModel @Inject constructor(
private val repository: StatsRepository,
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val period = MutableStateFlow(StatsPeriod.WEEK)
val onActionDone = MutableEventFlow<ReversibleAction>()
val selectedCategories = MutableStateFlow<Set<Long>>(emptySet())
val favoriteCategories = favouritesRepository.observeCategories()
.take(1)
val readingStats = MutableStateFlow<List<StatsRecord>>(emptyList())
init {
launchJob(Dispatchers.Default) {
combine<StatsPeriod, Set<Long>, Pair<StatsPeriod, Set<Long>>>(
period,
selectedCategories,
::Pair,
).collectLatest { p ->
readingStats.value = withLoading {
repository.getReadingStats(p.first, p.second)
}
}
}
}
fun setCategoryChecked(category: FavouriteCategory, checked: Boolean) {
val snapshot = selectedCategories.value.toMutableSet()
if (checked) {
snapshot.add(category.id)
} else {
snapshot.remove(category.id)
}
selectedCategories.value = snapshot
}
fun clear() {
launchLoadingJob(Dispatchers.Default) {
repository.clearStats()
readingStats.value = emptyList()
onActionDone.call(ReversibleAction(R.string.stats_cleared, null))
}
}
}

View File

@@ -0,0 +1,82 @@
package org.koitharu.kotatsu.stats.ui.sheet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.collection.IntList
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.stats.ui.views.BarChartView
@AndroidEntryPoint
class MangaStatsSheet : BaseAdaptiveSheet<SheetStatsMangaBinding>(), View.OnClickListener {
private val viewModel: MangaStatsViewModel by viewModels()
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetStatsMangaBinding {
return SheetStatsMangaBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetStatsMangaBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewTitle.text = viewModel.manga.title
binding.chartView.barColor = KotatsuColors.ofManga(binding.root.context, viewModel.manga)
viewModel.stats.observe(viewLifecycleOwner, ::onStatsChanged)
viewModel.startDate.observe(viewLifecycleOwner) {
binding.textViewStart.textAndVisible = it?.format(resources)
}
viewModel.totalPagesRead.observe(viewLifecycleOwner) {
binding.textViewPages.text = getString(R.string.pages_read_s, it.format())
}
binding.buttonOpen.setOnClickListener(this)
}
override fun onClick(v: View) {
startActivity(DetailsActivity.newIntent(v.context, viewModel.manga))
}
private fun onStatsChanged(stats: IntList) {
val chartView = viewBinding?.chartView ?: return
if (stats.isEmpty()) {
chartView.setData(emptyList())
return
}
val bars = ArrayList<BarChartView.Bar>(stats.size)
stats.forEach { pages ->
bars.add(
BarChartView.Bar(
value = pages,
label = pages.toString(),
),
)
}
chartView.setData(bars)
}
companion object {
const val ARG_MANGA = "manga"
private const val TAG = "MangaStatsSheet"
fun show(fm: FragmentManager, manga: Manga) {
MangaStatsSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG)
}
}
}

View File

@@ -0,0 +1,58 @@
package org.koitharu.kotatsu.stats.ui.sheet
import androidx.collection.IntList
import androidx.collection.LongIntMap
import androidx.collection.MutableIntList
import androidx.collection.emptyIntList
import androidx.collection.emptyLongIntMap
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.stats.data.StatsRepository
import org.koitharu.kotatsu.stats.domain.StatsRecord
import java.time.Instant
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class MangaStatsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: StatsRepository,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaStatsSheet.ARG_MANGA).manga
val stats = MutableStateFlow<IntList>(emptyIntList())
val startDate = MutableStateFlow<DateTimeAgo?>(null)
val totalPagesRead = MutableStateFlow(0)
init {
launchLoadingJob(Dispatchers.Default) {
val timeline = repository.getMangaTimeline(manga.id)
if (timeline.isEmpty()) {
startDate.value = null
stats.value = emptyIntList()
} else {
val startDay = TimeUnit.MILLISECONDS.toDays(timeline.firstKey())
val endDay = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis())
val res = MutableIntList((endDay - startDay).toInt() + 1)
for (day in startDay..endDay) {
val from = TimeUnit.DAYS.toMillis(day)
val to = TimeUnit.DAYS.toMillis(day + 1)
res.add(timeline.subMap(from, true, to, false).values.sum())
}
stats.value = res
startDate.value = calculateTimeAgo(Instant.ofEpochMilli(timeline.firstKey()))
}
}
launchLoadingJob(Dispatchers.Default) {
totalPagesRead.value = repository.getTotalPagesRead(manga.id)
}
}
}

View File

@@ -0,0 +1,172 @@
package org.koitharu.kotatsu.stats.ui.views
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.PathEffect
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.graphics.Xfermode
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.annotation.ColorInt
import androidx.collection.MutableIntList
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.minus
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.setPadding
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.parsers.util.toIntUp
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sqrt
import kotlin.random.Random
import com.google.android.material.R as materialR
class BarChartView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rawData = ArrayList<Bar>()
private val bars = ArrayList<Bar>()
private var maxValue: Int = 0
private val minBarSpacing = context.resources.resolveDp(12f)
private val minSpace = context.resources.resolveDp(20f)
private val barWidth = context.resources.resolveDp(12f)
private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
private val dottedEffect = DashPathEffect(
floatArrayOf(
context.resources.resolveDp(6f),
context.resources.resolveDp(6f),
),
0f,
)
private val chartBounds = RectF()
@ColorInt
var barColor: Int = context.getThemeColor(materialR.attr.colorAccent)
set(value) {
field = value
invalidate()
}
init {
paint.strokeWidth = context.resources.resolveDp(1f)
if (isInEditMode) {
setData(
List(Random.nextInt(20, 60)) {
Bar(
value = Random.nextInt(-20, 400).coerceAtLeast(0),
label = it.toString(),
)
},
)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (bars.isEmpty() || chartBounds.isEmpty) {
return
}
val spacing = (chartBounds.width() - (barWidth * bars.size.toFloat())) / (bars.size + 1).toFloat()
// dashed horizontal lines
paint.color = outlineColor
paint.style = Paint.Style.STROKE
canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint)
paint.pathEffect = dottedEffect
for (i in (0..maxValue).step(computeValueStep())) {
val y = chartBounds.top + (chartBounds.height() * i / maxValue.toFloat())
canvas.drawLine(paddingLeft.toFloat(), y, (width - paddingLeft - paddingRight).toFloat(), y, paint)
}
// bottom line
paint.color = outlineColor
paint.style = Paint.Style.STROKE
canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint)
// bars
paint.style = Paint.Style.FILL
paint.color = barColor
paint.pathEffect = null
val corner = barWidth / 2f
for ((i, bar) in bars.withIndex()) {
if (bar.value == 0) {
continue
}
val h = (chartBounds.height() * bar.value / maxValue.toFloat()).coerceAtLeast(barWidth)
val x = spacing + i * (barWidth + spacing) + paddingLeft
canvas.drawRoundRect(x, chartBounds.bottom - h, x + barWidth, chartBounds.bottom, corner, corner, paint)
}
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
invalidateBounds()
}
fun setData(value: List<Bar>) {
rawData.replaceWith(value)
compressBars()
invalidate()
}
private fun compressBars() {
if (rawData.isEmpty() || width <= 0) {
maxValue = 0
bars.clear()
return
}
var fullWidth = rawData.size * (barWidth + minBarSpacing) + minBarSpacing
val windowSize = (fullWidth / width.toFloat()).toIntUp()
bars.replaceWith(
rawData.chunked(windowSize) { it.average() },
)
maxValue = bars.maxOf { it.value }
}
private fun computeValueStep(): Int {
val h = chartBounds.height()
var step = 1
while (h / (maxValue / step).toFloat() <= minSpace) {
step++
}
return step
}
private fun invalidateBounds() {
val inset = paint.strokeWidth
chartBounds.set(
paddingLeft.toFloat() + inset,
paddingTop.toFloat() + inset,
(width - paddingLeft - paddingRight).toFloat() - inset,
(height - paddingTop - paddingBottom).toFloat() - inset,
)
compressBars()
}
private fun Collection<Bar>.average(): Bar {
return when (size) {
0 -> Bar(0, "")
1 -> first()
else -> Bar(
value = (sumOf { it.value } / size.toFloat()).roundToInt(),
label = "%s - %s".format(first().label, last().label),
)
}
}
class Bar(
val value: Int,
val label: String,
)
}

View File

@@ -0,0 +1,171 @@
package org.koitharu.kotatsu.stats.ui.views
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.graphics.Xfermode
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.annotation.ColorInt
import androidx.collection.MutableIntList
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.minus
import androidx.core.view.GestureDetectorCompat
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.replaceWith
import kotlin.math.absoluteValue
import kotlin.math.sqrt
import com.google.android.material.R as materialR
class PieChartView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), GestureDetector.OnGestureListener {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val segments = ArrayList<Segment>()
private val chartBounds = RectF()
private val clearColor = context.getThemeColor(android.R.attr.colorBackground)
private val touchDetector = GestureDetectorCompat(context, this)
private var hightlightedSegment = -1
var onSegmentClickListener: OnSegmentClickListener? = null
init {
touchDetector.setIsLongpressEnabled(false)
paint.strokeWidth = context.resources.resolveDp(2f)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
var angle = 0f
for ((i, segment) in segments.withIndex()) {
paint.color = segment.color
if (i == hightlightedSegment) {
paint.color = ColorUtils.setAlphaComponent(paint.color, 180)
}
paint.style = Paint.Style.FILL
val sweepAngle = segment.percent * 360f
canvas.drawArc(
chartBounds,
angle,
sweepAngle,
true,
paint,
)
paint.color = clearColor
paint.style = Paint.Style.STROKE
canvas.drawArc(
chartBounds,
angle,
sweepAngle,
true,
paint,
)
angle += sweepAngle
}
paint.style = Paint.Style.FILL
paint.color = clearColor
canvas.drawCircle(chartBounds.centerX(), chartBounds.centerY(), chartBounds.height() / 4f, paint)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val size = minOf(w, h).toFloat()
val inset = paint.strokeWidth
chartBounds.set(inset, inset, size - inset, size - inset)
chartBounds.offset(
(w - size) / 2f,
(h - size) / 2f,
)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_CANCEL || event.actionMasked == MotionEvent.ACTION_UP) {
hightlightedSegment = -1
invalidate()
}
return super.onTouchEvent(event) || touchDetector.onTouchEvent(event)
}
override fun onDown(e: MotionEvent): Boolean {
if (onSegmentClickListener == null) {
return false
}
val segment = findSegmentIndex(e.x, e.y)
if (segment != hightlightedSegment) {
hightlightedSegment = segment
invalidate()
return true
} else {
return false
}
}
override fun onShowPress(e: MotionEvent) = Unit
override fun onSingleTapUp(e: MotionEvent): Boolean {
onSegmentClickListener?.run {
val segment = segments.getOrNull(findSegmentIndex(e.x, e.y))
if (segment != null) {
onSegmentClick(this@PieChartView, segment)
}
}
return true
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean = false
override fun onLongPress(e: MotionEvent) = Unit
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean = false
fun setData(value: List<Segment>) {
segments.replaceWith(value)
invalidate()
}
private fun findSegmentIndex(x: Float, y: Float): Int {
val dy = (y - chartBounds.centerY()).toDouble()
val dx = (x - chartBounds.centerX()).toDouble()
val distance = sqrt(dx * dx + dy * dy).toFloat()
if (distance < chartBounds.height() / 4f || distance > chartBounds.centerX()) {
return -1
}
var touchAngle = Math.toDegrees(Math.atan2(dy, dx)).toFloat()
if (touchAngle < 0) {
touchAngle += 360
}
var angle = 0f
for ((i, segment) in segments.withIndex()) {
val sweepAngle = segment.percent * 360f
if (touchAngle in angle..(angle + sweepAngle)) {
return i
}
angle += sweepAngle
}
return -1
}
class Segment(
val value: Int,
val label: String,
val percent: Float,
val color: Int,
val tag: Any?,
)
interface OnSegmentClickListener {
fun onSegmentClick(view: PieChartView, segment: Segment)
}
}

View File

@@ -293,6 +293,7 @@ class TrackWorker @AssistedInject constructor(
setCategory(NotificationCompat.CATEGORY_SERVICE)
setDefaults(0)
setOngoing(false)
setOnlyAlertOnce(true)
setSilent(true)
setContentIntent(
PendingIntentCompat.getActivity(

View File

@@ -58,6 +58,7 @@
android:autofillHints="emailAddress"
android:imeOptions="actionDone"
android:inputType="textEmailAddress"
android:maxLength="512"
android:singleLine="true"
android:textSize="16sp"
tools:hint="Email" />
@@ -84,7 +85,7 @@
android:autofillHints="password"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLength="24"
android:maxLength="512"
android:singleLine="true"
android:textSize="16sp"
tools:hint="Password" />

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_scrollFlags="noScroll">
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:hideAnimationBehavior="outward"
app:layout_constraintBottom_toBottomOf="@id/appbar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar"
app:showAnimationBehavior="inward"
app:trackCornerRadius="0dp"
tools:visibility="visible" />
<HorizontalScrollView
android:id="@+id/scrollView_chips"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar">
<com.google.android.material.chip.ChipGroup
android:id="@+id/layout_chips"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.google.android.material.chip.Chip
android:id="@+id/chip_period"
style="@style/Widget.Kotatsu.Chip.Dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/week"
app:chipIcon="@drawable/ic_history" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
<org.koitharu.kotatsu.stats.ui.views.PieChartView
android:id="@+id/chart"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="24dp"
app:layout_constraintDimensionRatio="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/scrollView_chips" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:overScrollMode="ifContentScrolls"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chart"
tools:itemCount="4"
tools:listitem="@layout/item_stats" />
<ViewStub
android:id="@+id/stub_empty"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout="@layout/item_empty_state"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/scrollView_chips"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="?dialogPreferredPadding">
<TextView
android:id="@+id/textView_path_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/location"
android:textAppearance="?textAppearanceLabelMedium" />
<TextView
android:id="@+id/textView_path"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?textAppearanceBodyMedium"
tools:text="/storage/emulated/0/Manga/lorem.cbz" />
<org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
android:id="@+id/barView"
android:layout_width="match_parent"
android:layout_height="18dp"
android:layout_marginTop="12dp"
android:background="?colorSecondaryContainer" />
<TextView
android:id="@+id/label_used"
style="@style/Widget.Kotatsu.TextView.Indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:text="@string/this_manga"
app:drawableStartCompat="@drawable/bg_rounded_square"
tools:drawableTint="?colorPrimary" />
<TextView
android:id="@+id/label_available"
style="@style/Widget.Kotatsu.TextView.Indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:text="@string/available"
app:drawableStartCompat="@drawable/bg_rounded_square"
app:drawableTint="?colorSecondaryContainer" />
</LinearLayout>

View File

@@ -3,7 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/scrollView"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
@@ -13,6 +13,8 @@
android:id="@+id/chips_tags"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingVertical="@dimen/margin_small"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
app:selectionRequired="false"

View File

@@ -61,7 +61,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="imageView_cover,textView_status,imageView_expand" />
app:constraint_referenced_ids="imageView_cover,textView_status,imageView_expand,textView_details" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
@@ -141,7 +141,7 @@
app:layout_constraintEnd_toStartOf="@id/textView_percent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" />
tools:text="@tools:sample/lorem[10]" />
<Button
android:id="@+id/button_pause"

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/list_selector"
android:clipChildren="false"
android:gravity="center_vertical"
android:minHeight="?listPreferredItemHeightSmall"
android:orientation="horizontal"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd">
<ImageView
android:id="@+id/imageView_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
app:srcCompat="@drawable/bg_rounded_square" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceTitleSmall"
tools:text="@tools:sample/lorem[3]" />
<TextView
android:id="@+id/textView_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
tools:text="@tools:sample/lorem/random" />
</LinearLayout>
</LinearLayout>

View File

@@ -66,6 +66,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/custom_selectable_item_background"
android:visibility="gone"
app:drawableTopCompat="@drawable/ic_storage"
tools:text="1.8 GiB"

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:clipToPadding="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
tools:ignore="RtlSymmetry">
<LinearLayout
android:id="@+id/press_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?selectableItemBackground"
android:baselineAligned="false"
android:clipToPadding="false"
android:gravity="center_vertical"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:orientation="horizontal"
android:paddingStart="?android:attr/listPreferredItemPaddingStart">
<include layout="@layout/image_frame" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem" />
<TextView
android:id="@android:id/summary"
style="@style/PreferenceSummaryTextStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_alignStart="@android:id/title"
android:layout_gravity="start"
android:maxLines="10"
android:textAlignment="viewStart"
android:textColor="?android:attr/textColorSecondary" />
</RelativeLayout>
</LinearLayout>
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:layout_marginVertical="16dp"
android:background="?dividerVertical" />
<!-- Preference should place its actual preference widget here. -->
<LinearLayout
android:id="@android:id/widget_frame"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="end|center_vertical"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="0dp" />
</LinearLayout>

View File

@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/margin_small"
android:paddingBottom="@dimen/margin_normal">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/dragHandle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true"
android:layout_below="@id/dragHandle"
android:layout_alignParentStart="true"
android:layout_toStartOf="@id/textView_label"
android:paddingHorizontal="@dimen/margin_small"
android:paddingBottom="@dimen/margin_small"
android:singleLine="true"
android:text="@string/grid_size"
android:textAppearance="?textAppearanceTitleMedium" />
<TextView
android:id="@+id/textView_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@id/textView_title"
android:layout_alignParentEnd="true"
android:paddingHorizontal="@dimen/margin_small"
android:singleLine="true"
android:textAppearance="?textAppearanceLabelLarge"
tools:text="100%" />
<ImageView
android:id="@+id/button_small"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_alignTop="@id/slider_grid"
android:layout_alignBottom="@id/slider_grid"
android:layout_alignParentStart="true"
android:background="?selectableItemBackgroundBorderless"
android:padding="8dp"
android:src="@drawable/ic_size_small"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar" />
<ImageView
android:id="@+id/button_large"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_alignTop="@id/slider_grid"
android:layout_alignBottom="@id/slider_grid"
android:layout_alignParentEnd="true"
android:background="?selectableItemBackgroundBorderless"
android:padding="8dp"
android:src="@drawable/ic_size_large"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_grid"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_below="@id/textView_title"
android:layout_toStartOf="@id/button_large"
android:layout_toEndOf="@id/button_small"
android:stepSize="5"
android:valueFrom="50"
android:valueTo="150"
app:labelBehavior="gone"
app:tickVisible="false"
tools:value="100" />
</RelativeLayout>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingBottom="@dimen/screen_padding">
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/reading_stats" />
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollIndicators="top">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/screen_padding">
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?textAppearanceTitleMedium"
tools:text="@tools:sample/lorem[4]" />
<ImageButton
android:id="@+id/button_open"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:minWidth="?minTouchTargetSize"
android:minHeight="?minTouchTargetSize"
app:srcCompat="@drawable/ic_open_external" />
</LinearLayout>
<org.koitharu.kotatsu.stats.ui.views.BarChartView
android:id="@+id/chartView"
android:layout_width="match_parent"
android:layout_height="240dp"
android:layout_marginTop="12dp"
android:paddingHorizontal="@dimen/screen_padding" />
<TextView
android:id="@+id/textView_start"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:paddingHorizontal="@dimen/screen_padding"
android:textAppearance="?textAppearanceLabelSmall"
tools:text="Week ago" />
<TextView
android:id="@+id/textView_pages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:paddingHorizontal="@dimen/screen_padding"
android:textAppearance="?textAppearanceBodyMedium"
tools:text="Total pages read: 250" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -37,6 +37,12 @@
android:title="@string/tracking"
app:showAsAction="never" />
<item
android:id="@+id/action_stats"
android:orderInCategory="50"
android:title="@string/statistics"
app:showAsAction="never" />
<item
android:id="@+id/action_related"
android:orderInCategory="50"

View File

@@ -9,4 +9,10 @@
android:title="@string/clear_history"
app:showAsAction="never" />
<item
android:id="@+id/action_stats"
android:orderInCategory="40"
android:title="@string/statistics"
app:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<item
android:id="@+id/action_clear"
android:title="@string/clear_stats"
android:titleCondensed="@string/clear"
app:showAsAction="never" />
</menu>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="detailed_list">تفاصيل القائمة</string>
<string name="detailed_list">قائمة مفصلة</string>
<string name="error_occurred">حدث خطأ</string>
<string name="details">التفاصيل</string>
<string name="grid">شبكة</string>
@@ -11,8 +11,8 @@
<string name="favourites">المفضلة</string>
<string name="network_error">‌خطاء في الشبكة</string>
<string name="loading_">جار التحميل…</string>
<string name="chapter_d_of_d">فصل %1$d في %2$d</string>
<string name="close">غلق</string>
<string name="chapter_d_of_d">فصل %1$d من %2$d</string>
<string name="close">إغلاق</string>
<string name="try_again">حاول مجدداً</string>
<string name="computing_">جاري الحوسبة …</string>
<string name="local_storage">التخزين المحلي</string>
@@ -28,16 +28,16 @@
<string name="newest">الأحدث</string>
<string name="by_rating">تقييم</string>
<string name="pages">صفحات</string>
<string name="read">اقرأ</string>
<string name="read">إقرأ</string>
<string name="share">شارك</string>
<string name="nothing_found">لم يتم عثور على اي شيء</string>
<string name="nothing_found">لا شيء موجود</string>
<string name="you_have_not_favourites_yet">لا مفضلة بعد</string>
<string name="search">بحث</string>
<string name="search_manga">البحث في المانجا</string>
<string name="manga_downloading_">جاري التنزيل…</string>
<string name="create_shortcut">انشاء اختصار…</string>
<string name="theme">مظهر</string>
<string name="automatic">حسب النظام</string>
<string name="follow_system">حسب النظام</string>
<string name="share_s">شارك %s</string>
<string name="processing_">في طور معالجة…</string>
<string name="updated">محدث</string>
@@ -48,7 +48,7 @@
<string name="clear">مسح</string>
<string name="remove">ازالة</string>
<string name="popular">شائع</string>
<string name="add_new_category">أضف فئة جديدة</string>
<string name="add_new_category">فئة جديدة</string>
<string name="download_complete">تم التنزيل</string>
<string name="text_clear_history_prompt">هل تريد محو سجل القراءة بالكامل بشكل دائم؟</string>
<string name="save_page">احفظ الصفحة</string>
@@ -211,7 +211,7 @@
<string name="detect_reader_mode_summary">اكتشف تلقائيًا ما إذا كانت المانجا عبارة عن webtoon</string>
<string name="appwidget_recent_description">المانجا التي قرأتها مؤخرًا</string>
<string name="appearance">مظهر</string>
<string name="bookmark_remove">حذف من المحفظة</string>
<string name="bookmark_remove">حذف الإشارة المرجعية</string>
<string name="disable_battery_optimization_summary">يساعد في فحص التحديثات في الخلفية</string>
<string name="auth_not_supported_by">تسجيل الدخول على %s غير مدعوم</string>
<string name="status_on_hold">معلقَّة</string>
@@ -228,7 +228,7 @@
<string name="sync_title">مزامنة بياناتك</string>
<string name="appwidget_shelf_description">مانغا من المفضلة لديك</string>
<string name="send">إرسال</string>
<string name="bookmark_add">اضافة للمحفظة</string>
<string name="bookmark_add">اضافة إشارة مرجعية</string>
<string name="screenshots_block_all">احظر دائما</string>
<string name="new_sources_text">تتوفر مصادر مانغا جديدة</string>
<string name="zoom_mode_fit_height">مناسب للارتفاع</string>
@@ -338,4 +338,12 @@
<string name="folder_with_images_import_description">يمكنك اختيار مكان في الذاكرة يحتوي على أرشيفات أو صور. سيتم التعرف على كل أرشيف (أو مجلد فرعي) على أنه فصل.</string>
<string name="speed">السرعة</string>
<string name="restore_backup_description">استيراد نسخة احتياطية تم إنشاؤها لبيانات المستخدم.</string>
</resources>
<string name="feed">الموجز</string>
<string name="light_indicator">مؤشر إل إي دي</string>
<string name="comics_archive">أرشيف القصص المصورة</string>
<string name="importing_manga">استيراد المانجا</string>
<string name="import_completed">تم الإستيراد</string>
<string name="import_completed_hint">يمكنك حذف الملف الأصلي من التخزين لتوفير مساحة</string>
<string name="import_will_start_soon">الإستيراد سيبدأ عن قريب</string>
<string name="history_shortcuts">إظهار اختصارات المانجا الحديثة</string>
</resources>

View File

@@ -45,7 +45,7 @@
<string name="theme">Тэма</string>
<string name="light">Светлая</string>
<string name="dark">Цёмная</string>
<string name="automatic">Як у сістэме</string>
<string name="follow_system">Як у сістэме</string>
<string name="pages">Старонкi</string>
<string name="clear">Ачысціць</string>
<string name="text_clear_history_prompt">Вы ўпэўненыя, што жадаеце ачысціць гісторыю\?</string>
@@ -201,7 +201,7 @@
<string name="screenshots_block_all">Заўсёды блакуйце</string>
<string name="screenshots_block_nsfw">Забараніць для NSFW</string>
<string name="filter_load_error">Немагчыма загрузіць спіс жанраў</string>
<string name="disabled">Адключаны</string>
<string name="disabled">Адкл.</string>
<string name="enabled">Ўключаны</string>
<string name="exclude_nsfw_from_suggestions">Ня прапаноўваць NSFW мангу</string>
<string name="text_suggestion_holder">Пачніце чытаць мангу, і вы атрымаеце персаналізаваныя прапановы</string>
@@ -313,7 +313,7 @@
<string name="manga_error_description_pattern">Дэталі памылкі:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1. Паспрабуйце &lt;a href=%2$s&gt;адкрыць мангу ў вэб-браўзеры&lt;/a&gt;, каб пераканацца, што яна даступная ў крыніцы&lt;br&gt;2. Упэўніцеся, што вы выкарыстоўваеце &lt;a href=kotatsu://about&gt;апошнюю версію Kotatsu&lt;/a&gt;&lt;br&gt;3. Калі ён даступны, адпраўце распрацоўнікам справаздачу аб памылцы.</string>
<string name="history_shortcuts">Паказаць апошнія ярлыкі мангі</string>
<string name="history_shortcuts_summary">Зрабіце нядаўнюю мангу даступнай, доўга націскаючы на значок праграмы</string>
<string name="reader_control_ltr_summary">Навігацыя \"Далей\" заўсёды прыводзіць да наступнай старонцы пры выкарыстанні мышы і клавіятуры.</string>
<string name="reader_control_ltr_summary">Націск на правы край або націск правай клавішы заўсёды перамыкае на наступную старонку.</string>
<string name="reader_control_ltr">Эрганамічны упраўленне чытаннем</string>
<string name="color_correction">Карэкцыя колеру</string>
<string name="brightness">Яркасць</string>
@@ -469,7 +469,7 @@
<string name="disable_nsfw">Адключыць NSFW</string>
<string name="too_many_requests_message">Занадта шмат запытаў. Паўтарыце спробу пазней</string>
<string name="related_manga_summary">Паказаць спіс звязанай мангі. У некаторых выпадках ён можа быць недакладным або адсутнічаць</string>
<string name="advanced">Пашыраныя</string>
<string name="advanced">Прасунутая</string>
<string name="default_section">Раздзел па змаўчанні</string>
<string name="manga_list">Спіс мангі</string>
<string name="error_corrupted_file">Вяртаюцца няправільныя дадзеныя ці файл пашкоджаны</string>
@@ -588,4 +588,10 @@
<string name="check_for_new_chapters_disabled">Праверка новых раздзелаў адключана</string>
<string name="reading_time_estimation">Паказаць прыблізны час чытання</string>
<string name="reading_time_estimation_summary">Значэнне ацэнкі часу можа быць недакладным</string>
</resources>
<string name="show_labels_in_navbar">Паказаць меткі на панэлі навігацыі</string>
<string name="ask_for_dest_dir_every_time">Кожны раз запытваць каталог прызначэння</string>
<string name="default_page_save_dir">Каталог захавання старонкі па змаўчанні</string>
<string name="remove_from_history">Выдаліць з гісторыі</string>
<string name="pages_saving">Захаванне старонак</string>
<string name="location">Размяшчэнне</string>
</resources>

View File

@@ -64,7 +64,7 @@
<string name="theme">থিম</string>
<string name="light">আলো</string>
<string name="dark">আঁধার</string>
<string name="automatic">সিস্টেম অনুযায়ী</string>
<string name="follow_system">সিস্টেম অনুযায়ী</string>
<string name="pages">পৃষ্ঠাগুলি</string>
<string name="webtoon">ওয়েবটুন</string>
<string name="read_mode">পড়ার মোড</string>
@@ -159,4 +159,4 @@
<string name="suggestion_manga">পরামর্শ: %s</string>
<string name="text_empty_holder_primary">এখানে খালি…</string>
<string name="done">সম্পন্ন</string>
</resources>
</resources>

View File

@@ -33,7 +33,7 @@
<string name="theme">Téma</string>
<string name="light">Světlé</string>
<string name="dark">Tmavé</string>
<string name="automatic">Následovat systém</string>
<string name="follow_system">Následovat systém</string>
<string name="remove">Odstranit</string>
<string name="_s_deleted_from_local_storage">\"%s\" smazáno z místního uložiště</string>
<string name="share_image">Sdílet obrázek</string>
@@ -444,4 +444,4 @@
<string name="clear_source_cookies_summary">Vyčistit cookies pouze pro specifikované domény. Ve většině případech bude neplatná autorizace</string>
<string name="download_option_manual_selection">Vyberte kapitoly manuálně</string>
<string name="description">Popis</string>
</resources>
</resources>

View File

@@ -4,7 +4,7 @@
<string name="text_clear_history_prompt">Gesamten Leseverlauf unwiderruflich löschen\?</string>
<string name="theme">Design</string>
<string name="pages">Seiten</string>
<string name="automatic">Wie System</string>
<string name="follow_system">Wie System</string>
<string name="dark">Dunkel</string>
<string name="light">Hell</string>
<string name="filter">Filter</string>
@@ -580,4 +580,4 @@
<string name="two_pages">Zwei Seiten</string>
<string name="next_chapter">Nächstes Kapitel</string>
<string name="prev_page">Vorherige Seite</string>
</resources>
</resources>

View File

@@ -31,7 +31,7 @@
<string name="by_rating">Βαθμολογία</string>
<string name="filter">Φίλτρο</string>
<string name="dark">Σκοτεινό</string>
<string name="automatic">Όπως στο σύστημα</string>
<string name="follow_system">Όπως στο σύστημα</string>
<string name="clear">Εκκαθάριση</string>
<string name="text_clear_history_prompt">Να διαγράψετε μόνιμα όλο το ιστορικό ανάγνωσης;</string>
<string name="remove">Διαγραφή</string>
@@ -556,4 +556,4 @@
<string name="appwidget_recent_description">Τα πρόσφατα διαβασμένα manga σου</string>
<string name="clear_cookies_summary">Μπορεί να βοηθήσει σε περίπτωση κάποιων προβλημάτων. Όλες οι εξουσιοδοτήσεις θα ανακληθούν</string>
<string name="category_hidden_done">Αυτή η κατηγορία αποκρύφτηκε από την αρχική οθόνη και είναι προσβάσιμη μέσω του Μενού → Διαχείριση κατηγοριών</string>
</resources>
</resources>

View File

@@ -45,7 +45,7 @@
<string name="theme">Tema</string>
<string name="light">Claro</string>
<string name="dark">Oscuro</string>
<string name="automatic">De acuerdo al sistema</string>
<string name="follow_system">De acuerdo al sistema</string>
<string name="pages">Páginas</string>
<string name="clear">Limpiar</string>
<string name="text_clear_history_prompt">Borrar todo el historial de lectura de forma permanente\?</string>
@@ -328,7 +328,7 @@
<string name="color_correction_hint">Los ajustes de color elegidos serán recordados para este manga</string>
<string name="feed">Fuente</string>
<string name="history_shortcuts">Mostrar los accesos directos a los mangas recientes</string>
<string name="reader_control_ltr_summary">Tocando el borde derecho o pulsando la tecla derecha se pasa siempre a la página siguiente</string>
<string name="reader_control_ltr_summary">Tocando en el borde derecho, o pulsando la tecla derecha, se pasa siempre a la página siguiente.</string>
<string name="reader_control_ltr">Control ergonómico del lector</string>
<string name="color_correction">Corrección del color</string>
<string name="brightness">Brillo</string>
@@ -588,4 +588,10 @@
<string name="reading_time_estimation_summary">El valor estimado puede ser inexacto</string>
<string name="check_for_new_chapters_disabled">La búsqueda de nuevos capítulos está desactivada</string>
<string name="suggestions_unavailable_text">Sugerencias desactivadas</string>
</resources>
<string name="show_labels_in_navbar">Mostrar etiquetas en la barra de navegación</string>
<string name="ask_for_dest_dir_every_time">Preguntar siempre por el directorio de destino</string>
<string name="remove_from_history">Eliminar del historial</string>
<string name="pages_saving">Guardar páginas</string>
<string name="default_page_save_dir">Directorio predeterminado para guardar páginas</string>
<string name="location">Ubicación</string>
</resources>

View File

@@ -168,7 +168,7 @@
<string name="scale_mode">skaala mood</string>
<string name="advanced">Täiustatud</string>
<string name="only_using_wifi">Ainult Wi-Fi-l</string>
<string name="automatic">Jälgne süsteemile</string>
<string name="follow_system">Jälgne süsteemile</string>
<string name="sync_settings">Sünkroniseeri seadeid</string>
<string name="black_dark_theme">Must</string>
<string name="text_history_holder_primary">Mis sa loed näidatakse siin</string>
@@ -439,4 +439,4 @@
<string name="downloads_resumed">Allalaadimised on jätkanud</string>
<string name="invert_colors">Värvide ümberpööramine</string>
<string name="proxy">Puhverserver</string>
</resources>
</resources>

View File

@@ -14,7 +14,7 @@
<string name="internal_storage">حافظه ی درونی</string>
<string name="right_to_left">راست به چپ</string>
<string name="reader_mode_hint">پیکربندی انتخاب شده برای این مانگا بخاطر خواهد ماند</string>
<string name="automatic">تم سیستم</string>
<string name="follow_system">تم سیستم</string>
<string name="pages">صفحات</string>
<string name="clear">پاکسازی</string>
<string name="domain">دامنه</string>
@@ -255,4 +255,4 @@
<string name="notifications_enable">فعال کردن اعلان ها</string>
<string name="bookmark_remove">حذف نشانه</string>
<string name="bookmarks">نشانه ها</string>
</resources>
</resources>

View File

@@ -137,7 +137,7 @@
<string name="text_clear_history_prompt">Haluatko todella tyhjentää koko lukuhistoriasi\?</string>
<string name="clear">Tyhjennä</string>
<string name="pages">Sivut</string>
<string name="automatic">Automaattinen</string>
<string name="follow_system">Automaattinen</string>
<string name="dark">Tumma</string>
<string name="light">Vaalea</string>
<string name="theme">Teema</string>

View File

@@ -1,27 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="items">
<item quantity="one">%1$d aytem</item>
<item quantity="other">%1$d (na) aytem</item>
</plurals>
<plurals name="minutes_ago">
<item quantity="one">%1$d minutong nakakalipas</item>
<item quantity="other">%1$d (na) minutong nakakalipas</item>
</plurals>
<plurals name="new_chapters">
<item quantity="one">%1$d bagong kabanata</item>
<item quantity="other">%1$d mga bagong kabanata</item>
</plurals>
<plurals name="chapters">
<item quantity="one">"%1$d kabanata"</item>
<item quantity="other">%1$d (na) kabanata</item>
</plurals>
<plurals name="hours_ago">
<item quantity="one">%1$d oras ang nakalipas</item>
<item quantity="other">%1$d (na) oras ang nakalipas</item>
</plurals>
<plurals name="days_ago">
<item quantity="one">%1$d araw ang nakalipas</item>
<item quantity="other">%1$d (na) araw ang nakalipas</item>
</plurals>
</resources>
<plurals name="items">
<item quantity="one">%1$d aytem</item>
<item quantity="other">%1$d (na) aytem</item>
</plurals>
<plurals name="minutes_ago">
<item quantity="one">%1$d minutong nakakalipas</item>
<item quantity="other">%1$d (na) minutong nakakalipas</item>
</plurals>
<plurals name="new_chapters">
<item quantity="one">%1$d bagong kabanata</item>
<item quantity="other">%1$d mga bagong kabanata</item>
</plurals>
<plurals name="chapters">
<item quantity="one">"%1$d kabanata"</item>
<item quantity="other">%1$d (na) kabanata</item>
</plurals>
<plurals name="hours_ago">
<item quantity="one">%1$d oras ang nakalipas</item>
<item quantity="other">%1$d (na) oras ang nakalipas</item>
</plurals>
<plurals name="days_ago">
<item quantity="one">%1$d araw ang nakalipas</item>
<item quantity="other">%1$d (na) araw ang nakalipas</item>
</plurals>
<plurals name="months_ago">
<item quantity="one">%1$d buwan nakakalipas</item>
<item quantity="other">%1$d (na) buwan nakakalipas</item>
</plurals>
<plurals name="hours">
<item quantity="one">%1$d oras</item>
<item quantity="other">%1$d (na) oras</item>
</plurals>
<plurals name="minutes">
<item quantity="one">%1$d minuto</item>
<item quantity="other">%1$d (na) minuto</item>
</plurals>
</resources>

View File

@@ -7,7 +7,7 @@
<string name="filter">Pansala</string>
<string name="theme">Tema</string>
<string name="dark">Madilim</string>
<string name="automatic">Sundan ang sistema</string>
<string name="follow_system">Sundan ang sistema</string>
<string name="error_occurred">May nangyaring error</string>
<string name="network_error">Error sa network</string>
<string name="details">Mga detalye</string>
@@ -346,7 +346,7 @@
<string name="allow_unstable_updates_summary">Makakuha ng paunawa tungkol sa mga unstable build</string>
<string name="network_unavailable">Hindi magagamit ang network</string>
<string name="network_unavailable_hint">I-on ang Wi-Fi o mobile network para magbasa ng manga online</string>
<string name="reader_control_ltr_summary">Mag-tap sa kanang gilid o ang pagpindot sa kanang key ay palaging lilipat sa susunod na pahina</string>
<string name="reader_control_ltr_summary">Ang susunod na pag-navigate ay palaging magdadala sa iyo sa susunod na pahina kapag gumagamit ng mouse at keyboard.</string>
<string name="reader_slider">Ipakita ang slider ng paglipat ng pahina</string>
<string name="manga_error_description_pattern">Mga detalye ng error:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1. Subukang &lt;a href=%2$s&gt;magbukas ng manga sa isang web browser&lt;/a&gt; upang matiyak na available ito sa souce&lt;br&gt;2. Tiyaking ginagamit mo ang &lt;a href=kotatsu://about&gt;pinakabagong bersyon ng Kotatsu&lt;/a&gt;&lt;br&gt;3. Kung available ito, magpadala ng ulat ng error sa mga developer.</string>
<string name="enable_logging">Paganahin ang pag-log</string>
@@ -377,7 +377,7 @@
<string name="show_on_shelf">Ipakita sa Istante</string>
<string name="speed">Bilis</string>
<string name="comics_archive_import_description">Maaari kang pumili ng isa o higit pang .cbz o .zip file, ang bawat file ay makikilala bilang isang hiwalay na manga.</string>
<string name="folder_with_images_import_description">Maaari kang pumili ng isang directory na may mga archive o mga larawan. Ang bawat archive (o subdirectory) ay makikilala bilang isang kabanata.</string>
<string name="folder_with_images_import_description">Maaari kang pumili ng isang direktoryo na may mga archive o mga larawan. Ang bawat archive (o subdirectory) ay makikilala bilang isang kabanata.</string>
<string name="find_similar">Maghanap ng katulad</string>
<string name="sync_auth_hint">Maaari kang mag-sign in sa isang umiiral na account o lumikha ng bago</string>
<string name="translations">Mga pagsasalin</string>
@@ -438,7 +438,7 @@
<string name="download_option_manual_selection">Manu-manong pumili ng mga kabanata</string>
<string name="invert_colors">Baliktarin ang mga kulay</string>
<string name="custom_directory">Custom na direktoryo</string>
<string name="pick_custom_directory">Pumili ng Custom na direktoryo</string>
<string name="pick_custom_directory">Pumili ng custom na direktoryo</string>
<string name="no_access_to_file">Wala kang access sa file o direktoryo na ito</string>
<string name="local_manga_directories">Mga lokal na direktoryo ng manga</string>
<string name="password">Password</string>
@@ -478,7 +478,7 @@
<string name="on_device">Sa device</string>
<string name="moved_to_top">Nailipat sa itaas</string>
<string name="items_limit_exceeded">Wala nang mga aytem na pwedeng idagdag</string>
<string name="directories">Mga Directory</string>
<string name="directories">Mga direktoryo</string>
<string name="reader_zoom_buttons">Ipakita ang mga button ng pag-zoom</string>
<string name="main_screen_sections">Mga pangunahing seksyon ng screen</string>
<string name="zoom_out">Mag-zoom palabas</string>
@@ -502,7 +502,7 @@
<string name="periodic_backups">Mga periodic na pag-backup</string>
<string name="frequency_twice_per_month">Dalawang beses bawat buwan</string>
<string name="frequency_once_per_month">Isang beses bawat buwan</string>
<string name="backups_output_directory">Output directory ng mga backup</string>
<string name="backups_output_directory">Output na direktoryo sa mga backup</string>
<string name="last_successful_backup">Huling matagumpay na pag-backup: %s</string>
<string name="speed_value">x%.1f</string>
<string name="sources_catalog">Katalugo ng mga source</string>
@@ -557,4 +557,41 @@
\n
\nBabala: mawawala ang kasalukuyang progress sa pagbabasa.</string>
<string name="category_hidden_done">Nakatago ang kategoryang ito mula sa pangunahing screen at naa-access sa pamamagitan ng Menu → Ayusin ang mga kategorya</string>
</resources>
<string name="remove_from_history">Alisin sa kasaysayan</string>
<string name="incognito_mode_hint">Hindi mase-save ang iyong progress sa pagbabasa</string>
<string name="last_read">Huling nabasa</string>
<string name="default_webtoon_zoom_out">Default zoom out sa webtoon</string>
<string name="show_labels_in_navbar">Ipakita ang mga label sa navigation bar</string>
<string name="pages_saving">Nagse-save ng mga pahina</string>
<string name="ask_for_dest_dir_every_time">Laging magtanong sa direktoryo ng patutunguhan</string>
<string name="default_page_save_dir">Default na direktoryo ng pag-save ng pahina</string>
<string name="email_password_enter_hint">Ilagay ang iyong email at password upang magpatuloy</string>
<string name="remaining_time_pattern">%1$s %2$s</string>
<string name="volume_">Volume %d</string>
<string name="volume_unknown">Hindi kilalang volume</string>
<string name="approximate_remaining_time">Tinatayang natitirang oras</string>
<string name="vertical">Patayo</string>
<string name="show_menu">Ipakita ang menu</string>
<string name="tap_action">Aksyon sa pag-tap</string>
<string name="long_tap_action">Aksyon sa matagal na pag-tap</string>
<string name="none">Wala</string>
<string name="config_reset_confirm">I-reset ang mga setting sa mga default na value? Ang gawaing ito ay hindi pwedeng bawiin.</string>
<string name="use_two_pages_landscape">Gumamit ng dalawang page na layout sa landscape na oryentasyon (beta)</string>
<string name="fullscreen_mode">Fullscreen mode</string>
<string name="reader_fullscreen_summary">Itago ang status ng system at mga navigation bar</string>
<string name="two_pages">Dalawang pahina</string>
<string name="toggle_ui">Ipakita/itago ang UI</string>
<string name="prev_chapter">Nakaraang kabanata</string>
<string name="next_chapter">Sunod na kabanata</string>
<string name="prev_page">Nakaraang pahina</string>
<string name="next_page">Susunod na pahina</string>
<string name="reader_actions">Mga aksyon sa reader</string>
<string name="reader_actions_summary">Ayusin ang mga pagkilos para sa mga nata-tap na lugar ng screen</string>
<string name="switch_pages_volume_buttons">Paganahin ang mga volume button</string>
<string name="switch_pages_volume_buttons_summary">Gumamit ng mga volume button para sa paglipat ng mga pahina</string>
<string name="suggestions_unavailable_text">Naka-disable ang feature na Mga suhestiyon</string>
<string name="check_for_new_chapters_disabled">Naka-disable ang pagsuri para sa mga bagong kabanata</string>
<string name="reading_time_estimation">Ipakita ang tinantyang oras ng pagbabasa</string>
<string name="reading_time_estimation_summary">Maaaring hindi tumpak ang halaga ng pagtatantya ng oras</string>
<string name="location">Lokasyon</string>
</resources>

View File

@@ -36,13 +36,13 @@
<item quantity="other">Il y a %1$d mois</item>
</plurals>
<plurals name="hours">
<item quantity="one">heure</item>
<item quantity="many">heures</item>
<item quantity="other">heures</item>
<item quantity="one">%1$d heure</item>
<item quantity="many">%1$d heures</item>
<item quantity="other">%1$d heures</item>
</plurals>
<plurals name="minutes">
<item quantity="one">minute</item>
<item quantity="many">minutes</item>
<item quantity="other">minutes</item>
<item quantity="one">%1$d minute</item>
<item quantity="many">%1$d minutes</item>
<item quantity="other">%1$d minutes</item>
</plurals>
</resources>

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