Compare commits
56 Commits
v6.7.2
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d1a2fcf77 | ||
|
|
876675445d | ||
|
|
f7a70680bd | ||
|
|
8e82db441c | ||
|
|
f2626c668d | ||
|
|
4694215ccc | ||
|
|
096f5b15dc | ||
|
|
101d357eff | ||
|
|
11cd5609bb | ||
|
|
fda59996aa | ||
|
|
20461112d2 | ||
|
|
f98bb87d6e | ||
|
|
c451952a1e | ||
|
|
f8cbc9692f | ||
|
|
9f3113363b | ||
|
|
dba36838d4 | ||
|
|
f6de1b02d7 | ||
|
|
d6b8e2fd9e | ||
|
|
5227240478 | ||
|
|
8f65ea6535 | ||
|
|
7d7a6eadd2 | ||
|
|
40f1ad3181 | ||
|
|
a28c9447d7 | ||
|
|
a84cf97982 | ||
|
|
3a8eb58fd1 | ||
|
|
5d75e9af4a | ||
|
|
d4684e7462 | ||
|
|
c0a2f0b533 | ||
|
|
40867dd2b6 | ||
|
|
c3294e6459 | ||
|
|
5139feb51a | ||
|
|
6b1240fccb | ||
|
|
e00a5b7505 | ||
|
|
2c07d2c8e1 | ||
|
|
45c3c05f01 | ||
|
|
e97a745713 | ||
|
|
2dc4de0a3c | ||
|
|
3cf2c58058 | ||
|
|
1e19f32fc5 | ||
|
|
99e4359523 | ||
|
|
04868488cc | ||
|
|
2b3b406b84 | ||
|
|
7ab3c75232 | ||
|
|
61f7755465 | ||
|
|
9389015ab9 | ||
|
|
bc56a94aa6 | ||
|
|
7cfcaec6dd | ||
|
|
39c7ae31cd | ||
|
|
9349eccc0c | ||
|
|
8204934359 | ||
|
|
b5497c571e | ||
|
|
35a2ac4b04 | ||
|
|
b4d52f1367 | ||
|
|
325a8be484 | ||
|
|
f39ccb6223 | ||
|
|
6cb6c891dd |
@@ -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'
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ class AppShortcutManagerTest {
|
||||
page = 4,
|
||||
scroll = 2,
|
||||
percent = 0.3f,
|
||||
force = false,
|
||||
)
|
||||
awaitUpdate()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 )")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
enum class DownloadFormat {
|
||||
|
||||
AUTOMATIC,
|
||||
SINGLE_CBZ,
|
||||
MULTIPLE_CBZ,
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ class HistoryUpdateUseCase @Inject constructor(
|
||||
page = readerState.page,
|
||||
scroll = readerState.scroll,
|
||||
percent = percent,
|
||||
force = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class MarkAsReadUseCase @Inject constructor(
|
||||
page = pages.lastIndex,
|
||||
scroll = 0,
|
||||
percent = 1f,
|
||||
force = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
28
app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsAD.kt
Normal file
28
app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsAD.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -293,6 +293,7 @@ class TrackWorker @AssistedInject constructor(
|
||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
setDefaults(0)
|
||||
setOngoing(false)
|
||||
setOnlyAlertOnce(true)
|
||||
setSilent(true)
|
||||
setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
108
app/src/main/res/layout/activity_stats.xml
Normal file
108
app/src/main/res/layout/activity_stats.xml
Normal 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>
|
||||
54
app/src/main/res/layout/dialog_local_info.xml
Normal file
54
app/src/main/res/layout/dialog_local_info.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
52
app/src/main/res/layout/item_stats.xml
Normal file
52
app/src/main/res/layout/item_stats.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
74
app/src/main/res/layout/preference_split_switch.xml
Normal file
74
app/src/main/res/layout/preference_split_switch.xml
Normal 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>
|
||||
@@ -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>
|
||||
82
app/src/main/res/layout/sheet_stats_manga.xml
Normal file
82
app/src/main/res/layout/sheet_stats_manga.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
app/src/main/res/menu/opt_stats.xml
Normal file
13
app/src/main/res/menu/opt_stats.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">Дэталі памылкі:<br><tt>%1$s</tt><br><br>1. Паспрабуйце <a href=%2$s>адкрыць мангу ў вэб-браўзеры</a>, каб пераканацца, што яна даступная ў крыніцы<br>2. Упэўніцеся, што вы выкарыстоўваеце <a href=kotatsu://about>апошнюю версію Kotatsu</a><br>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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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:<br><tt>%1$s</tt><br><br>1. Subukang <a href=%2$s>magbukas ng manga sa isang web browser</a> upang matiyak na available ito sa souce<br>2. Tiyaking ginagamit mo ang <a href=kotatsu://about>pinakabagong bersyon ng Kotatsu</a><br>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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user