diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 000000000..e32589680
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,29 @@
+**PLEASE READ THIS**
+
+I acknowledge that:
+
+- I have updated to the latest version of the app (https://github.com/KotatsuApp/Kotatsu/releases/latest)
+- If this is an issue with a parser, that I should be opening an issue in https://github.com/KotatsuApp/kotatsu-parsers
+- I have searched the existing issues and this is new ticket **NOT** a duplicate or related to another open or closed issue
+- I will fill out the title and the information in this template
+
+Note that the issue will be automatically closed if you do not fill out the title or requested information.
+
+**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
+
+---
+
+## Device information
+* Kotatsu version: ?
+* Android version: ?
+* Device: ?
+
+## Steps to reproduce
+1. First step
+2. Second step
+
+## Issue/Request
+?
+
+## Other details
+Additional details and attachments.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index b6d4254a1..c7cc0c855 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: ⚠️ Source issue
- url: https://github.com/nv95/kotatsu-parsers/issues/new
+ url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/report_issue.yml b/.github/ISSUE_TEMPLATE/report_issue.yml
index e0a417b3f..4bc2d2de9 100644
--- a/.github/ISSUE_TEMPLATE/report_issue.yml
+++ b/.github/ISSUE_TEMPLATE/report_issue.yml
@@ -85,9 +85,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
- required: true
- - label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
+ - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new).
required: true
- label: I will fill out all of the requested information in this form.
required: true
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/request_feature.yml b/.github/ISSUE_TEMPLATE/request_feature.yml
index d4b373203..b49ba479b 100644
--- a/.github/ISSUE_TEMPLATE/request_feature.yml
+++ b/.github/ISSUE_TEMPLATE/request_feature.yml
@@ -21,6 +21,16 @@ body:
placeholder: |
Additional details and attachments.
+ - type: input
+ id: kotatsu-version
+ attributes:
+ label: Kotatsu version
+ description: You can find your Kotatsu version in **Settings → About**.
+ placeholder: |
+ Example: "3.3"
+ validations:
+ required: true
+
- type: checkboxes
id: acknowledgements
attributes:
@@ -31,9 +41,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
- required: true
- - label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
+ - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new).
required: true
- label: I will fill out all of the requested information in this form.
required: true
\ No newline at end of file
diff --git a/.github/workflows/issue_moderator.yml b/.github/workflows/issue_moderator.yml
new file mode 100644
index 000000000..ef256ed06
--- /dev/null
+++ b/.github/workflows/issue_moderator.yml
@@ -0,0 +1,29 @@
+name: Issue moderator
+
+on:
+ issues:
+ types: [opened, edited, reopened]
+ issue_comment:
+ types: [created]
+
+jobs:
+ moderate:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Moderate issues
+ uses: tachiyomiorg/issue-moderator-action@v1
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+ auto-close-rules: |
+ [
+ {
+ "type": "body",
+ "regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
+ "message": "The acknowledgment section was not removed."
+ },
+ {
+ "type": "body",
+ "regex": ".*\\* (Kotatsu version|Android version|Device): \\?.*",
+ "message": "Requested information in the template was not filled out."
+ }
+ ]
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 9b63e14e7..5611db9cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@
/.idea/kotlinScripting.xml
/.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml
+/.idea/render.experimental.xml
.DS_Store
/build
/captures
diff --git a/.idea/render.experimental.xml b/.idea/render.experimental.xml
deleted file mode 100644
index 5cad4e0be..000000000
--- a/.idea/render.experimental.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index 6714250e3..1d693109e 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Kotatsu is a free and open source manga reader for Android.
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
-Download APK from Github Releases:
+Download APK from GitHub Releases:
- [Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)
diff --git a/app/build.gradle b/app/build.gradle
index bf66187c7..1ca65ee45 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
- versionCode 410
- versionName '3.3.1'
+ versionCode 411
+ versionName '3.3.2'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -24,6 +24,10 @@ android {
arg 'room.schemaLocation', "$projectDir/schemas".toString()
}
}
+
+ // define this values in your local.properties file
+ buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
+ buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
}
buildTypes {
debug {
@@ -73,19 +77,19 @@ afterEvaluate {
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
- implementation('com.github.nv95:kotatsu-parsers:8a3b6df91d') {
+ implementation('com.github.nv95:kotatsu-parsers:c92f89f307') {
exclude group: 'org.json', module: 'json'
}
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3'
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.5.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.5.0-rc01'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc01'
- implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc01'
- implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc01'
- implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc01'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc02'
+ implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc02'
+ implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc02'
+ implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc02'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
@@ -95,7 +99,7 @@ dependencies {
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-alpha02'
//noinspection LifecycleAnnotationProcessorWithJava8
- kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc01'
+ kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc02'
implementation 'androidx.room:room-runtime:2.4.2'
implementation 'androidx.room:room-ktx:2.4.2'
@@ -103,7 +107,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
- implementation 'com.squareup.okio:okio:3.1.0'
+ implementation 'com.squareup.okio:okio:3.2.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
@@ -120,14 +124,14 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2'
- testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
- androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2'
+ androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3'
androidTestImplementation 'io.insert-koin:koin-test:3.2.0'
androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 72cfa4f2c..22bad7dd9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -59,7 +59,15 @@
android:label="@string/search_manga" />
+ android:exported="true"
+ android:label="@string/settings">
+
+
+
+
+
+
+
: BottomSheetDialogFragment() {
return AppBottomSheetDialog(requireContext(), theme)
}
+ fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) {
+ val b = behavior ?: return
+ b.addBottomSheetCallback(callback)
+ val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet)
+ if (rootView != null) {
+ callback.onStateChanged(rootView, b.state)
+ }
+ }
+
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
index 2125d044c..7db0f6e22 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
@@ -6,14 +6,12 @@ import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
-import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt
deleted file mode 100644
index 4b5c02ca6..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt
+++ /dev/null
@@ -1,89 +0,0 @@
-package org.koitharu.kotatsu.base.ui.dialog
-
-import android.content.Context
-import android.content.DialogInterface
-import android.text.InputFilter
-import android.view.LayoutInflater
-import androidx.annotation.StringRes
-import androidx.appcompat.app.AlertDialog
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import org.koitharu.kotatsu.databinding.DialogInputBinding
-
-class TextInputDialog private constructor(
- private val delegate: AlertDialog,
-) : DialogInterface by delegate {
-
- fun show() = delegate.show()
-
- class Builder(context: Context) {
-
- private val binding = DialogInputBinding.inflate(LayoutInflater.from(context))
-
- private val delegate = MaterialAlertDialogBuilder(context)
- .setView(binding.root)
-
- fun setTitle(@StringRes titleResId: Int): Builder {
- delegate.setTitle(titleResId)
- return this
- }
-
- fun setTitle(title: CharSequence): Builder {
- delegate.setTitle(title)
- return this
- }
-
- fun setHint(@StringRes hintResId: Int): Builder {
- binding.inputEdit.hint = binding.root.context.getString(hintResId)
- return this
- }
-
- fun setMaxLength(maxLength: Int, strict: Boolean): Builder {
- with(binding.inputLayout) {
- counterMaxLength = maxLength
- isCounterEnabled = maxLength > 0
- }
- if (strict && maxLength > 0) {
- binding.inputEdit.filters += InputFilter.LengthFilter(maxLength)
- }
- return this
- }
-
- fun setInputType(inputType: Int): Builder {
- binding.inputEdit.inputType = inputType
- return this
- }
-
- fun setText(text: String): Builder {
- binding.inputEdit.setText(text)
- binding.inputEdit.setSelection(text.length)
- return this
- }
-
- fun setPositiveButton(
- @StringRes textId: Int,
- listener: (DialogInterface, String) -> Unit
- ): Builder {
- delegate.setPositiveButton(textId) { dialog, _ ->
- listener(dialog, binding.inputEdit.text?.toString().orEmpty())
- }
- return this
- }
-
- fun setNegativeButton(
- @StringRes textId: Int,
- listener: DialogInterface.OnClickListener? = null
- ): Builder {
- delegate.setNegativeButton(textId, listener)
- return this
- }
-
- fun setOnCancelListener(listener: DialogInterface.OnCancelListener): Builder {
- delegate.setOnCancelListener(listener)
- return this
- }
-
- fun create() =
- TextInputDialog(delegate.create())
-
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt
index ac624d3c6..1974f6a5d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt
@@ -12,7 +12,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val boundsF = RectF()
- private val selection = HashSet()
+ protected val selection = HashSet()
protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
index 9c85787a7..909252b48 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
@@ -17,19 +17,28 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
-import android.widget.Button
import android.widget.FrameLayout
-import android.widget.TextView
+import androidx.annotation.ColorInt
import androidx.annotation.StringRes
+import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.postDelayed
-import org.koitharu.kotatsu.R
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.shape.MaterialShapeDrawable
+import com.google.android.material.shape.ShapeAppearanceModel
+import com.google.android.material.snackbar.Snackbar
+import org.koitharu.kotatsu.databinding.FadingSnackbarLayoutBinding
+import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
+import com.google.android.material.R as materialR
private const val ENTER_DURATION = 300L
private const val EXIT_DURATION = 200L
-private const val SHORT_DURATION = 1_500L
-private const val LONG_DURATION = 2_750L
+private const val SHORT_DURATION_MS = 1_500L
+private const val LONG_DURATION_MS = 2_750L
+
/**
* A custom snackbar implementation allowing more control over placement and entry/exit animations.
*
@@ -40,16 +49,13 @@ private const val LONG_DURATION = 2_750L
class FadingSnackbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
+ defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) {
- private val message: TextView
- private val action: Button
+ private val binding = FadingSnackbarLayoutBinding.inflate(LayoutInflater.from(context), this)
init {
- val view = LayoutInflater.from(context).inflate(R.layout.fading_snackbar_layout, this, true)
- message = view.findViewById(R.id.snackbar_text)
- action = view.findViewById(R.id.snackbar_action)
+ binding.snackbarLayout.background = createThemedBackground()
}
fun dismiss() {
@@ -62,33 +68,66 @@ class FadingSnackbar @JvmOverloads constructor(
}
fun show(
- messageText: CharSequence? = null,
- @StringRes actionId: Int? = null,
- longDuration: Boolean = true,
- actionClick: () -> Unit = { dismiss() },
- dismissListener: () -> Unit = { }
+ messageText: CharSequence?,
+ @StringRes actionId: Int = 0,
+ duration: Int = Snackbar.LENGTH_SHORT,
+ onActionClick: (FadingSnackbar.() -> Unit)? = null,
+ onDismiss: (() -> Unit)? = null,
) {
- message.text = messageText
- if (actionId != null) {
- action.run {
+ binding.snackbarText.text = messageText
+ if (actionId != 0) {
+ with(binding.snackbarAction) {
visibility = VISIBLE
text = context.getString(actionId)
setOnClickListener {
- actionClick()
+ onActionClick?.invoke(this@FadingSnackbar) ?: dismiss()
}
}
} else {
- action.visibility = GONE
+ binding.snackbarAction.visibility = GONE
}
alpha = 0f
visibility = VISIBLE
animate()
.alpha(1f)
.duration = ENTER_DURATION
- val showDuration = ENTER_DURATION + if (longDuration) LONG_DURATION else SHORT_DURATION
- postDelayed(showDuration) {
+ if (duration == Snackbar.LENGTH_INDEFINITE) {
+ return
+ }
+ val durationMs = ENTER_DURATION + if (duration == Snackbar.LENGTH_LONG) LONG_DURATION_MS else SHORT_DURATION_MS
+ postDelayed(durationMs) {
dismiss()
- dismissListener()
+ onDismiss?.invoke()
}
}
+
+ private fun createThemedBackground(): Drawable {
+ val backgroundColor = MaterialColors.layer(this, materialR.attr.colorSurface, materialR.attr.colorOnSurface, 1f)
+ val shapeAppearanceModel = ShapeAppearanceModel.builder(
+ context,
+ materialR.style.ShapeAppearance_Material3_Corner_ExtraSmall,
+ 0
+ ).build()
+ val background = createMaterialShapeDrawableBackground(
+ backgroundColor,
+ shapeAppearanceModel,
+ )
+ val backgroundTint = context.getThemeColorStateList(materialR.attr.colorSurfaceInverse)
+ return if (backgroundTint != null) {
+ val wrappedDrawable = DrawableCompat.wrap(background)
+ DrawableCompat.setTintList(wrappedDrawable, backgroundTint)
+ wrappedDrawable
+ } else {
+ DrawableCompat.wrap(background)
+ }
+ }
+
+ private fun createMaterialShapeDrawableBackground(
+ @ColorInt backgroundColor: Int,
+ shapeAppearanceModel: ShapeAppearanceModel,
+ ): MaterialShapeDrawable {
+ val background = MaterialShapeDrawable(shapeAppearanceModel)
+ background.fillColor = ColorStateList.valueOf(backgroundColor)
+ return background
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
index 0959b3362..1f348899b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
@@ -25,4 +25,5 @@ class BookmarkEntity(
@ColumnInfo(name = "scroll") val scroll: Int,
@ColumnInfo(name = "image") val imageUrl: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
+ @ColumnInfo(name = "percent") val percent: Float,
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
index 981aa05ea..0ab69dd18 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
@@ -18,6 +18,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
scroll = scroll,
imageUrl = imageUrl,
createdAt = Date(createdAt),
+ percent = percent,
)
fun Bookmark.toEntity() = BookmarkEntity(
@@ -28,4 +29,5 @@ fun Bookmark.toEntity() = BookmarkEntity(
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt.time,
+ percent = percent,
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
index 0b76c6537..5b6ff3bf0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
@@ -11,6 +11,7 @@ class Bookmark(
val scroll: Int,
val imageUrl: String,
val createdAt: Date,
+ val percent: Float,
) {
override fun equals(other: Any?): Boolean {
@@ -26,6 +27,7 @@ class Bookmark(
if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false
+ if (percent != other.percent) return false
return true
}
@@ -38,6 +40,7 @@ class Bookmark(
result = 31 * result + scroll
result = 31 * result + imageUrl.hashCode()
result = 31 * result + createdAt.hashCode()
+ result = 31 * result + percent.hashCode()
return result
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
index 4b42b4c60..7212b8569 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
@@ -111,6 +111,7 @@ class BackupRepository(private val db: MangaDatabase) {
jo.put("chapter_id", chapterId)
jo.put("page", page)
jo.put("scroll", scroll)
+ jo.put("percent", percent)
return jo
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
index f01dc73d9..8a6217d04 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
@@ -15,11 +15,11 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
- suspend fun put(entry: BackupEntry) {
+ suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name, entry.data.toString(2))
}
- suspend fun finish() {
+ suspend fun finish() = runInterruptible(Dispatchers.IO) {
output.finish()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
index 57fd4d6ee..4e20b955f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
@@ -9,10 +9,7 @@ import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.parsers.model.SortOrder
-import org.koitharu.kotatsu.parsers.util.json.JSONIterator
-import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
-import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
-import org.koitharu.kotatsu.parsers.util.json.mapJSON
+import org.koitharu.kotatsu.parsers.util.json.*
class RestoreRepository(private val db: MangaDatabase) {
@@ -95,7 +92,8 @@ class RestoreRepository(private val db: MangaDatabase) {
updatedAt = json.getLong("updated_at"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
- scroll = json.getDouble("scroll").toFloat()
+ scroll = json.getDouble("scroll").toFloat(),
+ percent = json.getFloatOrDefault("percent", -1f),
)
private fun parseCategory(json: JSONObject) = FavouriteCategoryEntity(
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
index 2c74455f8..82d5052aa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
@@ -6,8 +6,14 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
-import org.koitharu.kotatsu.core.db.dao.*
-import org.koitharu.kotatsu.core.db.entity.*
+import org.koitharu.kotatsu.core.db.dao.MangaDao
+import org.koitharu.kotatsu.core.db.dao.PreferencesDao
+import org.koitharu.kotatsu.core.db.dao.TagsDao
+import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
+import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
+import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
+import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
@@ -15,6 +21,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
+import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao
+import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity
@@ -26,8 +34,9 @@ import org.koitharu.kotatsu.tracker.data.TracksDao
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
+ ScrobblingEntity::class,
],
- version = 11,
+ version = 12,
)
abstract class MangaDatabase : RoomDatabase() {
@@ -50,6 +59,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val suggestionDao: SuggestionDao
abstract val bookmarksDao: BookmarksDao
+
+ abstract val scrobblingDao: ScrobblingDao
}
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
@@ -67,6 +78,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
Migration8To9(),
Migration9To10(),
Migration10To11(),
+ Migration11To12(),
).addCallback(
DatabasePrePopulateCallback(context.resources)
).build()
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
new file mode 100644
index 000000000..9d13d2420
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
@@ -0,0 +1,27 @@
+package org.koitharu.kotatsu.core.db.migrations
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+class Migration11To12 : Migration(11, 12) {
+
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `scrobblings` (
+ `scrobbler` INTEGER NOT NULL,
+ `id` INTEGER NOT NULL,
+ `manga_id` INTEGER NOT NULL,
+ `target_id` INTEGER NOT NULL,
+ `status` TEXT,
+ `chapter` INTEGER NOT NULL,
+ `comment` TEXT,
+ `rating` REAL NOT NULL,
+ PRIMARY KEY(`scrobbler`, `id`, `manga_id`)
+ )
+ """.trimIndent()
+ )
+ database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
+ database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt
index 5a8cd055c..ef20b4fb0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt
@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.core.exceptions
-import androidx.annotation.StringRes
import okio.IOException
-import org.koitharu.kotatsu.R
class CloudFlareProtectedException(
val url: String
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt
index 95f736f2b..9ac085183 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt
@@ -11,4 +11,5 @@ data class MangaHistory(
val chapterId: Long,
val page: Int,
val scroll: Int,
+ val percent: Float,
) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
index 2f7d73b6b..f377404c8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
@@ -9,6 +9,7 @@ object CommonHeaders {
const val ACCEPT = "Accept"
const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
+ const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl
get() = CacheControl.Builder().noStore().build()
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index ffa294262..cd9c8a07a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -10,6 +10,10 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
+import java.io.File
+import java.text.DateFormat
+import java.text.SimpleDateFormat
+import java.util.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
@@ -18,12 +22,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue
+import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull
-import java.io.File
-import java.text.DateFormat
-import java.text.SimpleDateFormat
-import java.util.*
class AppSettings(context: Context) {
@@ -40,7 +41,7 @@ class AppSettings(context: Context) {
get() = Collections.unmodifiableSet(remoteSources)
var listMode: ListMode
- get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
+ get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
var defaultSection: AppSection
@@ -104,10 +105,13 @@ class AppSettings(context: Context) {
val isReaderModeDetectionEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true)
- var historyGrouping: Boolean
+ var isHistoryGroupingEnabled: Boolean
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) }
+ val isReadingIndicatorsEnabled: Boolean
+ get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
+
val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
@@ -125,6 +129,10 @@ class AppSettings(context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
+ var isBiometricProtectionEnabled: Boolean
+ get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
+ set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
+
var sourcesOrder: List
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
@@ -242,15 +250,7 @@ class AppSettings(context: Context) {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
- fun observe() = callbackFlow {
- val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
- trySendBlocking(key)
- }
- prefs.registerOnSharedPreferenceChangeListener(listener)
- awaitClose {
- prefs.unregisterOnSharedPreferenceChangeListener(listener)
- }
- }
+ fun observe() = prefs.observe()
companion object {
@@ -293,11 +293,13 @@ class AppSettings(context: Context) {
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password"
const val KEY_PROTECT_APP = "protect_app"
+ const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version"
const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore"
const val KEY_HISTORY_GROUPING = "history_grouping"
+ const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers"
@@ -307,6 +309,7 @@ class AppSettings(context: Context) {
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
+ const val KEY_SHIKIMORI = "shikimori"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
@@ -316,9 +319,6 @@ class AppSettings(context: Context) {
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation"
- const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
- const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
- const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
index 916b75de1..88b40e2be 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
@@ -8,6 +8,6 @@ val detailsModule
get() = module {
viewModel { intent ->
- DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get())
+ DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get(), get())
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
index dc5e6a513..84155b516 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
@@ -21,9 +21,11 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.launch
+import org.acra.ktx.sendWithAcra
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -37,11 +39,13 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.download.ui.service.DownloadService
+import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
+import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -81,7 +85,7 @@ class DetailsActivity :
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) {
- binding.snackbar.show(messageText = getString(it), longDuration = false)
+ binding.snackbar.show(messageText = getString(it))
}
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
@@ -114,6 +118,21 @@ class DetailsActivity :
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition()
}
+ e is ParseException || e is IllegalArgumentException || e is IllegalStateException -> {
+ binding.snackbar.show(
+ messageText = e.getDisplayMessage(resources),
+ actionId = R.string.report,
+ duration = if (viewModel.manga.value?.chapters == null) {
+ Snackbar.LENGTH_INDEFINITE
+ } else {
+ Snackbar.LENGTH_LONG
+ },
+ onActionClick = {
+ e.sendWithAcra()
+ dismiss()
+ }
+ )
+ }
else -> {
binding.snackbar.show(e.getDisplayMessage(resources))
}
@@ -151,14 +170,11 @@ class DetailsActivity :
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val manga = viewModel.manga.value
- menu.findItem(R.id.action_save).isVisible =
- manga?.source != null && manga.source != MangaSource.LOCAL
- menu.findItem(R.id.action_delete).isVisible =
- manga?.source == MangaSource.LOCAL
- menu.findItem(R.id.action_browser).isVisible =
- manga?.source != MangaSource.LOCAL
- menu.findItem(R.id.action_shortcut).isVisible =
- ShortcutManagerCompat.isRequestPinShortcutSupported(this)
+ menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
+ menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
+ menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
+ menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this)
+ menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable
return super.onPrepareOptionsMenu(menu)
}
@@ -199,6 +215,12 @@ class DetailsActivity :
}
true
}
+ R.id.action_shiki_track -> {
+ viewModel.manga.value?.let {
+ ScrobblingSelectorBottomSheet.show(supportFragmentManager, it)
+ }
+ true
+ }
R.id.action_shortcut -> {
viewModel.manga.value?.let {
lifecycleScope.launch {
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
index dfc427d99..47ee11616 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
@@ -17,6 +17,7 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import coil.request.ImageRequest
+import coil.size.Scale
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import kotlinx.coroutines.launch
@@ -31,7 +32,9 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
+import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -39,6 +42,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize
@@ -67,6 +71,7 @@ class DetailsFragment :
binding.buttonRead.setOnClickListener(this)
binding.buttonRead.setOnLongClickListener(this)
binding.imageViewCover.setOnClickListener(this)
+ binding.scrobblingLayout.root.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
@@ -74,6 +79,7 @@ class DetailsFragment :
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
+ viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
addMenuProvider(DetailsMenuProvider())
}
@@ -176,6 +182,7 @@ class DetailsFragment :
setIconResource(R.drawable.ic_play)
}
}
+ binding.progressView.setPercent(history?.percent ?: PROGRESS_NONE, animate = true)
}
private fun onFavouriteChanged(isFavourite: Boolean) {
@@ -209,12 +216,39 @@ class DetailsFragment :
}
}
+ private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
+ with(binding.scrobblingLayout) {
+ root.isVisible = scrobbling != null
+ if (scrobbling == null) {
+ CoilUtils.dispose(imageViewCover)
+ return
+ }
+ imageViewCover.newImageRequest(scrobbling.coverUrl)
+ .crossfade(true)
+ .placeholder(R.drawable.ic_placeholder)
+ .fallback(R.drawable.ic_placeholder)
+ .error(R.drawable.ic_placeholder)
+ .scale(Scale.FILL)
+ .lifecycle(viewLifecycleOwner)
+ .enqueueWith(coil)
+ textViewTitle.text = scrobbling.title
+ textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, scrobbling.scrobbler.iconResId, 0)
+ ratingBar.rating = scrobbling.rating * ratingBar.numStars
+ textViewStatus.text = scrobbling.status?.let {
+ resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal)
+ }
+ }
+ }
+
override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return
when (v.id) {
R.id.button_favorite -> {
FavouriteCategoriesBottomSheet.show(childFragmentManager, manga)
}
+ R.id.scrobbling_layout -> {
+ ScrobblingInfoBottomSheet.show(childFragmentManager)
+ }
R.id.button_read -> {
val chapterId = viewModel.readingHistory.value?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
index 757c70f61..71a6c49fb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
@@ -4,7 +4,6 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
-import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
@@ -26,10 +25,13 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import java.io.IOException
class DetailsViewModel(
intent: MangaIntent,
@@ -40,6 +42,7 @@ class DetailsViewModel(
mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
+ private val scrobbler: Scrobbler,
) : BaseViewModel() {
private val delegate = MangaDetailsDelegate(
@@ -79,6 +82,11 @@ class DetailsViewModel(
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val onMangaRemoved = SingleLiveEvent()
+ val isScrobblingAvailable: Boolean
+ get() = scrobbler.isAvailable
+
+ val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId)
+ .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val branches: LiveData> = delegate.manga.map {
val chapters = it?.chapters ?: return@map emptyList()
@@ -188,6 +196,25 @@ class DetailsViewModel(
}
}
+ fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
+ launchJob(Dispatchers.Default) {
+ scrobbler.updateScrobblingInfo(
+ mangaId = delegate.mangaId,
+ rating = rating,
+ status = status,
+ comment = null,
+ )
+ }
+ }
+
+ fun unregisterScrobbling() {
+ launchJob(Dispatchers.Default) {
+ scrobbler.unregisterScrobbling(
+ mangaId = delegate.mangaId
+ )
+ }
+ }
+
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
index 07f03dbda..c6c45ecc1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import org.acra.ACRA
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
@@ -13,6 +14,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
+import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -20,6 +22,7 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import org.koitharu.kotatsu.utils.ext.setCurrentManga
class MangaDetailsDelegate(
private val intent: MangaIntent,
@@ -32,6 +35,7 @@ class MangaDetailsDelegate(
private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow(null)
+
// Remote manga for saved and saved for remote
val relatedManga = MutableStateFlow(null)
val manga: StateFlow
@@ -41,6 +45,7 @@ class MangaDetailsDelegate(
suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga")
+ ACRA.setCurrentManga(manga)
mangaData.value = manga
manga = MangaRepository(manga.source).getDetails(manga)
// find default branch
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt
new file mode 100644
index 000000000..8347b0a7e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt
@@ -0,0 +1,150 @@
+package org.koitharu.kotatsu.details.ui.scrobbling
+
+import android.app.ActivityOptions
+import android.content.Intent
+import android.os.Bundle
+import android.text.method.LinkMovementMethod
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.RatingBar
+import android.widget.Toast
+import androidx.appcompat.widget.PopupMenu
+import androidx.core.net.toUri
+import androidx.fragment.app.FragmentManager
+import coil.ImageLoader
+import coil.request.ImageRequest
+import coil.size.Scale
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseBottomSheet
+import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
+import org.koitharu.kotatsu.details.ui.DetailsViewModel
+import org.koitharu.kotatsu.image.ui.ImageActivity
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
+import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
+import org.koitharu.kotatsu.utils.ext.enqueueWith
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+
+class ScrobblingInfoBottomSheet :
+ BaseBottomSheet(),
+ AdapterView.OnItemSelectedListener,
+ RatingBar.OnRatingBarChangeListener,
+ View.OnClickListener,
+ PopupMenu.OnMenuItemClickListener {
+
+ private val viewModel by sharedViewModel()
+ private val coil by inject(mode = LazyThreadSafetyMode.NONE)
+ private var menu: PopupMenu? = null
+
+ override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding {
+ return SheetScrobblingBinding.inflate(inflater, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
+ viewModel.onError.observe(viewLifecycleOwner) {
+ Toast.makeText(view.context, it.getDisplayMessage(view.resources), Toast.LENGTH_SHORT).show()
+ }
+
+ binding.spinnerStatus.onItemSelectedListener = this
+ binding.ratingBar.onRatingBarChangeListener = this
+ binding.buttonMenu.setOnClickListener(this)
+ binding.imageViewCover.setOnClickListener(this)
+ binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
+
+ menu = PopupMenu(view.context, binding.buttonMenu).apply {
+ inflate(R.menu.opt_scrobbling)
+ setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ menu = null
+ }
+
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ viewModel.updateScrobbling(
+ rating = binding.ratingBar.rating / binding.ratingBar.numStars,
+ status = enumValues().getOrNull(position),
+ )
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
+
+ override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) {
+ if (fromUser) {
+ viewModel.updateScrobbling(
+ rating = rating / ratingBar.numStars,
+ status = enumValues().getOrNull(binding.spinnerStatus.selectedItemPosition),
+ )
+ }
+ }
+
+ override fun onClick(v: View) {
+ when (v.id) {
+ R.id.button_menu -> menu?.show()
+ R.id.imageView_cover -> {
+ val coverUrl = viewModel.scrobblingInfo.value?.coverUrl ?: return
+ val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
+ startActivity(ImageActivity.newIntent(v.context, coverUrl), options.toBundle())
+ }
+ }
+ }
+
+ private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
+ if (scrobbling == null) {
+ dismissAllowingStateLoss()
+ return
+ }
+ binding.textViewTitle.text = scrobbling.title
+ binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars
+ binding.textViewDescription.text = scrobbling.description
+ binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
+ ImageRequest.Builder(context ?: return)
+ .target(binding.imageViewCover)
+ .data(scrobbling.coverUrl)
+ .crossfade(true)
+ .lifecycle(viewLifecycleOwner)
+ .placeholder(R.drawable.ic_placeholder)
+ .fallback(R.drawable.ic_placeholder)
+ .error(R.drawable.ic_placeholder)
+ .scale(Scale.FILL)
+ .enqueueWith(coil)
+ }
+
+ companion object {
+
+ private const val TAG = "ScrobblingInfoBottomSheet"
+
+ fun show(fm: FragmentManager) = ScrobblingInfoBottomSheet().show(fm, TAG)
+ }
+
+ override fun onMenuItemClick(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.action_browser -> {
+ val url = viewModel.scrobblingInfo.value?.externalUrl ?: return false
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri())
+ startActivity(
+ Intent.createChooser(intent, getString(R.string.open_in_browser))
+ )
+ }
+ R.id.action_unregister -> {
+ viewModel.unregisterScrobbling()
+ dismiss()
+ }
+ R.id.action_edit -> {
+ val manga = viewModel.manga.value ?: return false
+ ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga)
+ dismiss()
+ }
+ }
+ return true
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
index 6d880abb9..f3bc159a8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
@@ -11,10 +11,10 @@ import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel
val favouritesModule
get() = module {
- factory { FavouritesRepository(get(), get()) }
+ single { FavouritesRepository(get(), get()) }
viewModel { categoryId ->
- FavouritesListViewModel(categoryId.get(), get(), get(), get())
+ FavouritesListViewModel(categoryId.get(), get(), get(), get(), get())
}
viewModel { FavouritesCategoriesViewModel(get(), get()) }
viewModel { manga ->
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
index f7a98c078..cece6607f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
@@ -4,6 +4,7 @@ import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
+import com.google.android.material.R as materialR
class CategoriesEditDelegate(
private val context: Context,
@@ -11,9 +12,10 @@ class CategoriesEditDelegate(
) {
fun deleteCategory(category: FavouriteCategory) {
- MaterialAlertDialogBuilder(context)
+ MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setMessage(context.getString(R.string.category_delete_confirm, category.title))
.setTitle(R.string.remove_category)
+ .setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.remove) { _, _ ->
callback.onDeleteCategory(category)
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
index b38dfec26..96de7ea86 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
@@ -3,8 +3,6 @@ package org.koitharu.kotatsu.favourites.ui.categories.edit
import android.content.Context
import android.content.Intent
import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
@@ -24,7 +22,8 @@ import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-class FavouritesCategoryEditActivity : BaseActivity(), AdapterView.OnItemClickListener {
+class FavouritesCategoryEditActivity : BaseActivity(), AdapterView.OnItemClickListener,
+ View.OnClickListener {
private val viewModel by viewModel {
parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID))
@@ -39,6 +38,7 @@ class FavouritesCategoryEditActivity : BaseActivity
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
}
initSortSpinner()
+ binding.buttonDone.setOnClickListener(this)
viewModel.onSaved.observe(this) { finishAfterTransition() }
viewModel.category.observe(this, ::onCategoryChanged)
@@ -62,22 +62,14 @@ class FavouritesCategoryEditActivity : BaseActivity
}
}
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.opt_config, menu)
- menu.findItem(R.id.action_done)?.setTitle(R.string.save)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_done -> {
- viewModel.save(
+ override fun onClick(v: View) {
+ when (v.id) {
+ R.id.button_done -> viewModel.save(
title = binding.editName.text?.toString().orEmpty(),
sortOrder = getSelectedSortOrder(),
isTrackerEnabled = binding.switchTracker.isChecked,
)
- true
}
- else -> super.onOptionsItemSelected(item)
}
override fun onWindowInsetsChanged(insets: Insets) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
index aa2bacbbe..f13cce9a7 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
@@ -2,11 +2,9 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle
import android.view.LayoutInflater
-import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
-import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -28,7 +26,7 @@ class FavouriteCategoriesBottomSheet :
BaseBottomSheet(),
OnListItemClickListener,
CategoriesEditDelegate.CategoriesEditCallback,
- Toolbar.OnMenuItemClickListener, View.OnClickListener {
+ View.OnClickListener {
private val viewModel by viewModel {
parametersOf(requireNotNull(arguments?.getParcelableArrayList(KEY_MANGA_LIST)).map { it.manga })
@@ -45,7 +43,7 @@ class FavouriteCategoriesBottomSheet :
super.onViewCreated(view, savedInstanceState)
adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter
- binding.toolbar.setOnMenuItemClickListener(this)
+ binding.buttonDone.setOnClickListener(this)
binding.itemCreate.setOnClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
@@ -57,19 +55,10 @@ class FavouriteCategoriesBottomSheet :
super.onDestroyView()
}
- override fun onMenuItemClick(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.action_done -> {
- dismiss()
- true
- }
- else -> false
- }
- }
-
override fun onClick(v: View) {
when (v.id) {
R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
+ R.id.button_done -> dismiss()
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
index c2e8c00a1..44f89c767 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
@@ -11,7 +11,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
-import org.koitharu.kotatsu.list.domain.CountersProvider
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
+import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
@@ -25,8 +27,9 @@ class FavouritesListViewModel(
private val categoryId: Long,
private val repository: FavouritesRepository,
private val trackingRepository: TrackingRepository,
- settings: AppSettings,
-) : MangaListViewModel(settings), CountersProvider {
+ private val historyRepository: HistoryRepository,
+ private val settings: AppSettings,
+) : MangaListViewModel(settings), ListExtraProvider {
var sortOrder: LiveData = if (categoryId == NO_ID) {
MutableLiveData(null)
@@ -47,7 +50,7 @@ class FavouritesListViewModel(
when {
list.isEmpty() -> listOf(
EmptyState(
- icon = R.drawable.ic_heart_outline,
+ icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = if (categoryId == NO_ID) {
R.string.you_have_not_favourites_yet
@@ -92,4 +95,12 @@ class FavouritesListViewModel(
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)
}
+
+ override suspend fun getProgress(mangaId: Long): Float {
+ return if (settings.isReadingIndicatorsEnabled) {
+ historyRepository.getProgress(mangaId)
+ } else {
+ PROGRESS_NONE
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
index 246cb3a5f..319353b69 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule
get() = module {
- factory { HistoryRepository(get(), get(), get()) }
+ single { HistoryRepository(get(), get(), get(), getAll()) }
+
viewModel { HistoryListViewModel(get(), get(), get(), get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt
index c03300b99..0e5624559 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt
@@ -1,12 +1,13 @@
package org.koitharu.kotatsu.history.data
-import java.util.*
import org.koitharu.kotatsu.core.model.MangaHistory
+import java.util.*
fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
- scroll = scroll.toInt()
+ scroll = scroll.toInt(),
+ percent = percent,
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
index f52c5db6d..f27a0af0e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
@@ -45,26 +45,36 @@ abstract class HistoryDao {
@Query("SELECT COUNT(*) FROM history")
abstract fun observeCount(): Flow
+ @Query("SELECT percent FROM history WHERE manga_id = :id")
+ abstract fun findProgress(id: Long): Float?
+
@Query("DELETE FROM history")
abstract suspend fun clear()
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: HistoryEntity): Long
- @Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, updated_at = :updatedAt WHERE manga_id = :mangaId")
+ @Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt WHERE manga_id = :mangaId")
abstract suspend fun update(
mangaId: Long,
page: Int,
chapterId: Long,
scroll: Float,
- updatedAt: Long
+ percent: Float,
+ updatedAt: Long,
): Int
@Query("DELETE FROM history WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
- suspend fun update(entity: HistoryEntity) =
- update(entity.mangaId, entity.page, entity.chapterId, entity.scroll, entity.updatedAt)
+ suspend fun update(entity: HistoryEntity) = update(
+ mangaId = entity.mangaId,
+ page = entity.page,
+ chapterId = entity.chapterId,
+ scroll = entity.scroll,
+ percent = entity.percent,
+ updatedAt = entity.updatedAt
+ )
@Transaction
open suspend fun upsert(entity: HistoryEntity): Boolean {
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
index 24a487c60..181499fa4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
@@ -13,16 +13,17 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
- onDelete = ForeignKey.CASCADE
+ onDelete = ForeignKey.CASCADE,
)
]
)
class HistoryEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
- @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
+ @ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float,
+ @ColumnInfo(name = "percent") val percent: Float,
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
index 4519b60e4..4b7bb1c99 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
@@ -13,13 +13,18 @@ import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
+import org.koitharu.kotatsu.scrobbling.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
+const val PROGRESS_NONE = -1f
+
class HistoryRepository(
private val db: MangaDatabase,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
+ private val scrobblers: List,
) {
suspend fun getList(offset: Int, limit: Int = 20): List {
@@ -59,7 +64,7 @@ class HistoryRepository(
.distinctUntilChanged()
}
- suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
+ suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
return
}
@@ -75,9 +80,14 @@ class HistoryRepository(
chapterId = chapterId,
page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
+ percent = percent,
)
)
trackingRepository.syncWithHistory(manga, chapterId)
+ val chapter = manga.chapters?.find { x -> x.id == chapterId }
+ if (chapter != null) {
+ scrobblers.forEach { it.tryScrobble(manga.id, chapter) }
+ }
}
}
@@ -85,6 +95,10 @@ class HistoryRepository(
return db.historyDao.find(manga.id)?.toMangaHistory()
}
+ suspend fun getProgress(mangaId: Long): Float {
+ return db.historyDao.findProgress(mangaId) ?: PROGRESS_NONE
+ }
+
suspend fun clear() {
db.historyDao.clear()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
index b27629ce6..fe0daa58c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
@@ -7,6 +7,7 @@ import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
+import com.google.android.material.R as materialR
class HistoryListMenuProvider(
private val context: Context,
@@ -19,9 +20,10 @@ class HistoryListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_clear_history -> {
- MaterialAlertDialogBuilder(context)
+ MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_history)
.setMessage(R.string.text_clear_history_prompt)
+ .setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearHistory()
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
index 1768c2a5f..2e930c529 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
@@ -2,8 +2,6 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
-import java.util.*
-import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -19,6 +17,7 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
@@ -26,6 +25,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
+import java.util.*
+import java.util.concurrent.TimeUnit
class HistoryListViewModel(
private val repository: HistoryRepository,
@@ -37,7 +38,7 @@ class HistoryListViewModel(
val isGroupingEnabled = MutableLiveData()
val onItemsRemoved = SingleLiveEvent()
- private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { historyGrouping }
+ private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled }
.onEach { isGroupingEnabled.postValue(it) }
override val content = combine(
@@ -48,7 +49,7 @@ class HistoryListViewModel(
when {
list.isEmpty() -> listOf(
EmptyState(
- icon = R.drawable.ic_history,
+ icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_history_holder_primary,
textSecondary = R.string.text_history_holder_secondary,
actionStringRes = 0,
@@ -89,7 +90,7 @@ class HistoryListViewModel(
}
fun setGrouping(isGroupingEnabled: Boolean) {
- settings.historyGrouping = isGroupingEnabled
+ settings.isHistoryGroupingEnabled = isGroupingEnabled
}
private suspend fun mapList(
@@ -98,6 +99,7 @@ class HistoryListViewModel(
mode: ListMode
): List {
val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
+ val showPercent = settings.isReadingIndicatorsEnabled
var prevDate: DateTimeAgo? = null
if (!grouped) {
result += ListHeader(null, R.string.history, null)
@@ -111,10 +113,11 @@ class HistoryListViewModel(
prevDate = date
}
val counter = trackingRepository.getNewChaptersCount(manga.id)
+ val percent = if (showPercent) history.percent else PROGRESS_NONE
result += when (mode) {
- ListMode.LIST -> manga.toListModel(counter)
- ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter)
- ListMode.GRID -> manga.toGridModel(counter)
+ ListMode.LIST -> manga.toListModel(counter, percent)
+ ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent)
+ ListMode.GRID -> manga.toGridModel(counter, percent)
}
}
return result
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt
new file mode 100644
index 000000000..9fd4542ec
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt
@@ -0,0 +1,151 @@
+package org.koitharu.kotatsu.history.ui.util
+
+import android.content.Context
+import android.graphics.*
+import android.graphics.drawable.Drawable
+import androidx.annotation.StyleRes
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.graphics.ColorUtils
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
+import kotlin.math.roundToInt
+
+class ReadingProgressDrawable(
+ context: Context,
+ @StyleRes styleResId: Int,
+) : Drawable() {
+
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val checkDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_check)
+ private val lineColor: Int
+ private val outlineColor: Int
+ private val backgroundColor: Int
+ private val textColor: Int
+ private val textPattern = context.getString(R.string.percent_string_pattern)
+ private val textBounds = Rect()
+ private val tempRect = Rect()
+ private val hasBackground: Boolean
+ private val hasOutline: Boolean
+ private val hasText: Boolean
+ private val desiredHeight: Int
+ private val desiredWidth: Int
+ private val autoFitTextSize: Boolean
+
+ var progress: Float = PROGRESS_NONE
+ set(value) {
+ field = value
+ text = textPattern.format((value * 100f).toInt().toString())
+ paint.getTextBounds(text, 0, text.length, textBounds)
+ invalidateSelf()
+ }
+ private var text = ""
+
+ init {
+ val ta = context.obtainStyledAttributes(styleResId, R.styleable.ProgressDrawable)
+ desiredHeight = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_height, -1)
+ desiredWidth = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_width, -1)
+ autoFitTextSize = ta.getBoolean(R.styleable.ProgressDrawable_autoFitTextSize, false)
+ lineColor = ta.getColor(R.styleable.ProgressDrawable_android_strokeColor, Color.BLACK)
+ outlineColor = ta.getColor(R.styleable.ProgressDrawable_outlineColor, Color.TRANSPARENT)
+ backgroundColor = ColorUtils.setAlphaComponent(
+ ta.getColor(R.styleable.ProgressDrawable_android_fillColor, Color.TRANSPARENT),
+ (255 * ta.getFloat(R.styleable.ProgressDrawable_android_fillAlpha, 0f)).toInt(),
+ )
+ textColor = ta.getColor(R.styleable.ProgressDrawable_android_textColor, lineColor)
+ paint.strokeCap = Paint.Cap.ROUND
+ paint.textAlign = Paint.Align.CENTER
+ paint.textSize = ta.getDimension(R.styleable.ProgressDrawable_android_textSize, paint.textSize)
+ paint.strokeWidth = ta.getDimension(R.styleable.ProgressDrawable_strokeWidth, 1f)
+ ta.recycle()
+ hasBackground = Color.alpha(backgroundColor) != 0
+ hasOutline = Color.alpha(outlineColor) != 0
+ hasText = Color.alpha(textColor) != 0 && paint.textSize > 0
+ checkDrawable?.setTint(textColor)
+ }
+
+ override fun onBoundsChange(bounds: Rect) {
+ super.onBoundsChange(bounds)
+ if (autoFitTextSize) {
+ val innerWidth = bounds.width() - (paint.strokeWidth * 2f)
+ paint.textSize = getTextSizeForWidth(innerWidth, "100%")
+ paint.getTextBounds(text, 0, text.length, textBounds)
+ invalidateSelf()
+ }
+ }
+
+ override fun draw(canvas: Canvas) {
+ if (progress < 0f) {
+ return
+ }
+ val cx = bounds.exactCenterX()
+ val cy = bounds.exactCenterY()
+ val radius = minOf(bounds.width(), bounds.height()) / 2f
+ if (hasBackground) {
+ paint.style = Paint.Style.FILL
+ paint.color = backgroundColor
+ canvas.drawCircle(cx, cy, radius, paint)
+ }
+ val innerRadius = radius - paint.strokeWidth / 2f
+ paint.style = Paint.Style.STROKE
+ if (hasOutline) {
+ paint.color = outlineColor
+ canvas.drawCircle(cx, cy, innerRadius, paint)
+ }
+ paint.color = lineColor
+ canvas.drawArc(
+ cx - innerRadius,
+ cy - innerRadius,
+ cx + innerRadius,
+ cy + innerRadius,
+ -90f,
+ 360f * progress,
+ false,
+ paint,
+ )
+ if (hasText) {
+ if (checkDrawable != null && progress >= 1f - Math.ulp(progress)) {
+ tempRect.set(bounds)
+ tempRect *= 0.6
+ checkDrawable.bounds = tempRect
+ checkDrawable.draw(canvas)
+ } else {
+ paint.style = Paint.Style.FILL
+ paint.color = textColor
+ val ty = bounds.height() / 2f + textBounds.height() / 2f - textBounds.bottom
+ canvas.drawText(text, cx, ty, paint)
+ }
+ }
+ }
+
+ override fun setAlpha(alpha: Int) {
+ paint.alpha = alpha
+ }
+
+ override fun setColorFilter(colorFilter: ColorFilter?) {
+ paint.colorFilter = colorFilter
+ }
+
+ @Suppress("DeprecatedCallableAddReplaceWith")
+ @Deprecated("Deprecated in Java")
+ override fun getOpacity() = PixelFormat.TRANSLUCENT
+
+ override fun getIntrinsicHeight() = desiredHeight
+
+ override fun getIntrinsicWidth() = desiredWidth
+
+ private fun getTextSizeForWidth(width: Float, text: String): Float {
+ val testTextSize = 48f
+ paint.textSize = testTextSize
+ paint.getTextBounds(text, 0, text.length, tempRect)
+ return testTextSize * width / tempRect.width()
+ }
+
+ private operator fun Rect.timesAssign(factor: Double) {
+ val newWidth = (width() * factor).roundToInt()
+ val newHeight = (height() * factor).roundToInt()
+ inset(
+ (width() - newWidth) / 2,
+ (height() - newHeight) / 2,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt
new file mode 100644
index 000000000..2bb906c99
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt
@@ -0,0 +1,114 @@
+package org.koitharu.kotatsu.history.ui.util
+
+import android.animation.Animator
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Outline
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewOutlineProvider
+import android.view.animation.AccelerateDecelerateInterpolator
+import androidx.annotation.AttrRes
+import androidx.annotation.StyleRes
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
+
+class ReadingProgressView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = 0,
+) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
+
+ private var percentAnimator: ValueAnimator? = null
+ private val animationDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime).toLong()
+
+ @StyleRes
+ private val drawableStyle: Int
+
+ var percent: Float
+ get() = peekProgressDrawable()?.progress ?: PROGRESS_NONE
+ set(value) {
+ cancelAnimation()
+ getProgressDrawable().progress = value
+ }
+
+ init {
+ val ta = context.obtainStyledAttributes(attrs, R.styleable.ReadingProgressView, defStyleAttr, 0)
+ drawableStyle = ta.getResourceId(R.styleable.ReadingProgressView_progressStyle, R.style.ProgressDrawable)
+ ta.recycle()
+ outlineProvider = OutlineProvider()
+ if (isInEditMode) {
+ percent = 0.27f
+ }
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ percentAnimator?.run {
+ if (isRunning) end()
+ }
+ percentAnimator = null
+ }
+
+ override fun onAnimationUpdate(animation: ValueAnimator) {
+ val p = animation.animatedValue as Float
+ getProgressDrawable().progress = p
+ }
+
+ override fun onAnimationStart(animation: Animator?) = Unit
+
+ override fun onAnimationEnd(animation: Animator?) {
+ if (percentAnimator === animation) {
+ percentAnimator = null
+ }
+ }
+
+ override fun onAnimationCancel(animation: Animator?) = Unit
+
+ override fun onAnimationRepeat(animation: Animator?) = Unit
+
+ fun setPercent(value: Float, animate: Boolean) {
+ val currentDrawable = peekProgressDrawable()
+ if (!animate || currentDrawable == null || value == PROGRESS_NONE) {
+ percent = value
+ return
+ }
+ percentAnimator?.cancel()
+ percentAnimator = ValueAnimator.ofFloat(
+ currentDrawable.progress.coerceAtLeast(0f),
+ value
+ ).apply {
+ duration = animationDuration
+ interpolator = AccelerateDecelerateInterpolator()
+ addUpdateListener(this@ReadingProgressView)
+ addListener(this@ReadingProgressView)
+ start()
+ }
+ }
+
+ private fun cancelAnimation() {
+ percentAnimator?.cancel()
+ percentAnimator = null
+ }
+
+ private fun peekProgressDrawable(): ReadingProgressDrawable? {
+ return background as? ReadingProgressDrawable
+ }
+
+ private fun getProgressDrawable(): ReadingProgressDrawable {
+ var d = peekProgressDrawable()
+ if (d != null) {
+ return d
+ }
+ d = ReadingProgressDrawable(context, drawableStyle)
+ background = d
+ return d
+ }
+
+ private class OutlineProvider : ViewOutlineProvider() {
+
+ override fun getOutline(view: View, outline: Outline) {
+ outline.setOval(0, 0, view.width, view.height)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/domain/CountersProvider.kt b/app/src/main/java/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt
similarity index 52%
rename from app/src/main/java/org/koitharu/kotatsu/list/domain/CountersProvider.kt
rename to app/src/main/java/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt
index e9594019c..7c547e0ae 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/domain/CountersProvider.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt
@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.list.domain
-fun interface CountersProvider {
+interface ListExtraProvider {
suspend fun getCounter(mangaId: Long): Int
+
+ suspend fun getProgress(mangaId: Long): Float
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
index 28556ac48..568bb6427 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
@@ -9,7 +9,6 @@ import androidx.collection.ArraySet
import androidx.core.graphics.Insets
import androidx.core.view.isNotEmpty
import androidx.core.view.updatePadding
-import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt
index 3ef530824..8422d00ec 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt
@@ -18,17 +18,17 @@ import org.koitharu.kotatsu.utils.ext.getItem
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
-class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
+open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
- private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
- private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
- private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
- private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
- private val fillColor = ColorUtils.setAlphaComponent(
+ protected val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
+ protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
+ protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
+ protected val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74
)
- private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
+ protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
index e4ad38d3e..8145e240e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
@@ -11,6 +11,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
@@ -43,8 +44,9 @@ fun mangaGridItemAD(
}
}
- bind {
+ bind { payloads ->
binding.textViewTitle.text = item.title
+ binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
imageRequest?.dispose()
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl)
.referer(item.manga.publicUrl)
@@ -60,6 +62,7 @@ fun mangaGridItemAD(
onViewRecycled {
itemView.clearBadge(badge)
+ binding.progressView.percent = PROGRESS_NONE
badge = null
imageRequest?.dispose()
imageRequest = null
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
index 93f271c0c..6b69445c5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
@@ -54,9 +54,14 @@ class MangaListAdapter(
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
return when (newItem) {
- is MangaListModel,
- is MangaGridModel,
- is MangaListDetailedModel,
+ is MangaItemModel -> {
+ oldItem as MangaItemModel
+ if (oldItem.progress != newItem.progress) {
+ PAYLOAD_PROGRESS
+ } else {
+ Unit
+ }
+ }
is CurrentFilterModel -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
@@ -77,5 +82,7 @@ class MangaListAdapter(
const val ITEM_TYPE_HEADER = 9
const val ITEM_TYPE_FILTER = 10
const val ITEM_TYPE_HEADER_FILTER = 11
+
+ val PAYLOAD_PROGRESS = Any()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
index 74a67378f..583be4079 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
@@ -10,6 +10,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.Manga
@@ -36,10 +37,11 @@ fun mangaListDetailedItemAD(
clickListener.onItemLongClick(item.manga, it)
}
- bind {
+ bind { payloads ->
imageRequest?.dispose()
binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle
+ binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl)
.referer(item.manga.publicUrl)
.placeholder(R.drawable.ic_placeholder)
@@ -56,6 +58,7 @@ fun mangaListDetailedItemAD(
onViewRecycled {
itemView.clearBadge(badge)
+ binding.progressView.percent = PROGRESS_NONE
badge = null
imageRequest?.dispose()
imageRequest = null
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
index cda127e73..b642a248b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
@@ -4,21 +4,23 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode
-import org.koitharu.kotatsu.list.domain.CountersProvider
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
+import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.ifZero
-fun Manga.toListModel(counter: Int) = MangaListModel(
+fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel(
id = id,
title = title,
subtitle = tags.joinToString(", ") { it.title },
coverUrl = coverUrl,
manga = this,
counter = counter,
+ progress = progress,
)
-fun Manga.toListDetailedModel(counter: Int) = MangaListDetailedModel(
+fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailedModel(
id = id,
title = title,
subtitle = altTitle,
@@ -27,50 +29,48 @@ fun Manga.toListDetailedModel(counter: Int) = MangaListDetailedModel(
coverUrl = coverUrl,
manga = this,
counter = counter,
+ progress = progress,
)
-fun Manga.toGridModel(counter: Int) = MangaGridModel(
+fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel(
id = id,
title = title,
coverUrl = coverUrl,
manga = this,
counter = counter,
+ progress = progress,
)
suspend fun List.toUi(
mode: ListMode,
- countersProvider: CountersProvider,
+ extraProvider: ListExtraProvider,
): List = when (mode) {
- ListMode.LIST -> map { it.toListModel(countersProvider.getCounter(it.id)) }
- ListMode.DETAILED_LIST -> map { it.toListDetailedModel(countersProvider.getCounter(it.id)) }
- ListMode.GRID -> map { it.toGridModel(countersProvider.getCounter(it.id)) }
-}
-
-suspend fun > List.toUi(
- destination: C,
- mode: ListMode,
- countersProvider: CountersProvider,
-): C = when (mode) {
- ListMode.LIST -> mapTo(destination) { it.toListModel(countersProvider.getCounter(it.id)) }
- ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(countersProvider.getCounter(it.id)) }
- ListMode.GRID -> mapTo(destination) { it.toGridModel(countersProvider.getCounter(it.id)) }
+ ListMode.LIST -> map {
+ it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
+ }
+ ListMode.DETAILED_LIST -> map {
+ it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
+ }
+ ListMode.GRID -> map {
+ it.toGridModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
+ }
}
fun List.toUi(
mode: ListMode,
): List = when (mode) {
- ListMode.LIST -> map { it.toListModel(0) }
- ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0) }
- ListMode.GRID -> map { it.toGridModel(0) }
+ ListMode.LIST -> map { it.toListModel(0, PROGRESS_NONE) }
+ ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0, PROGRESS_NONE) }
+ ListMode.GRID -> map { it.toGridModel(0, PROGRESS_NONE) }
}
fun > List.toUi(
destination: C,
mode: ListMode,
): C = when (mode) {
- ListMode.LIST -> mapTo(destination) { it.toListModel(0) }
- ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0) }
- ListMode.GRID -> mapTo(destination) { it.toGridModel(0) }
+ ListMode.LIST -> mapTo(destination) { it.toListModel(0, PROGRESS_NONE) }
+ ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0, PROGRESS_NONE) }
+ ListMode.GRID -> mapTo(destination) { it.toGridModel(0, PROGRESS_NONE) }
}
fun Throwable.toErrorState(canRetry: Boolean = true) = ErrorState(
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt
index 9575d82fd..97a414879 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt
@@ -4,8 +4,9 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class MangaGridModel(
override val id: Long,
- val title: String,
- val coverUrl: String,
+ override val title: String,
+ override val coverUrl: String,
override val manga: Manga,
- val counter: Int,
+ override val counter: Int,
+ override val progress: Float,
) : MangaItemModel
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt
index 64a4f66ae..ce5145d26 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt
@@ -6,4 +6,8 @@ sealed interface MangaItemModel : ListModel {
val id: Long
val manga: Manga
+ val title: String
+ val coverUrl: String
+ val counter: Int
+ val progress: Float
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt
index c1cedeb24..f9957f345 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt
@@ -4,11 +4,12 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel(
override val id: Long,
- val title: String,
+ override val title: String,
val subtitle: String?,
val tags: String,
- val coverUrl: String,
+ override val coverUrl: String,
val rating: String?,
override val manga: Manga,
- val counter: Int,
+ override val counter: Int,
+ override val progress: Float,
) : MangaItemModel
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt
index 7533858eb..71ac5743c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt
@@ -4,9 +4,10 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListModel(
override val id: Long,
- val title: String,
+ override val title: String,
val subtitle: String,
- val coverUrl: String,
+ override val coverUrl: String,
override val manga: Manga,
- val counter: Int,
+ override val counter: Int,
+ override val progress: Float,
) : MangaItemModel
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
index 375e03997..e8490f9d9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
@@ -49,7 +49,7 @@ class LocalListViewModel(
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(
EmptyState(
- icon = R.drawable.ic_storage,
+ icon = R.drawable.ic_empty_local,
textPrimary = R.string.text_local_holder_primary,
textSecondary = R.string.text_local_holder_secondary,
actionStringRes = R.string._import,
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
index e8f54182d..b3a409f53 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
@@ -269,8 +269,9 @@ class MainActivity :
}
override fun onClearSearchHistory() {
- MaterialAlertDialogBuilder(this)
+ MaterialAlertDialogBuilder(this, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_search_history)
+ .setIcon(R.drawable.ic_clear_all)
.setMessage(R.string.text_clear_search_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt
index 953991baa..0da2ee55f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt
@@ -96,6 +96,9 @@ class ProtectActivity :
}
private fun useFingerprint(): Boolean {
+ if (!viewModel.isBiometricEnabled) {
+ return false
+ }
if (BiometricManager.from(this).canAuthenticate(BIOMETRIC_WEAK) != BIOMETRIC_SUCCESS) {
return false
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt
index 69e671c01..85ffe23cb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt
@@ -19,6 +19,9 @@ class ProtectViewModel(
val onUnlockSuccess = SingleLiveEvent()
+ val isBiometricEnabled
+ get() = settings.isBiometricProtectionEnabled
+
fun tryUnlock(password: String) {
if (job?.isActive == true) {
return
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
index e4a29b948..dfd3af976 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.acra.ktx.sendWithAcra
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -214,6 +215,8 @@ class ReaderActivity :
val resolveTextId = ExceptionResolver.getResolveStringId(e)
if (resolveTextId != 0) {
dialog.setPositiveButton(resolveTextId, listener)
+ } else {
+ dialog.setPositiveButton(R.string.report, listener)
}
dialog.show()
}
@@ -368,7 +371,11 @@ class ReaderActivity :
override fun onClick(dialog: DialogInterface?, which: Int) {
if (which == DialogInterface.BUTTON_POSITIVE) {
dialog?.dismiss()
- tryResolve(exception)
+ if (ExceptionResolver.canResolve(exception)) {
+ tryResolve(exception)
+ } else {
+ exception.sendWithAcra()
+ }
} else {
onCancel(dialog)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
index 931461de0..0384e44aa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
@@ -6,9 +6,9 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
-import java.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
+import org.acra.ACRA
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.*
import org.koitharu.kotatsu.history.domain.HistoryRepository
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
@@ -32,6 +33,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
+import org.koitharu.kotatsu.utils.ext.setCurrentManga
+import java.util.*
private const val BOUNDS_PAGE_OFFSET = 2
private const val PAGES_TRIM_THRESHOLD = 120
@@ -135,13 +138,16 @@ class ReaderViewModel(
}
}
+ // TODO check performance
fun saveCurrentState(state: ReaderState? = null) {
if (state != null) {
currentState.value = state
}
+ val readerState = state ?: currentState.value ?: return
historyRepository.saveStateAsync(
- mangaData.value ?: return,
- state ?: currentState.value ?: return
+ manga = mangaData.value ?: return,
+ state = readerState,
+ percent = computePercent(readerState.chapterId, readerState.page),
)
}
@@ -223,7 +229,7 @@ class ReaderViewModel(
if (bookmarkJob?.isActive == true) {
return
}
- bookmarkJob = launchJob {
+ bookmarkJob = launchJob(Dispatchers.Default) {
loadingJob?.join()
val state = checkNotNull(currentState.value)
val page = checkNotNull(getCurrentPage()) { "Page not found" }
@@ -235,9 +241,10 @@ class ReaderViewModel(
scroll = state.scroll,
imageUrl = page.preview ?: pageLoader.getPageUrl(page),
createdAt = Date(),
+ percent = computePercent(state.chapterId, state.page),
)
bookmarksRepository.addBookmark(bookmark)
- onShowToast.call(R.string.bookmark_added)
+ onShowToast.postCall(R.string.bookmark_added)
}
}
@@ -257,6 +264,7 @@ class ReaderViewModel(
private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default) {
var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga")
+ ACRA.setCurrentManga(manga)
mangaData.value = manga
val repo = MangaRepository(manga.source)
manga = repo.getDetails(manga)
@@ -279,7 +287,8 @@ class ReaderViewModel(
val pages = loadChapter(requireNotNull(currentState.value).chapterId)
// save state
currentState.value?.let {
- historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll)
+ val percent = computePercent(it.chapterId, it.page)
+ historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent)
shortcutsRepository.updateShortcuts()
}
@@ -364,20 +373,35 @@ class ReaderViewModel(
it.printStackTraceDebug()
}.getOrDefault(defaultMode)
}
+
+ private fun computePercent(chapterId: Long, pageIndex: Int): Float {
+ val chapters = manga?.chapters ?: return PROGRESS_NONE
+ val chaptersCount = chapters.size
+ val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
+ val pages = content.value?.pages ?: return PROGRESS_NONE
+ val pagesCount = pages.count { x -> x.chapterId == chapterId }
+ if (chaptersCount == 0 || pagesCount == 0) {
+ return PROGRESS_NONE
+ }
+ val pagePercent = (pageIndex + 1) / pagesCount.toFloat()
+ val ppc = 1f / chaptersCount
+ return ppc * chapterIndex + ppc * pagePercent
+ }
}
/**
* This function is not a member of the ReaderViewModel
* because it should work independently of the ViewModel's lifecycle.
*/
-private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState): Job {
+private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job {
return processLifecycleScope.launch(Dispatchers.Default) {
runCatching {
addOrUpdate(
manga = manga,
chapterId = state.chapterId,
page = state.page,
- scroll = state.scroll
+ scroll = state.scroll,
+ percent = percent,
)
}.onFailure {
it.printStackTraceDebug()
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt
index 30b696297..d3980c687 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt
@@ -16,6 +16,7 @@ abstract class BasePageHolder(
exceptionResolver: ExceptionResolver
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
+ @Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(loader, settings, this, exceptionResolver)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt
index b6adc87b1..d097c1bc2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt
@@ -11,10 +11,11 @@ import org.koitharu.kotatsu.utils.ext.resetTransformations
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
+@Suppress("LeakingThis")
abstract class BaseReaderAdapter>(
private val loader: PageLoader,
private val settings: AppSettings,
- private val exceptionResolver: ExceptionResolver
+ private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter() {
private val differ = AsyncListDiffer(this, DiffCallback())
diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
index b57f0b30b..092194b96 100644
--- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
@@ -151,7 +151,7 @@ class RemoteListViewModel(
}
private fun createEmptyState(filterState: FilterState) = EmptyState(
- icon = R.drawable.ic_book_cross,
+ icon = R.drawable.ic_empty_search,
textPrimary = R.string.nothing_found,
textSecondary = 0,
actionStringRes = if (filterState.tags.isEmpty()) 0 else R.string.reset_filter,
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt
new file mode 100644
index 000000000..72cf83031
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt
@@ -0,0 +1,23 @@
+package org.koitharu.kotatsu.scrobbling.data
+
+import androidx.room.*
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+abstract class ScrobblingDao {
+
+ @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
+ abstract suspend fun find(scrobbler: Int, mangaId: Long): ScrobblingEntity?
+
+ @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
+ abstract fun observe(scrobbler: Int, mangaId: Long): Flow
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ abstract suspend fun insert(entity: ScrobblingEntity)
+
+ @Update
+ abstract suspend fun update(entity: ScrobblingEntity)
+
+ @Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
+ abstract suspend fun delete(scrobbler: Int, mangaId: Long)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt
new file mode 100644
index 000000000..dc4e02d8e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt
@@ -0,0 +1,35 @@
+package org.koitharu.kotatsu.scrobbling.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+
+@Entity(
+ tableName = "scrobblings",
+ primaryKeys = ["scrobbler", "id", "manga_id"],
+)
+class ScrobblingEntity(
+ @ColumnInfo(name = "scrobbler") val scrobbler: Int,
+ @ColumnInfo(name = "id") val id: Int,
+ @ColumnInfo(name = "manga_id") val mangaId: Long,
+ @ColumnInfo(name = "target_id") val targetId: Long,
+ @ColumnInfo(name = "status") val status: String?,
+ @ColumnInfo(name = "chapter") val chapter: Int,
+ @ColumnInfo(name = "comment") val comment: String?,
+ @ColumnInfo(name = "rating") val rating: Float,
+) {
+
+ fun copy(
+ status: String?,
+ comment: String?,
+ rating: Float,
+ ) = ScrobblingEntity(
+ scrobbler = scrobbler,
+ id = id,
+ mangaId = mangaId,
+ targetId = targetId,
+ status = status,
+ chapter = chapter,
+ comment = comment,
+ rating = rating,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt
new file mode 100644
index 000000000..2fe44e39c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt
@@ -0,0 +1,80 @@
+package org.koitharu.kotatsu.scrobbling.domain
+
+import androidx.collection.LongSparseArray
+import androidx.collection.getOrElse
+import androidx.core.text.parseAsHtml
+import java.util.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
+import org.koitharu.kotatsu.scrobbling.domain.model.*
+import org.koitharu.kotatsu.utils.ext.findKey
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+
+abstract class Scrobbler(
+ protected val db: MangaDatabase,
+ val scrobblerService: ScrobblerService,
+) {
+
+ private val infoCache = LongSparseArray()
+ protected val statuses = EnumMap(ScrobblingStatus::class.java)
+
+ abstract val isAvailable: Boolean
+
+ abstract suspend fun findManga(query: String, offset: Int): List
+
+ abstract suspend fun linkManga(mangaId: Long, targetId: Long)
+
+ abstract suspend fun scrobble(mangaId: Long, chapter: MangaChapter)
+
+ suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? {
+ val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null
+ return entity.toScrobblingInfo(mangaId)
+ }
+
+ abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?)
+
+ fun observeScrobblingInfo(mangaId: Long): Flow {
+ return db.scrobblingDao.observe(scrobblerService.id, mangaId)
+ .map { it?.toScrobblingInfo(mangaId) }
+ }
+
+ abstract suspend fun unregisterScrobbling(mangaId: Long)
+
+ protected abstract suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo
+
+ private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? {
+ val mangaInfo = infoCache.getOrElse(targetId) {
+ runCatching {
+ getMangaInfo(targetId)
+ }.onFailure {
+ it.printStackTraceDebug()
+ }.onSuccess {
+ infoCache.put(targetId, it)
+ }.getOrNull() ?: return null
+ }
+ return ScrobblingInfo(
+ scrobbler = scrobblerService,
+ mangaId = mangaId,
+ targetId = targetId,
+ status = statuses.findKey(status),
+ chapter = chapter,
+ comment = comment,
+ rating = rating,
+ title = mangaInfo.name,
+ coverUrl = mangaInfo.cover,
+ description = mangaInfo.descriptionHtml.parseAsHtml(),
+ externalUrl = mangaInfo.url,
+ )
+ }
+}
+
+suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean {
+ return runCatching {
+ scrobble(mangaId, chapter)
+ }.onFailure {
+ it.printStackTraceDebug()
+ }.isSuccess
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt
new file mode 100644
index 000000000..9e28c9d7d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt
@@ -0,0 +1,40 @@
+package org.koitharu.kotatsu.scrobbling.domain.model
+
+import org.koitharu.kotatsu.list.ui.model.ListModel
+
+class ScrobblerManga(
+ val id: Long,
+ val name: String,
+ val altName: String?,
+ val cover: String,
+ val url: String,
+) : ListModel {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as ScrobblerManga
+
+ if (id != other.id) return false
+ if (name != other.name) return false
+ if (altName != other.altName) return false
+ if (cover != other.cover) return false
+ if (url != other.url) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = id.hashCode()
+ result = 31 * result + name.hashCode()
+ result = 31 * result + altName.hashCode()
+ result = 31 * result + cover.hashCode()
+ result = 31 * result + url.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "ScrobblerManga #$id \"$name\" $url"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt
new file mode 100644
index 000000000..940262041
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt
@@ -0,0 +1,9 @@
+package org.koitharu.kotatsu.scrobbling.domain.model
+
+class ScrobblerMangaInfo(
+ val id: Long,
+ val name: String,
+ val cover: String,
+ val url: String,
+ val descriptionHtml: String,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt
new file mode 100644
index 000000000..45038ed12
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt
@@ -0,0 +1,14 @@
+package org.koitharu.kotatsu.scrobbling.domain.model
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import org.koitharu.kotatsu.R
+
+enum class ScrobblerService(
+ val id: Int,
+ @StringRes val titleResId: Int,
+ @DrawableRes val iconResId: Int,
+) {
+
+ SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt
new file mode 100644
index 000000000..87393d6ec
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt
@@ -0,0 +1,52 @@
+package org.koitharu.kotatsu.scrobbling.domain.model
+
+class ScrobblingInfo(
+ val scrobbler: ScrobblerService,
+ val mangaId: Long,
+ val targetId: Long,
+ val status: ScrobblingStatus?,
+ val chapter: Int,
+ val comment: String?,
+ val rating: Float,
+ val title: String,
+ val coverUrl: String,
+ val description: CharSequence?,
+ val externalUrl: String,
+) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as ScrobblingInfo
+
+ if (scrobbler != other.scrobbler) return false
+ if (mangaId != other.mangaId) return false
+ if (targetId != other.targetId) return false
+ if (status != other.status) return false
+ if (chapter != other.chapter) return false
+ if (comment != other.comment) return false
+ if (rating != other.rating) return false
+ if (title != other.title) return false
+ if (coverUrl != other.coverUrl) return false
+ if (description != other.description) return false
+ if (externalUrl != other.externalUrl) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = scrobbler.hashCode()
+ result = 31 * result + mangaId.hashCode()
+ result = 31 * result + targetId.hashCode()
+ result = 31 * result + (status?.hashCode() ?: 0)
+ result = 31 * result + chapter
+ result = 31 * result + (comment?.hashCode() ?: 0)
+ result = 31 * result + rating.hashCode()
+ result = 31 * result + title.hashCode()
+ result = 31 * result + coverUrl.hashCode()
+ result = 31 * result + (description?.hashCode() ?: 0)
+ result = 31 * result + externalUrl.hashCode()
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt
new file mode 100644
index 000000000..cfb408094
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt
@@ -0,0 +1,11 @@
+package org.koitharu.kotatsu.scrobbling.domain.model
+
+enum class ScrobblingStatus {
+
+ PLANNED,
+ READING,
+ RE_READING,
+ COMPLETED,
+ ON_HOLD,
+ DROPPED,
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt
new file mode 100644
index 000000000..ec3c65b57
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt
@@ -0,0 +1,32 @@
+package org.koitharu.kotatsu.scrobbling.shikimori
+
+import okhttp3.OkHttpClient
+import org.koin.android.ext.koin.androidContext
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.bind
+import org.koin.dsl.module
+import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
+import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator
+import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor
+import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
+import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriStorage
+import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler
+import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsViewModel
+import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorViewModel
+
+val shikimoriModule
+ get() = module {
+ single { ShikimoriStorage(androidContext()) }
+ factory {
+ val okHttp = OkHttpClient.Builder().apply {
+ authenticator(ShikimoriAuthenticator(get(), ::get))
+ addInterceptor(ShikimoriInterceptor(get()))
+ }.build()
+ ShikimoriRepository(okHttp, get(), get())
+ }
+ factory { ShikimoriScrobbler(get(), get()) } bind Scrobbler::class
+ viewModel { params ->
+ ShikimoriSettingsViewModel(get(), params.getOrNull())
+ }
+ viewModel { params -> ScrobblingSelectorViewModel(params[0], get()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt
new file mode 100644
index 000000000..8a94bf98a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt
@@ -0,0 +1,51 @@
+package org.koitharu.kotatsu.scrobbling.shikimori.data
+
+import kotlinx.coroutines.runBlocking
+import okhttp3.Authenticator
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import org.koitharu.kotatsu.BuildConfig
+import org.koitharu.kotatsu.core.network.CommonHeaders
+
+class ShikimoriAuthenticator(
+ private val storage: ShikimoriStorage,
+ private val repositoryProvider: () -> ShikimoriRepository,
+) : Authenticator {
+
+ override fun authenticate(route: Route?, response: Response): Request? {
+ val accessToken = storage.accessToken ?: return null
+ if (!isRequestWithAccessToken(response)) {
+ return null
+ }
+ synchronized(this) {
+ val newAccessToken = storage.accessToken ?: return null
+ if (accessToken != newAccessToken) {
+ return newRequestWithAccessToken(response.request, newAccessToken)
+ }
+ val updatedAccessToken = refreshAccessToken() ?: return null
+ return newRequestWithAccessToken(response.request, updatedAccessToken)
+ }
+ }
+
+ private fun isRequestWithAccessToken(response: Response): Boolean {
+ val header = response.request.header(CommonHeaders.AUTHORIZATION)
+ return header?.startsWith("Bearer") == true
+ }
+
+ private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
+ return request.newBuilder()
+ .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
+ .build()
+ }
+
+ private fun refreshAccessToken(): String? = runCatching {
+ val repository = repositoryProvider()
+ runBlocking { repository.authorize(null) }
+ return storage.accessToken
+ }.onFailure {
+ if (BuildConfig.DEBUG) {
+ it.printStackTrace()
+ }
+ }.getOrNull()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt
new file mode 100644
index 000000000..f203f2e4c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt
@@ -0,0 +1,24 @@
+package org.koitharu.kotatsu.scrobbling.shikimori.data
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import okio.IOException
+import org.koitharu.kotatsu.core.network.CommonHeaders
+
+private const val USER_AGENT_SHIKIMORI = "Kotatsu"
+
+class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request().newBuilder()
+ request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
+ storage.accessToken?.let {
+ request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
+ }
+ val response = chain.proceed(request.build())
+ if (!response.isSuccessful && !response.isRedirect) {
+ throw IOException("${response.code} ${response.message}")
+ }
+ return response
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt
new file mode 100644
index 000000000..119d33637
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt
@@ -0,0 +1,199 @@
+package org.koitharu.kotatsu.scrobbling.shikimori.data
+
+import okhttp3.FormBody
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+import org.koitharu.kotatsu.BuildConfig
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.util.await
+import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
+import org.koitharu.kotatsu.parsers.util.json.mapJSON
+import org.koitharu.kotatsu.parsers.util.parseJson
+import org.koitharu.kotatsu.parsers.util.parseJsonArray
+import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
+import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
+import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
+import org.koitharu.kotatsu.utils.ext.toRequestBody
+
+private const val REDIRECT_URI = "kotatsu://shikimori-auth"
+private const val BASE_URL = "https://shikimori.one/"
+private const val MANGA_PAGE_SIZE = 10
+
+class ShikimoriRepository(
+ private val okHttp: OkHttpClient,
+ private val storage: ShikimoriStorage,
+ private val db: MangaDatabase,
+) {
+
+ val oauthUrl: String
+ get() = "${BASE_URL}oauth/authorize?client_id=${BuildConfig.SHIKIMORI_CLIENT_ID}&" +
+ "redirect_uri=$REDIRECT_URI&response_type=code&scope="
+
+ val isAuthorized: Boolean
+ get() = storage.accessToken != null
+
+ suspend fun authorize(code: String?) {
+ val body = FormBody.Builder()
+ body.add("grant_type", "authorization_code")
+ body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID)
+ body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET)
+ if (code != null) {
+ body.add("redirect_uri", REDIRECT_URI)
+ body.add("code", code)
+ } else {
+ body.add("refresh_token", checkNotNull(storage.refreshToken))
+ }
+ val request = Request.Builder()
+ .post(body.build())
+ .url("${BASE_URL}oauth/token")
+ val response = okHttp.newCall(request.build()).await().parseJson()
+ storage.accessToken = response.getString("access_token")
+ storage.refreshToken = response.getString("refresh_token")
+ }
+
+ suspend fun loadUser(): ShikimoriUser {
+ val request = Request.Builder()
+ .get()
+ .url("${BASE_URL}api/users/whoami")
+ val response = okHttp.newCall(request.build()).await().parseJson()
+ return ShikimoriUser(response).also { storage.user = it }
+ }
+
+ fun getCachedUser(): ShikimoriUser? {
+ return storage.user
+ }
+
+ suspend fun unregister(mangaId: Long) {
+ return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId)
+ }
+
+ fun logout() {
+ storage.clear()
+ }
+
+ suspend fun findManga(query: String, offset: Int): List {
+ val page = offset / MANGA_PAGE_SIZE
+ val pageOffset = offset % MANGA_PAGE_SIZE
+ val url = BASE_URL.toHttpUrl().newBuilder()
+ .addPathSegment("api")
+ .addPathSegment("mangas")
+ .addEncodedQueryParameter("page", (page + 1).toString())
+ .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString())
+ .addEncodedQueryParameter("censored", false.toString())
+ .addQueryParameter("search", query)
+ .build()
+ val request = Request.Builder().url(url).get().build()
+ val response = okHttp.newCall(request).await().parseJsonArray()
+ val list = response.mapJSON { ScrobblerManga(it) }
+ return if (pageOffset != 0) list.drop(pageOffset) else list
+ }
+
+ suspend fun createRate(mangaId: Long, shikiMangaId: Long) {
+ val user = getCachedUser() ?: loadUser()
+ val payload = JSONObject()
+ payload.put(
+ "user_rate",
+ JSONObject().apply {
+ put("target_id", shikiMangaId)
+ put("target_type", "Manga")
+ put("user_id", user.id)
+ }
+ )
+ val url = BASE_URL.toHttpUrl().newBuilder()
+ .addPathSegment("api")
+ .addPathSegment("v2")
+ .addPathSegment("user_rates")
+ .build()
+ val request = Request.Builder().url(url).post(payload.toRequestBody()).build()
+ val response = okHttp.newCall(request).await().parseJson()
+ saveRate(response, mangaId)
+ }
+
+ suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) {
+ val payload = JSONObject()
+ payload.put(
+ "user_rate",
+ JSONObject().apply {
+ put("chapters", chapter.number)
+ }
+ )
+ val url = BASE_URL.toHttpUrl().newBuilder()
+ .addPathSegment("api")
+ .addPathSegment("v2")
+ .addPathSegment("user_rates")
+ .addPathSegment(rateId.toString())
+ .build()
+ val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
+ val response = okHttp.newCall(request).await().parseJson()
+ saveRate(response, mangaId)
+ }
+
+ suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
+ val payload = JSONObject()
+ payload.put(
+ "user_rate",
+ JSONObject().apply {
+ put("score", rating.toString())
+ if (comment != null) {
+ put("text", comment)
+ }
+ if (status != null) {
+ put("status", status)
+ }
+ }
+ )
+ val url = BASE_URL.toHttpUrl().newBuilder()
+ .addPathSegment("api")
+ .addPathSegment("v2")
+ .addPathSegment("user_rates")
+ .addPathSegment(rateId.toString())
+ .build()
+ val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
+ val response = okHttp.newCall(request).await().parseJson()
+ saveRate(response, mangaId)
+ }
+
+ suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
+ val request = Request.Builder()
+ .get()
+ .url("${BASE_URL}api/mangas/$id")
+ val response = okHttp.newCall(request.build()).await().parseJson()
+ return ScrobblerMangaInfo(response)
+ }
+
+ private suspend fun saveRate(json: JSONObject, mangaId: Long) {
+ val entity = ScrobblingEntity(
+ scrobbler = ScrobblerService.SHIKIMORI.id,
+ id = json.getInt("id"),
+ mangaId = mangaId,
+ targetId = json.getLong("target_id"),
+ status = json.getString("status"),
+ chapter = json.getInt("chapters"),
+ comment = json.getString("text"),
+ rating = json.getDouble("score").toFloat() / 10f,
+ )
+ db.scrobblingDao.insert(entity)
+ }
+
+ private fun ScrobblerManga(json: JSONObject) = ScrobblerManga(
+ id = json.getLong("id"),
+ name = json.getString("name"),
+ altName = json.getStringOrNull("russian"),
+ cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"),
+ url = json.getString("url").toAbsoluteUrl("shikimori.one"),
+ )
+
+ private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
+ id = json.getLong("id"),
+ name = json.getString("name"),
+ cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"),
+ url = json.getString("url").toAbsoluteUrl("shikimori.one"),
+ descriptionHtml = json.getString("description_html"),
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt
new file mode 100644
index 000000000..0dfe0421e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt
@@ -0,0 +1,36 @@
+package org.koitharu.kotatsu.scrobbling.shikimori.data
+
+import android.content.Context
+import androidx.core.content.edit
+import org.json.JSONObject
+import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
+
+private const val PREF_NAME = "shikimori"
+private const val KEY_ACCESS_TOKEN = "access_token"
+private const val KEY_REFRESH_TOKEN = "refresh_token"
+private const val KEY_USER = "user"
+
+class ShikimoriStorage(context: Context) {
+
+ private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+
+ var accessToken: String?
+ get() = prefs.getString(KEY_ACCESS_TOKEN, null)
+ set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) }
+
+ var refreshToken: String?
+ get() = prefs.getString(KEY_REFRESH_TOKEN, null)
+ set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) }
+
+ var user: ShikimoriUser?
+ get() = prefs.getString(KEY_USER, null)?.let {
+ ShikimoriUser(JSONObject(it))
+ }
+ set(value) = prefs.edit {
+ putString(KEY_USER, value?.toJson()?.toString())
+ }
+
+ fun clear() = prefs.edit {
+ clear()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt
new file mode 100644
index 000000000..79ecfa6c5
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt
@@ -0,0 +1,42 @@
+package org.koitharu.kotatsu.scrobbling.shikimori.data.model
+
+import org.json.JSONObject
+
+class ShikimoriUser(
+ val id: Long,
+ val nickname: String,
+ val avatar: String,
+) {
+
+ constructor(json: JSONObject) : this(
+ id = json.getLong("id"),
+ nickname = json.getString("nickname"),
+ avatar = json.getString("avatar"),
+ )
+
+ fun toJson() = JSONObject().apply {
+ put("id", id)
+ put("nickname", nickname)
+ put("avatar", avatar)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as ShikimoriUser
+
+ if (id != other.id) return false
+ if (nickname != other.nickname) return false
+ if (avatar != other.avatar) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = id.hashCode()
+ result = 31 * result + nickname.hashCode()
+ result = 31 * result + avatar.hashCode()
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt
new file mode 100644
index 000000000..72f9d5cbf
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt
@@ -0,0 +1,68 @@
+package org.koitharu.kotatsu.scrobbling.shikimori.domain
+
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
+import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
+
+private const val RATING_MAX = 10f
+
+class ShikimoriScrobbler(
+ private val repository: ShikimoriRepository,
+ db: MangaDatabase,
+) : Scrobbler(db, ScrobblerService.SHIKIMORI) {
+
+ init {
+ statuses[ScrobblingStatus.PLANNED] = "planned"
+ statuses[ScrobblingStatus.READING] = "watching"
+ statuses[ScrobblingStatus.RE_READING] = "rewatching"
+ statuses[ScrobblingStatus.COMPLETED] = "completed"
+ statuses[ScrobblingStatus.ON_HOLD] = "on_hold"
+ statuses[ScrobblingStatus.DROPPED] = "dropped"
+ }
+
+ override val isAvailable: Boolean
+ get() = repository.isAuthorized
+
+ override suspend fun findManga(query: String, offset: Int): List {
+ return repository.findManga(query, offset)
+ }
+
+ override suspend fun linkManga(mangaId: Long, targetId: Long) {
+ repository.createRate(mangaId, targetId)
+ }
+
+ override suspend fun scrobble(mangaId: Long, chapter: MangaChapter) {
+ val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return
+ repository.updateRate(entity.id, entity.mangaId, chapter)
+ }
+
+ override suspend fun updateScrobblingInfo(
+ mangaId: Long,
+ rating: Float,
+ status: ScrobblingStatus?,
+ comment: String?,
+ ) {
+ val entity = db.scrobblingDao.find(scrobblerService.id, mangaId)
+ requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
+ repository.updateRate(
+ rateId = entity.id,
+ mangaId = entity.mangaId,
+ rating = rating * RATING_MAX,
+ status = statuses[status],
+ comment = comment,
+ )
+ }
+
+ override suspend fun unregisterScrobbling(mangaId: Long) {
+ repository.unregister(mangaId)
+ }
+
+ override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
+ return repository.getMangaInfo(id)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt
new file mode 100644
index 000000000..10098a239
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt
@@ -0,0 +1,79 @@
+package org.koitharu.kotatsu.scrobbling.shikimori.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import androidx.preference.Preference
+import coil.ImageLoader
+import coil.request.ImageRequest
+import coil.transform.CircleCropTransformation
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.core.parameter.parametersOf
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
+import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
+import org.koitharu.kotatsu.utils.PreferenceIconTarget
+import org.koitharu.kotatsu.utils.ext.enqueueWith
+import org.koitharu.kotatsu.utils.ext.withArgs
+
+class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) {
+
+ private val viewModel by viewModel {
+ parametersOf(arguments?.getString(ARG_AUTH_CODE))
+ }
+ private val coil by inject(mode = LazyThreadSafetyMode.NONE)
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ addPreferencesFromResource(R.xml.pref_shikimori)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewModel.user.observe(viewLifecycleOwner, this::onUserChanged)
+ }
+
+ override fun onPreferenceTreeClick(preference: Preference): Boolean {
+ return when (preference.key) {
+ KEY_USER -> openAuthorization()
+ KEY_LOGOUT -> {
+ viewModel.logout()
+ true
+ }
+ else -> super.onPreferenceTreeClick(preference)
+ }
+ }
+
+ private fun onUserChanged(user: ShikimoriUser?) {
+ val pref = findPreference(KEY_USER) ?: return
+ pref.isSelectable = user == null
+ pref.title = user?.nickname ?: getString(R.string.sign_in)
+ ImageRequest.Builder(requireContext())
+ .data(user?.avatar)
+ .transformations(CircleCropTransformation())
+ .target(PreferenceIconTarget(pref))
+ .enqueueWith(coil)
+ findPreference(KEY_LOGOUT)?.isVisible = user != null
+ }
+
+ private fun openAuthorization(): Boolean {
+ return runCatching {
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = Uri.parse(viewModel.authorizationUrl)
+ startActivity(intent)
+ }.isSuccess
+ }
+
+ companion object {
+
+ private const val KEY_USER = "shiki_user"
+ private const val KEY_LOGOUT = "shiki_logout"
+
+ private const val ARG_AUTH_CODE = "auth_code"
+
+ fun newInstance(authCode: String?) = ShikimoriSettingsFragment().withArgs(1) {
+ putString(ARG_AUTH_CODE, authCode)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt
new file mode 100644
index 000000000..ef8f73b85
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt
@@ -0,0 +1,48 @@
+package org.koitharu.kotatsu.scrobbling.shikimori.ui
+
+import androidx.lifecycle.MutableLiveData
+import kotlinx.coroutines.Dispatchers
+import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
+import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
+
+class ShikimoriSettingsViewModel(
+ private val repository: ShikimoriRepository,
+ authCode: String?,
+) : BaseViewModel() {
+
+ val authorizationUrl: String
+ get() = repository.oauthUrl
+
+ val user = MutableLiveData()
+
+ init {
+ if (authCode != null) {
+ authorize(authCode)
+ } else {
+ loadUser()
+ }
+ }
+
+ fun logout() {
+ launchJob(Dispatchers.Default) {
+ repository.logout()
+ user.postValue(null)
+ }
+ }
+
+ private fun loadUser() = launchJob(Dispatchers.Default) {
+ val userModel = if (repository.isAuthorized) {
+ repository.getCachedUser()?.let(user::postValue)
+ repository.loadUser()
+ } else {
+ null
+ }
+ user.postValue(userModel)
+ }
+
+ private fun authorize(code: String) = launchJob(Dispatchers.Default) {
+ repository.authorize(code)
+ user.postValue(repository.loadUser())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt
new file mode 100644
index 000000000..276502ec7
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt
@@ -0,0 +1,155 @@
+package org.koitharu.kotatsu.scrobbling.ui.selector
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.*
+import android.widget.Toast
+import androidx.appcompat.widget.SearchView
+import androidx.fragment.app.FragmentManager
+import org.koin.android.ext.android.get
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.core.parameter.parametersOf
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.MangaIntent
+import org.koitharu.kotatsu.base.ui.BaseBottomSheet
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
+import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
+import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikiMangaSelectionDecoration
+import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter
+import org.koitharu.kotatsu.utils.BottomSheetToolbarController
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+import org.koitharu.kotatsu.utils.ext.withArgs
+
+class ScrobblingSelectorBottomSheet :
+ BaseBottomSheet(),
+ OnListItemClickListener,
+ PaginationScrollListener.Callback,
+ View.OnClickListener,
+ MenuItem.OnActionExpandListener,
+ SearchView.OnQueryTextListener,
+ DialogInterface.OnKeyListener {
+
+ private val viewModel by viewModel {
+ parametersOf(requireNotNull(requireArguments().getParcelable(MangaIntent.KEY_MANGA)).manga)
+ }
+
+ override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding {
+ return SheetScrobblingSelectorBinding.inflate(inflater, container, false)
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return super.onCreateDialog(savedInstanceState).also {
+ it.setOnKeyListener(this)
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.toolbar.setNavigationOnClickListener { dismiss() }
+ addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar))
+ val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this)
+ val decoration = ShikiMangaSelectionDecoration(view.context)
+ with(binding.recyclerView) {
+ adapter = listAdapter
+ addItemDecoration(decoration)
+ addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorBottomSheet))
+ }
+ binding.buttonDone.setOnClickListener(this)
+ initOptionsMenu()
+
+ viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it }
+ viewModel.selectedItemId.observe(viewLifecycleOwner) {
+ decoration.checkedItemId = it
+ binding.recyclerView.invalidateItemDecorations()
+ }
+ viewModel.onError.observe(viewLifecycleOwner, ::onError)
+ viewModel.onClose.observe(viewLifecycleOwner) {
+ dismiss()
+ }
+ viewModel.searchQuery.observe(viewLifecycleOwner) {
+ binding.toolbar.subtitle = it
+ }
+ }
+
+ override fun onClick(v: View) {
+ when (v.id) {
+ R.id.button_done -> viewModel.onDoneClick()
+ }
+ }
+
+ override fun onItemClick(item: ScrobblerManga, view: View) {
+ viewModel.selectedItemId.value = item.id
+ }
+
+ override fun onScrolledToEnd() {
+ viewModel.loadList(append = true)
+ }
+
+ override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
+ setExpanded(isExpanded = true, isLocked = true)
+ return true
+ }
+
+ override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
+ val searchView = (item.actionView as? SearchView) ?: return false
+ searchView.setQuery("", false)
+ searchView.post { setExpanded(isExpanded = false, isLocked = false) }
+ return true
+ }
+
+ override fun onQueryTextSubmit(query: String?): Boolean {
+ if (query == null || query.length < 3) {
+ return false
+ }
+ viewModel.search(query)
+ binding.toolbar.menu.findItem(R.id.action_search)?.collapseActionView()
+ return true
+ }
+
+ override fun onQueryTextChange(newText: String?): Boolean = false
+
+ override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ val menuItem = binding.toolbar.menu.findItem(R.id.action_search) ?: return false
+ if (menuItem.isActionViewExpanded) {
+ if (event?.action == KeyEvent.ACTION_UP) {
+ menuItem.collapseActionView()
+ }
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun onError(e: Throwable) {
+ Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
+ if (viewModel.isEmpty) {
+ dismissAllowingStateLoss()
+ }
+ }
+
+ private fun initOptionsMenu() {
+ binding.toolbar.inflateMenu(R.menu.opt_shiki_selector)
+ val searchMenuItem = binding.toolbar.menu.findItem(R.id.action_search)
+ searchMenuItem.setOnActionExpandListener(this)
+ val searchView = searchMenuItem.actionView as SearchView
+ searchView.setOnQueryTextListener(this)
+ searchView.setIconifiedByDefault(false)
+ searchView.queryHint = searchMenuItem.title
+ }
+
+ companion object {
+
+ private const val TAG = "ScrobblingSelectorBottomSheet"
+
+ fun show(fm: FragmentManager, manga: Manga) =
+ ScrobblingSelectorBottomSheet().withArgs(1) {
+ putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false))
+ }.show(fm, TAG)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt
new file mode 100644
index 000000000..2c881b23b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt
@@ -0,0 +1,101 @@
+package org.koitharu.kotatsu.scrobbling.ui.selector
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import androidx.recyclerview.widget.RecyclerView.NO_ID
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.list.ui.model.LoadingFooter
+import org.koitharu.kotatsu.list.ui.model.LoadingState
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
+import org.koitharu.kotatsu.utils.SingleLiveEvent
+import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+
+class ScrobblingSelectorViewModel(
+ val manga: Manga,
+ private val scrobbler: Scrobbler,
+) : BaseViewModel() {
+
+ private val shikiMangaList = MutableStateFlow?>(null)
+ private val hasNextPage = MutableStateFlow(false)
+ private var loadingJob: Job? = null
+ private var doneJob: Job? = null
+
+ val content: LiveData> = combine(
+ shikiMangaList.filterNotNull(),
+ hasNextPage
+ ) { list, isHasNextPage ->
+ when {
+ list.isEmpty() -> listOf()
+ isHasNextPage -> list + LoadingFooter
+ else -> list
+ }
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+
+ val selectedItemId = MutableLiveData(NO_ID)
+ val searchQuery = MutableLiveData(manga.title)
+ val onClose = SingleLiveEvent()
+
+ val isEmpty: Boolean
+ get() = shikiMangaList.value.isNullOrEmpty()
+
+ init {
+ launchJob(Dispatchers.Default) {
+ try {
+ val info = scrobbler.getScrobblingInfoOrNull(manga.id)
+ if (info != null) {
+ selectedItemId.postValue(info.targetId)
+ }
+ } finally {
+ loadList(append = false)
+ }
+ }
+ }
+
+ fun search(query: String) {
+ loadingJob?.cancel()
+ searchQuery.value = query
+ loadList(append = false)
+ }
+
+ fun loadList(append: Boolean) {
+ if (loadingJob?.isActive == true) {
+ return
+ }
+ if (append && !hasNextPage.value) {
+ return
+ }
+ loadingJob = launchLoadingJob(Dispatchers.Default) {
+ val offset = if (append) shikiMangaList.value?.size ?: 0 else 0
+ val list = scrobbler.findManga(checkNotNull(searchQuery.value), offset)
+ if (!append) {
+ shikiMangaList.value = list
+ } else if (list.isNotEmpty()) {
+ shikiMangaList.value = shikiMangaList.value?.plus(list) ?: list
+ }
+ hasNextPage.value = list.isNotEmpty()
+ }
+ }
+
+ fun onDoneClick() {
+ if (doneJob?.isActive == true) {
+ return
+ }
+ val targetId = selectedItemId.value ?: NO_ID
+ if (targetId == NO_ID) {
+ onClose.call(Unit)
+ }
+ doneJob = launchJob(Dispatchers.Default) {
+ scrobbler.linkManga(manga.id, targetId)
+ onClose.postCall(Unit)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt
new file mode 100644
index 000000000..3cd806a99
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt
@@ -0,0 +1,52 @@
+package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.NO_ID
+import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
+import org.koitharu.kotatsu.utils.ext.getItem
+
+class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
+
+ var checkedItemId: Long
+ get() = selection.singleOrNull() ?: NO_ID
+ set(value) {
+ clearSelection()
+ if (value != NO_ID) {
+ selection.add(value)
+ }
+ }
+
+ override fun getItemId(parent: RecyclerView, child: View): Long {
+ val holder = parent.getChildViewHolder(child) ?: return NO_ID
+ val item = holder.getItem(ScrobblerManga::class.java) ?: return NO_ID
+ return item.id
+ }
+
+ override fun onDrawForeground(
+ canvas: Canvas,
+ parent: RecyclerView,
+ child: View,
+ bounds: RectF,
+ state: RecyclerView.State,
+ ) {
+ paint.color = strokeColor
+ paint.style = Paint.Style.STROKE
+ canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint)
+ checkIcon?.run {
+ val offset = (bounds.height() - intrinsicHeight) / 2
+ setBounds(
+ (bounds.right - offset - intrinsicWidth).toInt(),
+ (bounds.top + offset).toInt(),
+ (bounds.right - offset).toInt(),
+ (bounds.top + offset + intrinsicHeight).toInt(),
+ )
+ draw(canvas)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt
new file mode 100644
index 000000000..f786b5d95
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt
@@ -0,0 +1,52 @@
+package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
+
+import androidx.lifecycle.LifecycleOwner
+import coil.ImageLoader
+import coil.request.Disposable
+import coil.size.Scale
+import coil.util.CoilUtils
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.databinding.ItemMangaListBinding
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
+import org.koitharu.kotatsu.utils.ext.enqueueWith
+import org.koitharu.kotatsu.utils.ext.newImageRequest
+import org.koitharu.kotatsu.utils.ext.textAndVisible
+
+fun shikimoriMangaAD(
+ lifecycleOwner: LifecycleOwner,
+ coil: ImageLoader,
+ clickListener: OnListItemClickListener,
+) = adapterDelegateViewBinding(
+ { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
+) {
+
+ var imageRequest: Disposable? = null
+
+ itemView.setOnClickListener {
+ clickListener.onItemClick(item, it)
+ }
+
+ bind {
+ imageRequest?.dispose()
+ binding.textViewTitle.text = item.name
+ binding.textViewSubtitle.textAndVisible = item.altName
+ imageRequest = binding.imageViewCover.newImageRequest(item.cover)
+ .placeholder(R.drawable.ic_placeholder)
+ .fallback(R.drawable.ic_placeholder)
+ .error(R.drawable.ic_placeholder)
+ .scale(Scale.FILL)
+ .allowRgb565(true)
+ .lifecycle(lifecycleOwner)
+ .enqueueWith(coil)
+ }
+
+ onViewRecycled {
+ imageRequest?.dispose()
+ imageRequest = null
+ CoilUtils.dispose(binding.imageViewCover)
+ binding.imageViewCover.setImageDrawable(null)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt
new file mode 100644
index 000000000..90c6af56b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt
@@ -0,0 +1,40 @@
+package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
+
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.DiffUtil
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
+import kotlin.jvm.internal.Intrinsics
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
+import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
+
+class ShikimoriSelectorAdapter(
+ lifecycleOwner: LifecycleOwner,
+ coil: ImageLoader,
+ clickListener: OnListItemClickListener,
+) : AsyncListDifferDelegationAdapter(DiffCallback()) {
+
+ init {
+ delegatesManager.addDelegate(loadingStateAD())
+ .addDelegate(shikimoriMangaAD(lifecycleOwner, coil, clickListener))
+ .addDelegate(loadingFooterAD())
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback() {
+
+ override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
+ return when {
+ oldItem === newItem -> true
+ oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id
+ else -> false
+ }
+ }
+
+ override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
+ return Intrinsics.areEqual(oldItem, newItem)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt
index 3d075c1b3..ea5551701 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.search.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
-import android.os.Parcelable
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt
index e20c3bb12..9615e84a6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt
@@ -35,7 +35,7 @@ class SearchViewModel(
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(
EmptyState(
- icon = R.drawable.ic_book_search,
+ icon = R.drawable.ic_empty_search,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt
index adea07898..1a07e3dec 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt
@@ -42,7 +42,7 @@ class MultiSearchViewModel(
loading -> LoadingState
error != null -> error.toErrorState(canRetry = true)
else -> EmptyState(
- icon = R.drawable.ic_book_search,
+ icon = R.drawable.ic_empty_search,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
index 800599441..ed9cae71e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
@@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.View
import androidx.preference.ListPreference
import androidx.preference.Preference
+import java.io.File
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
@@ -18,7 +19,6 @@ import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
-import java.io.File
class ContentSettingsFragment :
BasePreferenceFragment(R.string.content),
@@ -49,12 +49,12 @@ class ContentSettingsFragment :
).names()
setDefaultValueCompat(DoHProvider.NONE.name)
}
- bindRemoteSourcesSummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
+ bindRemoteSourcesSummary()
settings.subscribe(this)
}
@@ -108,9 +108,7 @@ class ContentSettingsFragment :
private fun bindRemoteSourcesSummary() {
findPreference(AppSettings.KEY_REMOTE_SOURCES)?.run {
val total = settings.remoteMangaSources.size
- summary = getString(
- R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
- )
+ summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total)
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
index dfa8a7bd0..c4c5f46ba 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.settings
+import android.content.Intent
+import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.preference.Preference
@@ -14,6 +16,7 @@ import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager
+import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.FileSize
@@ -25,6 +28,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
private val trackerRepo by inject(mode = LazyThreadSafetyMode.NONE)
private val searchRepository by inject(mode = LazyThreadSafetyMode.NONE)
private val storageManager by inject(mode = LazyThreadSafetyMode.NONE)
+ private val shikimoriRepository by inject(mode = LazyThreadSafetyMode.NONE)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_history)
@@ -50,6 +54,11 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
}
}
+ override fun onResume() {
+ super.onResume()
+ bindShikimoriSummary()
+ }
+
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
@@ -81,6 +90,14 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
}
true
}
+ AppSettings.KEY_SHIKIMORI -> {
+ if (!shikimoriRepository.isAuthorized) {
+ launchShikimoriAuth()
+ true
+ } else {
+ super.onPreferenceTreeClick(preference)
+ }
+ }
else -> super.onPreferenceTreeClick(preference)
}
}
@@ -142,4 +159,22 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
}
}.show()
}
+
+ private fun bindShikimoriSummary() {
+ findPreference(AppSettings.KEY_SHIKIMORI)?.summary = if (shikimoriRepository.isAuthorized) {
+ getString(R.string.logged_in_as, shikimoriRepository.getCachedUser()?.nickname)
+ } else {
+ getString(R.string.disabled)
+ }
+ }
+
+ private fun launchShikimoriAuth() {
+ runCatching {
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = Uri.parse(shikimoriRepository.oauthUrl)
+ startActivity(intent)
+ }.onFailure {
+ Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt
index 8b82b327c..91f9c5987 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.settings
import android.content.ComponentName
import android.content.Context
import android.content.Intent
+import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
@@ -22,6 +23,7 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
class SettingsActivity :
@@ -89,7 +91,7 @@ class SettingsActivity :
val fm = supportFragmentManager
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false)
fragment.arguments = pref.extras
- // fragment.setTargetFragment(caller, 0)
+ fragment.setTargetFragment(caller, 0)
openFragment(fragment)
return true
}
@@ -116,8 +118,11 @@ class SettingsActivity :
private fun openDefaultFragment() {
val fragment = when (intent?.action) {
+ Intent.ACTION_VIEW -> handleUri(intent.data) ?: return
ACTION_READER -> ReaderSettingsFragment()
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
+ ACTION_SHIKIMORI -> ShikimoriSettingsFragment()
+ ACTION_TRACKER -> TrackerSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL
)
@@ -129,23 +134,44 @@ class SettingsActivity :
}
}
+ private fun handleUri(uri: Uri?): Fragment? {
+ when (uri?.host) {
+ HOST_SHIKIMORI_AUTH ->
+ return ShikimoriSettingsFragment.newInstance(authCode = uri.getQueryParameter("code"))
+ }
+ finishAfterTransition()
+ return null
+ }
+
companion object {
private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
+ private const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
+ private const val ACTION_SHIKIMORI = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SHIKIMORI_SETTINGS"
private const val EXTRA_SOURCE = "source"
+ private const val HOST_SHIKIMORI_AUTH = "shikimori-auth"
+
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)
fun newReaderSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_READER)
+ fun newShikimoriSettingsIntent(context: Context) =
+ Intent(context, SettingsActivity::class.java)
+ .setAction(ACTION_SHIKIMORI)
+
fun newSuggestionsSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS)
+ fun newTrackerSettingsIntent(context: Context) =
+ Intent(context, SettingsActivity::class.java)
+ .setAction(ACTION_TRACKER)
+
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCE)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt
index a0145362a..f88a8dad9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt
@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.settings.protect
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
@@ -7,9 +9,11 @@ import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
+import android.widget.CompoundButton
import android.widget.TextView
import androidx.core.graphics.Insets
import androidx.core.view.isGone
+import androidx.core.view.isVisible
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
@@ -18,7 +22,7 @@ import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding
private const val MIN_PASSWORD_LENGTH = 4
class ProtectSetupActivity : BaseActivity(), TextWatcher,
- View.OnClickListener, TextView.OnEditorActionListener {
+ View.OnClickListener, TextView.OnEditorActionListener, CompoundButton.OnCheckedChangeListener {
private val viewModel by viewModel()
@@ -31,6 +35,9 @@ class ProtectSetupActivity : BaseActivity(), TextWa
binding.buttonNext.setOnClickListener(this)
binding.buttonCancel.setOnClickListener(this)
+ binding.switchBiometric.isChecked = viewModel.isBiometricEnabled
+ binding.switchBiometric.setOnCheckedChangeListener(this)
+
viewModel.isSecondStep.observe(this, this::onStepChanged)
viewModel.onPasswordSet.observe(this) {
finishAfterTransition()
@@ -62,6 +69,10 @@ class ProtectSetupActivity : BaseActivity(), TextWa
}
}
+ override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
+ viewModel.setBiometricEnabled(isChecked)
+ }
+
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
return if (actionId == EditorInfo.IME_ACTION_DONE && binding.buttonNext.isEnabled) {
binding.buttonNext.performClick()
@@ -85,6 +96,7 @@ class ProtectSetupActivity : BaseActivity(), TextWa
private fun onStepChanged(isSecondStep: Boolean) {
binding.buttonCancel.isGone = isSecondStep
+ binding.switchBiometric.isVisible = isSecondStep && isBiometricAvailable()
if (isSecondStep) {
binding.layoutPassword.helperText = getString(R.string.repeat_password)
binding.buttonNext.setText(R.string.confirm)
@@ -93,4 +105,9 @@ class ProtectSetupActivity : BaseActivity(), TextWa
binding.buttonNext.setText(R.string.next)
}
}
+
+ private fun isBiometricAvailable(): Boolean {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
+ packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt
index 1244c836e..c9013d23d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt
@@ -22,6 +22,9 @@ class ProtectSetupViewModel(
val onPasswordMismatch = SingleLiveEvent()
val onClearText = SingleLiveEvent()
+ val isBiometricEnabled
+ get() = settings.isBiometricProtectionEnabled
+
fun onNextClick(password: String) {
if (firstPassword.value == null) {
firstPassword.value = password
@@ -35,4 +38,8 @@ class ProtectSetupViewModel(
}
}
}
+
+ fun setBiometricEnabled(isEnabled: Boolean) {
+ settings.isBiometricProtectionEnabled = isEnabled
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt
index e8044fdde..10453c27e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt
@@ -106,7 +106,13 @@ class SourcesSettingsFragment :
searchView.queryHint = searchMenuItem.title
}
- override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
+ R.id.action_disable_all -> {
+ viewModel.disableAll()
+ true
+ }
+ else -> false
+ }
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
index da3eba14f..3a5340fe8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import org.koitharu.kotatsu.utils.ext.map
@@ -58,6 +59,13 @@ class SourcesSettingsViewModel(
buildList()
}
+ fun disableAll() {
+ settings.hiddenSources = settings.getMangaSources(includeHidden = true).mapToSet {
+ it.name
+ }
+ buildList()
+ }
+
fun expandOrCollapse(headerId: String?) {
if (headerId in expandedGroups) {
expandedGroups.remove(headerId)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt
index 1bf8b7856..3920cb32c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt
@@ -29,6 +29,6 @@ class RingtonePickContract(private val title: String?) : ActivityResultContract<
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
- return intent?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
+ return intent?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt
index d334f31ef..398a0a0f0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt
@@ -32,14 +32,14 @@ class SuggestionRepository(
suspend fun replace(suggestions: Iterable) {
db.withTransaction {
db.suggestionDao.deleteAll()
- suggestions.forEach { x ->
- val tags = x.manga.tags.toEntities()
+ suggestions.forEach { (manga, relevance) ->
+ val tags = manga.tags.toEntities()
db.tagsDao.upsert(tags)
- db.mangaDao.upsert(x.manga.toEntity(), tags)
+ db.mangaDao.upsert(manga.toEntity(), tags)
db.suggestionDao.upsert(
SuggestionEntity(
- mangaId = x.manga.id,
- relevance = x.relevance,
+ mangaId = manga.id,
+ relevance = relevance,
createdAt = System.currentTimeMillis(),
)
)
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
index 832922ea3..42f0c093a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
@@ -27,7 +27,7 @@ class SuggestionsViewModel(
when {
list.isEmpty() -> listOf(
EmptyState(
- icon = R.drawable.ic_book_cross,
+ icon = R.drawable.ic_empty_suggestions,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_suggestion_holder,
actionStringRes = 0,
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedMenuProvider.kt
index 6787ff7da..655f02f8b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedMenuProvider.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedMenuProvider.kt
@@ -9,6 +9,7 @@ import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.tracker.work.TrackWorker
class FeedMenuProvider(
@@ -43,6 +44,11 @@ class FeedMenuProvider(
}.show()
true
}
+ R.id.action_settings -> {
+ val intent = SettingsActivity.newTrackerSettingsIntent(context)
+ context.startActivity(intent)
+ true
+ }
else -> false
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt
index 3cb415143..11ae7f6fa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt
@@ -39,7 +39,7 @@ class FeedViewModel(
add(header)
add(
EmptyState(
- icon = R.drawable.ic_feed,
+ icon = R.drawable.ic_empty_feed,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.text_feed_holder,
actionStringRes = 0,
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt b/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt
new file mode 100644
index 000000000..edece17d7
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt
@@ -0,0 +1,22 @@
+package org.koitharu.kotatsu.utils
+
+import android.graphics.drawable.Drawable
+import androidx.preference.Preference
+import coil.target.Target
+
+class PreferenceIconTarget(
+ private val preference: Preference,
+) : Target {
+
+ override fun onError(error: Drawable?) {
+ preference.icon = error
+ }
+
+ override fun onStart(placeholder: Drawable?) {
+ preference.icon = placeholder
+ }
+
+ override fun onSuccess(result: Drawable) {
+ preference.icon = result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt
index 6c856f4d4..4cecbd2a8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt
@@ -35,7 +35,7 @@ class ScreenOrientationHelper(private val activity: Activity) {
isLandscape = !isLandscape
}
- fun observeAutoOrientation() = callbackFlow {
+ fun observeAutoOrientation() = callbackFlow {
val observer = object : ContentObserver(Handler(activity.mainLooper)) {
override fun onChange(selfChange: Boolean) {
trySendBlocking(isAutoRotationEnabled)
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
index 51e49f24f..535a627d5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
+import android.content.SharedPreferences
import android.content.pm.ResolveInfo
import android.graphics.Color
import android.net.ConnectivityManager
@@ -15,7 +16,14 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider
+import kotlin.coroutines.resume
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.utils.InternalResourceHelper
@@ -65,6 +73,25 @@ fun ActivityResultLauncher.tryLaunch(input: I, options: ActivityOptionsCo
}.isSuccess
}
+fun SharedPreferences.observe() = callbackFlow {
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ trySendBlocking(key)
+ }
+ registerOnSharedPreferenceChangeListener(listener)
+ awaitClose {
+ unregisterOnSharedPreferenceChangeListener(listener)
+ }
+}
+
+fun SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow = flow {
+ emit(valueProducer())
+ observe().collect { upstreamKey ->
+ if (upstreamKey == key) {
+ emit(valueProducer())
+ }
+ }
+}.distinctUntilChanged()
+
fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) {
coroutineScope.launch {
delay(delay)
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt
index 53cf3bdb2..16416bf51 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt
@@ -11,7 +11,7 @@ import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
-fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context)
+fun ImageView.newImageRequest(url: String?) = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.target(this)
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt
index 0ab153da6..f66a6dc22 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt
@@ -34,4 +34,13 @@ fun List.asArrayList(): ArrayList = if (this is ArrayList<*>) {
this as ArrayList
} else {
ArrayList(this)
+}
+
+fun Map.findKey(value: V): K? {
+ for ((k, v) in entries) {
+ if (v == value) {
+ return k
+ }
+ }
+ return null
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt
new file mode 100644
index 000000000..058ca4ea6
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt
@@ -0,0 +1,10 @@
+
+package org.koitharu.kotatsu.utils.ext
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+
+private val TYPE_JSON = "application/json".toMediaType()
+
+fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/InsetsExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/InsetsExt.kt
deleted file mode 100644
index 7276dab57..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/InsetsExt.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.koitharu.kotatsu.utils.ext
-
-import android.view.View
-import androidx.core.graphics.Insets
-
-fun Insets.getStart(view: View): Int {
- return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
- right
- } else {
- left
- }
-}
-
-fun Insets.getEnd(view: View): Int {
- return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
- left
- } else {
- right
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt
index e3cd66b43..c4172000f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt
@@ -4,11 +4,11 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.liveData
-import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import org.koitharu.kotatsu.utils.BufferedObserver
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
-import kotlinx.coroutines.flow.Flow
-import org.koitharu.kotatsu.utils.BufferedObserver
fun LiveData.observeNotNull(owner: LifecycleOwner, observer: Observer) {
this.observe(owner) {
@@ -18,6 +18,10 @@ fun LiveData.observeNotNull(owner: LifecycleOwner, observer: Observer
}
}
+fun LiveData.requireValue(): T = checkNotNull(value) {
+ "LiveData value is null"
+}
+
fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver) {
var previous: T? = null
this.observe(owner) {
@@ -26,6 +30,7 @@ fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: Buffere
}
}
+@Deprecated("Use variant with default value")
fun Flow.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext
): LiveData = liveData(context) {
@@ -36,6 +41,10 @@ fun Flow.asLiveDataDistinct(
}
}
+fun StateFlow.asLiveDataDistinct(
+ context: CoroutineContext = EmptyCoroutineContext
+): LiveData = asLiveDataDistinct(context, value)
+
fun Flow.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext,
defaultValue: T
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThrowableExt.kt
similarity index 81%
rename from app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt
rename to app/src/main/java/org/koitharu/kotatsu/utils/ext/ThrowableExt.kt
index dc6974749..6dac10c3b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThrowableExt.kt
@@ -1,18 +1,22 @@
package org.koitharu.kotatsu.utils.ext
+import android.content.ActivityNotFoundException
import android.content.res.Resources
+import okio.FileNotFoundException
+import org.acra.ACRA
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
-import java.io.FileNotFoundException
+import org.koitharu.kotatsu.parsers.model.Manga
import java.net.SocketTimeoutException
fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
+ is ActivityNotFoundException,
is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
@@ -20,4 +24,6 @@ fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is SocketTimeoutException -> resources.getString(R.string.network_error)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
else -> localizedMessage ?: resources.getString(R.string.error_occurred)
-}
\ No newline at end of file
+}
+
+fun ACRA.setCurrentManga(manga: Manga?) = errorReporter.putCustomData("manga", manga?.publicUrl.toString())
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
index 67c30830e..586e40eef 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
@@ -5,8 +5,6 @@ import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
-import androidx.annotation.StringRes
-import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt
index 3e4125a21..04c25f382 100644
--- a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt
@@ -7,13 +7,15 @@ import android.widget.RemoteViewsService
import coil.ImageLoader
import coil.executeBlocking
import coil.request.ImageRequest
+import coil.size.Scale
+import coil.size.Size
+import coil.transform.RoundedCornersTransformation
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.requireBitmap
-import java.io.IOException
class RecentListFactory(
private val context: Context,
@@ -22,9 +24,15 @@ class RecentListFactory(
) : RemoteViewsService.RemoteViewsFactory {
private val dataSet = ArrayList()
+ private val transformation = RoundedCornersTransformation(
+ context.resources.getDimension(R.dimen.appwidget_corner_radius_inner)
+ )
+ private val coverSize = Size(
+ context.resources.getDimensionPixelSize(R.dimen.widget_cover_width),
+ context.resources.getDimensionPixelSize(R.dimen.widget_cover_height),
+ )
- override fun onCreate() {
- }
+ override fun onCreate() = Unit
override fun getLoadingView() = null
@@ -41,14 +49,18 @@ class RecentListFactory(
override fun getViewAt(position: Int): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.item_recent)
val item = dataSet[position]
- try {
- val cover = coil.executeBlocking(
+ runCatching {
+ coil.executeBlocking(
ImageRequest.Builder(context)
.data(item.coverUrl)
+ .size(coverSize)
+ .scale(Scale.FILL)
+ .transformations(transformation)
.build()
).requireBitmap()
+ }.onSuccess { cover ->
views.setImageViewBitmap(R.id.imageView_cover, cover)
- } catch (e: IOException) {
+ }.onFailure {
views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder)
}
val intent = Intent()
@@ -61,6 +73,5 @@ class RecentListFactory(
override fun getViewTypeCount() = 1
- override fun onDestroy() {
- }
+ override fun onDestroy() = Unit
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt
index b7eea4171..44a2cc632 100644
--- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt
@@ -4,15 +4,12 @@ import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
+import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
-import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.divider.MaterialDividerItemDecoration
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
@@ -26,7 +23,7 @@ import org.koitharu.kotatsu.widget.shelf.model.CategoryItem
import com.google.android.material.R as materialR
class ShelfConfigActivity : BaseActivity(),
- OnListItemClickListener {
+ OnListItemClickListener, View.OnClickListener {
private val viewModel by viewModel()
@@ -41,10 +38,9 @@ class ShelfConfigActivity : BaseActivity(),
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
adapter = CategorySelectAdapter(this)
- binding.recyclerView.addItemDecoration(
- MaterialDividerItemDecoration(this, RecyclerView.VERTICAL)
- )
binding.recyclerView.adapter = adapter
+ binding.buttonDone.isVisible = true
+ binding.buttonDone.setOnClickListener(this)
binding.fabAdd.hide()
val appWidgetId = intent?.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
@@ -61,23 +57,18 @@ class ShelfConfigActivity : BaseActivity(),
viewModel.onError.observe(this, this::onError)
}
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.opt_config, menu)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_done -> {
- config.categoryId = viewModel.checkedId
- updateWidget()
- setResult(
- Activity.RESULT_OK,
- Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId)
- )
- finish()
- true
+ override fun onClick(v: View) {
+ when (v.id) {
+ R.id.button_done -> {
+ config.categoryId = viewModel.checkedId
+ updateWidget()
+ setResult(
+ Activity.RESULT_OK,
+ Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId)
+ )
+ finish()
+ }
}
- else -> super.onOptionsItemSelected(item)
}
override fun onItemClick(item: CategoryItem, view: View) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt
index 61fd0d9ab..1676e5a49 100644
--- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt
@@ -7,6 +7,9 @@ import android.widget.RemoteViewsService
import coil.ImageLoader
import coil.executeBlocking
import coil.request.ImageRequest
+import coil.size.Scale
+import coil.size.Size
+import coil.transform.RoundedCornersTransformation
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
@@ -14,20 +17,25 @@ import org.koitharu.kotatsu.core.prefs.AppWidgetConfig
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.requireBitmap
-import java.io.IOException
class ShelfListFactory(
private val context: Context,
private val favouritesRepository: FavouritesRepository,
private val coil: ImageLoader,
- widgetId: Int
+ widgetId: Int,
) : RemoteViewsService.RemoteViewsFactory {
private val dataSet = ArrayList()
private val config = AppWidgetConfig(context, widgetId)
+ private val transformation = RoundedCornersTransformation(
+ context.resources.getDimension(R.dimen.appwidget_corner_radius_inner)
+ )
+ private val coverSize = Size(
+ context.resources.getDimensionPixelSize(R.dimen.widget_cover_width),
+ context.resources.getDimensionPixelSize(R.dimen.widget_cover_height),
+ )
- override fun onCreate() {
- }
+ override fun onCreate() = Unit
override fun getLoadingView() = null
@@ -52,14 +60,18 @@ class ShelfListFactory(
val views = RemoteViews(context.packageName, R.layout.item_shelf)
val item = dataSet[position]
views.setTextViewText(R.id.textView_title, item.title)
- try {
- val cover = coil.executeBlocking(
+ runCatching {
+ coil.executeBlocking(
ImageRequest.Builder(context)
.data(item.coverUrl)
+ .size(coverSize)
+ .scale(Scale.FILL)
+ .transformations(transformation)
.build()
).requireBitmap()
+ }.onSuccess { cover ->
views.setImageViewBitmap(R.id.imageView_cover, cover)
- } catch (e: IOException) {
+ }.onFailure {
views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder)
}
val intent = Intent()
diff --git a/app/src/main/res/color/selector_switch_thumb.xml b/app/src/main/res/color/selector_switch_thumb.xml
deleted file mode 100644
index ef1a3cc36..000000000
--- a/app/src/main/res/color/selector_switch_thumb.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/color/selector_switch_track.xml b/app/src/main/res/color/selector_switch_track.xml
deleted file mode 100644
index 3779a794a..000000000
--- a/app/src/main/res/color/selector_switch_track.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable-hdpi/ic_shikimori.png b/app/src/main/res/drawable-hdpi/ic_shikimori.png
new file mode 100644
index 000000000..28bf0507f
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_shikimori.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_shikimori.png b/app/src/main/res/drawable-mdpi/ic_shikimori.png
new file mode 100644
index 000000000..d16d155d9
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_shikimori.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_shikimori.png b/app/src/main/res/drawable-xhdpi/ic_shikimori.png
new file mode 100644
index 000000000..69ab19103
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_shikimori.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_shikimori.png b/app/src/main/res/drawable-xxhdpi/ic_shikimori.png
new file mode 100644
index 000000000..f4002413e
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_shikimori.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_shikimori.png b/app/src/main/res/drawable-xxxhdpi/ic_shikimori.png
new file mode 100644
index 000000000..cd989aec4
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_shikimori.png differ
diff --git a/app/src/main/res/drawable/badge.xml b/app/src/main/res/drawable/bg_appwidget_card.xml
similarity index 50%
rename from app/src/main/res/drawable/badge.xml
rename to app/src/main/res/drawable/bg_appwidget_card.xml
index a15864699..35a460504 100644
--- a/app/src/main/res/drawable/badge.xml
+++ b/app/src/main/res/drawable/bg_appwidget_card.xml
@@ -2,6 +2,6 @@
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_empty_favourites.xml b/app/src/main/res/drawable/ic_empty_favourites.xml
new file mode 100644
index 000000000..ccfc51361
--- /dev/null
+++ b/app/src/main/res/drawable/ic_empty_favourites.xml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_empty_feed.xml b/app/src/main/res/drawable/ic_empty_feed.xml
new file mode 100644
index 000000000..1df74d5ed
--- /dev/null
+++ b/app/src/main/res/drawable/ic_empty_feed.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_empty_history.xml b/app/src/main/res/drawable/ic_empty_history.xml
new file mode 100644
index 000000000..f0c6fdee1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_empty_history.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_empty_local.xml b/app/src/main/res/drawable/ic_empty_local.xml
new file mode 100644
index 000000000..f578a6ce8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_empty_local.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_empty_search.xml b/app/src/main/res/drawable/ic_empty_search.xml
new file mode 100644
index 000000000..37fa1c487
--- /dev/null
+++ b/app/src/main/res/drawable/ic_empty_search.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_empty_suggestions.xml b/app/src/main/res/drawable/ic_empty_suggestions.xml
new file mode 100644
index 000000000..84025ea16
--- /dev/null
+++ b/app/src/main/res/drawable/ic_empty_suggestions.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_expand_less.xml b/app/src/main/res/drawable/ic_expand_less.xml
index 466a91a2e..86c53a93b 100644
--- a/app/src/main/res/drawable/ic_expand_less.xml
+++ b/app/src/main/res/drawable/ic_expand_less.xml
@@ -1,7 +1,11 @@
-
\ No newline at end of file
+ android:width="24dp"
+ android:height="24dp"
+ android:tint="?attr/colorControlNormal"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+
+
diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml
index f7cae8b82..63e31a16f 100644
--- a/app/src/main/res/drawable/ic_expand_more.xml
+++ b/app/src/main/res/drawable/ic_expand_more.xml
@@ -1,7 +1,11 @@
-
\ No newline at end of file
+ android:width="24dp"
+ android:height="24dp"
+ android:tint="?attr/colorControlNormal"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+
+
diff --git a/app/src/main/res/drawable/ic_list_add.xml b/app/src/main/res/drawable/ic_list_add.xml
deleted file mode 100644
index 26ca07f23..000000000
--- a/app/src/main/res/drawable/ic_list_add.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml
deleted file mode 100644
index e63766078..000000000
--- a/app/src/main/res/drawable/ic_pause.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_resume.xml b/app/src/main/res/drawable/ic_resume.xml
deleted file mode 100644
index 448628b18..000000000
--- a/app/src/main/res/drawable/ic_resume.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/switch_thumb.xml b/app/src/main/res/drawable/switch_thumb.xml
deleted file mode 100644
index 548ff3bd8..000000000
--- a/app/src/main/res/drawable/switch_thumb.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
- -
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/switch_track.xml b/app/src/main/res/drawable/switch_track.xml
deleted file mode 100644
index ff149c727..000000000
--- a/app/src/main/res/drawable/switch_track.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
- -
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout-w600dp/fragment_details.xml b/app/src/main/res/layout-w600dp/fragment_details.xml
index c691b4ea1..5bf8629f4 100644
--- a/app/src/main/res/layout-w600dp/fragment_details.xml
+++ b/app/src/main/res/layout-w600dp/fragment_details.xml
@@ -31,6 +31,14 @@
tools:background="@tools:sample/backgrounds/scenic"
tools:ignore="ContentDescription,UnusedAttribute" />
+
+
+
+
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true">
+ app:layout_collapseMode="pin">
+
+
+
+
diff --git a/app/src/main/res/layout/activity_category_edit.xml b/app/src/main/res/layout/activity_category_edit.xml
index c8c2fa22a..93eef7b49 100644
--- a/app/src/main/res/layout/activity_category_edit.xml
+++ b/app/src/main/res/layout/activity_category_edit.xml
@@ -10,7 +10,18 @@
+ android:layout_height="?attr/actionBarSize">
+
+
+
+
-
+
+
+ app:title="@string/add_to_favourites">
+
+
+
+
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_list_mode.xml b/app/src/main/res/layout/dialog_list_mode.xml
index cfbd8e018..e8341c954 100644
--- a/app/src/main/res/layout/dialog_list_mode.xml
+++ b/app/src/main/res/layout/dialog_list_mode.xml
@@ -1,70 +1,75 @@
-
+ android:layout_height="wrap_content">
-
-
+ android:layout_margin="16dp"
+ android:orientation="vertical">
-
+
+
+
+
+
+
+
+
+ android:paddingLeft="?attr/dialogPreferredPadding"
+ android:paddingRight="?attr/dialogPreferredPadding"
+ android:singleLine="true"
+ android:text="@string/grid_size"
+ android:visibility="gone"
+ tools:visibility="visible" />
-
+ android:layout_marginHorizontal="16dp"
+ android:stepSize="5"
+ android:valueFrom="50"
+ android:valueTo="150"
+ android:visibility="gone"
+ app:labelBehavior="floating"
+ app:tickVisible="false"
+ tools:value="100"
+ tools:visibility="visible" />
-
-
-
-
-
-
-
\ No newline at end of file
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fading_snackbar_layout.xml b/app/src/main/res/layout/fading_snackbar_layout.xml
index 4c75ce8b9..0ebbb7bdd 100644
--- a/app/src/main/res/layout/fading_snackbar_layout.xml
+++ b/app/src/main/res/layout/fading_snackbar_layout.xml
@@ -1,5 +1,4 @@
-
-
+
+
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 16b847cc8..ddf07925e 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -68,10 +68,11 @@
- @style/Widget.Material3.Toolbar
- @style/Widget.Material3.AppBarLayout
- @style/Widget.Kotatsu.Tabs
- - @style/Widget.Kotatsu.Switch
- @style/Widget.Material3.CardView.Filled
- @style/Widget.Kotatsu.RecyclerView
- @style/Widget.Kotatsu.ListItemTextView
+ - @style/Widget.Material3.CompoundButton.MaterialSwitch
+ - @style/Preference.SwitchPreferenceCompat.M3
- @style/TextAppearance.Kotatsu.Menu
@@ -102,4 +103,8 @@
- @style/Theme.Kotatsu.ActionMode.CloseButton
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
index a1fe42507..d560bd956 100644
--- a/app/src/main/res/xml/network_security_config.xml
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -1,5 +1,6 @@
-
+
-
+
diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml
index 7b48494ea..ffd9f261f 100644
--- a/app/src/main/res/xml/pref_content.xml
+++ b/app/src/main/res/xml/pref_content.xml
@@ -11,7 +11,6 @@
-
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/widget_recent.xml b/app/src/main/res/xml/widget_recent.xml
index 4f4beea5a..476c83cda 100644
--- a/app/src/main/res/xml/widget_recent.xml
+++ b/app/src/main/res/xml/widget_recent.xml
@@ -1,12 +1,17 @@
+ android:widgetCategory="home_screen"
+ tools:ignore="UnusedAttribute" />
diff --git a/app/src/main/res/xml/widget_shelf.xml b/app/src/main/res/xml/widget_shelf.xml
index 45c37262e..c1e4879de 100644
--- a/app/src/main/res/xml/widget_shelf.xml
+++ b/app/src/main/res/xml/widget_shelf.xml
@@ -1,13 +1,19 @@
+ android:widgetCategory="home_screen"
+ android:widgetFeatures="reconfigurable"
+ tools:ignore="UnusedAttribute" />
diff --git a/build.gradle b/build.gradle
index 152a7d981..989d431d9 100644
--- a/build.gradle
+++ b/build.gradle
@@ -23,6 +23,15 @@ allprojects {
}
}
+Object localProperty(String name, Object defaultValue = 'null') {
+ Properties localProperties = new Properties()
+ project.rootProject.file('local.properties').withInputStream { localProperties.load(it) }
+
+ def value = localProperties[name]
+
+ return value != null ? value : defaultValue
+}
+
task clean(type: Delete) {
delete rootProject.buildDir
}
\ No newline at end of file
diff --git a/metadata/ru/full_description.txt b/metadata/ru/full_description.txt
index 87944e3da..df079ccdd 100644
--- a/metadata/ru/full_description.txt
+++ b/metadata/ru/full_description.txt
@@ -5,7 +5,7 @@ Kotatsu - приложения для чтения манги с открыты
- Поиск манги по имени и жанрам
- История чтения
- Избранное с пользовательскими категориями
-- Возможность сохранять мангу и читать её оффлайн. Поддержка сторонних комиксов в формате CBZ
+- Возможность сохранять мангу и читать её офлайн. Поддержка сторонних комиксов в формате CBZ
- Интерфейс также оптимизирован для планшетов
- Поддержка манхвы (Webtoon)
- Уведомления о новых главах и лента обновлений