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"> + +