Scrobblers config activity

This commit is contained in:
Koitharu
2023-02-10 20:36:59 +02:00
parent c2e56f7ba6
commit 26b852365a
79 changed files with 814 additions and 718 deletions

View File

@@ -85,16 +85,7 @@
<activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:exported="true"
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
</intent-filter>
</activity>
android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -140,6 +131,22 @@
<activity
android:name="org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity"
android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity"
android:exported="true"
android:label="@string/settings"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
</intent-filter>
</activity>
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"

View File

@@ -4,11 +4,6 @@ import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.room.withTransaction
import java.io.File
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -17,7 +12,11 @@ import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.ReaderMode
@@ -27,6 +26,11 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.math.roundToInt
private const val MIN_WEBTOON_RATIO = 2
@@ -121,7 +125,7 @@ class MangaDataRepository @Inject constructor(
val request = Request.Builder()
.url(url)
.get()
.header(CommonHeaders.REFERER, page.referer)
.tag(MangaSource::class.java, page.source)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.build()
okHttpClient.newCall(request).await().use {

View File

@@ -19,15 +19,28 @@ 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.core.db.migrations.Migration10To11
import org.koitharu.kotatsu.core.db.migrations.Migration11To12
import org.koitharu.kotatsu.core.db.migrations.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
import org.koitharu.kotatsu.core.db.migrations.Migration5To6
import org.koitharu.kotatsu.core.db.migrations.Migration6To7
import org.koitharu.kotatsu.core.db.migrations.Migration7To8
import org.koitharu.kotatsu.core.db.migrations.Migration8To9
import org.koitharu.kotatsu.core.db.migrations.Migration9To10
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
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.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity

View File

@@ -5,7 +5,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.format
import java.util.*
import java.util.Date
sealed class DateTimeAgo : ListModel {
@@ -17,6 +17,8 @@ sealed class DateTimeAgo : ListModel {
}
override fun toString() = "just_now"
override fun equals(other: Any?): Boolean = other === JustNow
}
class MinutesAgo(val minutes: Int) : DateTimeAgo() {
@@ -60,6 +62,8 @@ sealed class DateTimeAgo : ListModel {
}
override fun toString() = "today"
override fun equals(other: Any?): Boolean = other === Today
}
object Yesterday : DateTimeAgo() {
@@ -68,6 +72,8 @@ sealed class DateTimeAgo : ListModel {
}
override fun toString() = "yesterday"
override fun equals(other: Any?): Boolean = other === Yesterday
}
class DaysAgo(val days: Int) : DateTimeAgo() {
@@ -119,5 +125,7 @@ sealed class DateTimeAgo : ListModel {
}
override fun toString() = "long_ago"
override fun equals(other: Any?): Boolean = other === LongAgo
}
}
}

View File

@@ -40,7 +40,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.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize

View File

@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesB
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.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper

View File

@@ -45,9 +45,9 @@ 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.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData

View File

@@ -6,7 +6,7 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest

View File

@@ -21,9 +21,9 @@ 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.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest

View File

@@ -5,7 +5,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
class ScrollingInfoAdapter(
lifecycleOwner: LifecycleOwner,
@@ -27,7 +27,7 @@ class ScrollingInfoAdapter(
return oldItem == newItem
}
override fun getChangePayload(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Any? {
override fun getChangePayload(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Any {
return Unit
}
}

View File

@@ -84,5 +84,8 @@ sealed interface ExploreItem : ListModel {
@StringRes actionStringRes: Int,
) : EmptyState(icon, textPrimary, textSecondary, actionStringRes), ExploreItem
object Loading : ExploreItem
object Loading : ExploreItem {
override fun equals(other: Any?): Boolean = other === Loading
}
}

View File

@@ -22,8 +22,8 @@ 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.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject

View File

@@ -12,17 +12,22 @@ import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyStateListAD(
coil: ImageLoader,
listener: ListStateHolderListener,
listener: ListStateHolderListener?,
) = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>(
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
if (listener != null) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
}
bind {
binding.icon.newImageRequest(item.icon)?.enqueueWith(coil)
binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary)
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
if (listener != null) {
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
}
}
onViewRecycled {

View File

@@ -1,3 +1,6 @@
package org.koitharu.kotatsu.list.ui.model
interface ListModel
interface ListModel {
override fun equals(other: Any?): Boolean
}

View File

@@ -1,3 +1,6 @@
package org.koitharu.kotatsu.list.ui.model
object LoadingFooter : ListModel
object LoadingFooter : ListModel {
override fun equals(other: Any?): Boolean = other === LoadingFooter
}

View File

@@ -1,3 +1,6 @@
package org.koitharu.kotatsu.list.ui.model
object LoadingState : ListModel
object LoadingState : ListModel {
override fun equals(other: Any?): Boolean = other === LoadingState
}

View File

@@ -15,14 +15,14 @@ import org.koitharu.kotatsu.scrobbling.anilist.data.AniListAuthenticator
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListInterceptor
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.anilist.domain.AniListScrobbler
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType
import org.koitharu.kotatsu.scrobbling.mal.data.MALAuthenticator
import org.koitharu.kotatsu.scrobbling.mal.data.MALInterceptor
import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository
import org.koitharu.kotatsu.scrobbling.mal.domain.MALScrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerType
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository

View File

@@ -7,9 +7,9 @@ import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerType
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType
import javax.inject.Inject
import javax.inject.Provider

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.scrobbling.anilist.data
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
private const val JSON = "application/json"

View File

@@ -15,13 +15,12 @@ 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.toIntUp
import org.koitharu.kotatsu.scrobbling.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
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.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import kotlin.math.roundToInt
private const val REDIRECT_URI = "kotatsu://anilist-auth"
@@ -36,7 +35,7 @@ class AniListRepository(
private val okHttp: OkHttpClient,
private val storage: ScrobblerStorage,
private val db: MangaDatabase,
) : ScrobblerRepository {
) : org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository {
override val oauthUrl: String
get() = "${BASE_URL}oauth/authorize?client_id=${BuildConfig.ANILIST_CLIENT_ID}&" +

View File

@@ -2,9 +2,9 @@ package org.koitharu.kotatsu.scrobbling.anilist.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import javax.inject.Inject
import javax.inject.Singleton

View File

@@ -1,86 +0,0 @@
package org.koitharu.kotatsu.scrobbling.anilist.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 dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser
import org.koitharu.kotatsu.utils.PreferenceIconTarget
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class AniListSettingsFragment : BasePreferenceFragment(R.string.anilist) {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var viewModelFactory: AniListSettingsViewModel.Factory
private val viewModel by assistedViewModels {
viewModelFactory.create(arguments?.getString(ARG_AUTH_CODE))
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_anilist)
}
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: ScrobblerUser?) {
val pref = findPreference<Preference>(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<Preference>(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 = "al_user"
private const val KEY_LOGOUT = "al_logout"
private const val ARG_AUTH_CODE = "auth_code"
fun newInstance(authCode: String?) = AniListSettingsFragment().withArgs(1) {
putString(ARG_AUTH_CODE, authCode)
}
}
}

View File

@@ -1,57 +0,0 @@
package org.koitharu.kotatsu.scrobbling.anilist.ui
import androidx.lifecycle.MutableLiveData
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser
class AniListSettingsViewModel @AssistedInject constructor(
private val repository: AniListRepository,
@Assisted authCode: String?,
) : BaseViewModel() {
val authorizationUrl: String
get() = repository.oauthUrl
val user = MutableLiveData<ScrobblerUser?>()
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.cachedUser?.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())
}
@AssistedFactory
interface Factory {
fun create(authCode: String?): AniListSettingsViewModel
}
}

View File

@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.scrobbling.data
package org.koitharu.kotatsu.scrobbling.common.data
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
interface ScrobblerRepository {

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.scrobbling.data
package org.koitharu.kotatsu.scrobbling.common.data
import android.content.Context
import androidx.core.content.edit
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.data
package org.koitharu.kotatsu.scrobbling.common.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@@ -12,6 +12,9 @@ abstract class ScrobblingDao {
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract fun observe(scrobbler: Int, mangaId: Long): Flow<ScrobblingEntity?>
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler")
abstract fun observe(scrobbler: Int): Flow<List<ScrobblingEntity>>
@Upsert
abstract suspend fun upsert(entity: ScrobblingEntity)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.data
package org.koitharu.kotatsu.scrobbling.common.data
import androidx.room.ColumnInfo
import androidx.room.Entity

View File

@@ -1,19 +1,23 @@
package org.koitharu.kotatsu.scrobbling.domain
package org.koitharu.kotatsu.scrobbling.common.domain
import androidx.collection.LongSparseArray
import androidx.collection.getOrElse
import androidx.core.text.parseAsHtml
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
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.ScrobblerRepository
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.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.utils.ext.findKeyByValue
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
@@ -22,15 +26,37 @@ import java.util.EnumMap
abstract class Scrobbler(
protected val db: MangaDatabase,
val scrobblerService: ScrobblerService,
private val repository: ScrobblerRepository,
private val repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository,
) {
private val infoCache = LongSparseArray<ScrobblerMangaInfo>()
protected val statuses = EnumMap<ScrobblingStatus, String>(ScrobblingStatus::class.java)
val user: Flow<ScrobblerUser> = flow {
repository.cachedUser?.let {
emit(it)
}
runCatchingCancellable {
repository.loadUser()
}.onSuccess {
emit(it)
}.onFailure {
it.printStackTraceDebug()
}
}
val isAvailable: Boolean
get() = repository.isAuthorized
suspend fun authorize(authCode: String): ScrobblerUser {
repository.authorize(authCode)
return repository.loadUser()
}
suspend fun logout() {
repository.logout()
}
suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
return repository.findManga(query, offset)
}
@@ -46,14 +72,27 @@ abstract class Scrobbler(
suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null
return entity.toScrobblingInfo(mangaId)
return entity.toScrobblingInfo()
}
abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?)
fun observeScrobblingInfo(mangaId: Long): Flow<ScrobblingInfo?> {
return db.scrobblingDao.observe(scrobblerService.id, mangaId)
.map { it?.toScrobblingInfo(mangaId) }
.map { it?.toScrobblingInfo() }
}
fun observeAllScrobblingInfo(): Flow<List<ScrobblingInfo>> {
return db.scrobblingDao.observe(scrobblerService.id)
.map { entities ->
coroutineScope {
entities.map {
async {
it.toScrobblingInfo()
}
}.awaitAll()
}.filterNotNull()
}
}
suspend fun unregisterScrobbling(mangaId: Long) {
@@ -64,7 +103,7 @@ abstract class Scrobbler(
return repository.getMangaInfo(id)
}
private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? {
private suspend fun ScrobblingEntity.toScrobblingInfo(): ScrobblingInfo? {
val mangaInfo = infoCache.getOrElse(targetId) {
runCatchingCancellable {
getMangaInfo(targetId)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.domain.model
package org.koitharu.kotatsu.scrobbling.common.domain.model
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -37,4 +37,4 @@ class ScrobblerManga(
override fun toString(): String {
return "ScrobblerManga #$id \"$name\" $url"
}
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.domain.model
package org.koitharu.kotatsu.scrobbling.common.domain.model
class ScrobblerMangaInfo(
val id: Long,
@@ -6,4 +6,4 @@ class ScrobblerMangaInfo(
val cover: String,
val url: String,
val descriptionHtml: String,
)
)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.domain.model
package org.koitharu.kotatsu.scrobbling.common.domain.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.domain.model
package org.koitharu.kotatsu.scrobbling.common.domain.model
import javax.inject.Qualifier

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.domain.model
package org.koitharu.kotatsu.scrobbling.common.domain.model
class ScrobblerUser(
val id: Long,

View File

@@ -1,4 +1,6 @@
package org.koitharu.kotatsu.scrobbling.domain.model
package org.koitharu.kotatsu.scrobbling.common.domain.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class ScrobblingInfo(
val scrobbler: ScrobblerService,
@@ -12,7 +14,7 @@ class ScrobblingInfo(
val coverUrl: String,
val description: CharSequence?,
val externalUrl: String,
) {
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -49,4 +51,4 @@ class ScrobblingInfo(
result = 31 * result + externalUrl.hashCode()
return result
}
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.scrobbling.common.domain.model
import org.koitharu.kotatsu.list.ui.model.ListModel
enum class ScrobblingStatus : ListModel {
PLANNED,
READING,
RE_READING,
COMPLETED,
ON_HOLD,
DROPPED,
}

View File

@@ -0,0 +1,188 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter
import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hideCompat
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.showCompat
import javax.inject.Inject
@AndroidEntryPoint
class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
OnListItemClickListener<ScrobblingInfo>, View.OnClickListener {
@Inject
lateinit var viewModelFactory: ScrobblerConfigViewModel.Factory
@Inject
lateinit var coil: ImageLoader
private val viewModel: ScrobblerConfigViewModel by assistedViewModels {
viewModelFactory.create(requireNotNull(getScrobblerService(intent)))
}
private var paddingVertical = 0
private var paddingHorizontal = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityScrobblerConfigBinding.inflate(layoutInflater))
setTitle(viewModel.titleResId)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val listAdapter = ScrobblingMangaAdapter(this, coil, this)
with(binding.recyclerView) {
adapter = listAdapter
setHasFixedSize(true)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
paddingHorizontal = spacing
paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
val decoration = TypedSpacingItemDecoration(
FeedAdapter.ITEM_TYPE_FEED to 0,
fallbackSpacing = spacing,
)
addItemDecoration(decoration)
}
binding.imageViewAvatar.setOnClickListener(this)
viewModel.content.observe(this, listAdapter::setItems)
viewModel.user.observe(this, this::onUserChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onError.observe(this, this::onError)
viewModel.onLoggedOut.observe(this) {
finishAfterTransition()
}
processIntent(intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent != null) {
setIntent(intent)
processIntent(intent)
}
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
left = insets.left + paddingHorizontal,
right = insets.right + paddingHorizontal,
bottom = insets.bottom + paddingVertical,
)
}
override fun onItemClick(item: ScrobblingInfo, view: View) {
startActivity(
DetailsActivity.newIntent(this, item.mangaId),
)
}
override fun onClick(v: View) {
when (v.id) {
R.id.imageView_avatar -> showUserDialog()
}
}
private fun processIntent(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW) {
val uri = intent.data ?: return
val code = uri.getQueryParameter("code")
if (!code.isNullOrEmpty()) {
viewModel.onAuthCodeReceived(code)
}
}
}
private fun onUserChanged(user: ScrobblerUser?) {
if (user == null) {
binding.imageViewAvatar.disposeImageRequest()
binding.imageViewAvatar.isVisible = false
return
}
binding.imageViewAvatar.isVisible = true
binding.imageViewAvatar.newImageRequest(user.avatar, null)
?.enqueueWith(coil)
}
private fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.run {
if (isLoading) {
showCompat()
} else {
hideCompat()
}
}
}
private fun onError(e: Throwable) {
Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_LONG,
).show()
}
private fun showUserDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(getString(R.string.logged_in_as, viewModel.user.value?.nickname))
.setNegativeButton(R.string.close, null)
.setPositiveButton(R.string.logout) { _, _ ->
viewModel.logout()
}.show()
}
companion object {
private const val EXTRA_SERVICE_ID = "service"
private const val HOST_SHIKIMORI_AUTH = "shikimori-auth"
private const val HOST_ANILIST_AUTH = "anilist-auth"
private const val HOST_MAL_AUTH = "mal-auth"
fun newIntent(context: Context, service: ScrobblerService) =
Intent(context, ScrobblerConfigActivity::class.java)
.putExtra(EXTRA_SERVICE_ID, service.id)
private fun getScrobblerService(
intent: Intent
): ScrobblerService? {
val serviceId = intent.getIntExtra(EXTRA_SERVICE_ID, 0)
if (serviceId != 0) {
return enumValues<ScrobblerService>().first { it.id == serviceId }
}
val uri = intent.data ?: return null
return when (uri.host) {
HOST_SHIKIMORI_AUTH -> ScrobblerService.SHIKIMORI
HOST_ANILIST_AUTH -> ScrobblerService.ANILIST
HOST_MAL_AUTH -> ScrobblerService.MAL
else -> null
}
}
}
}

View File

@@ -0,0 +1,98 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.onFirst
class ScrobblerConfigViewModel @AssistedInject constructor(
@Assisted scrobblerService: ScrobblerService,
scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) : BaseViewModel() {
private val scrobbler = scrobblers.first { it.scrobblerService == scrobblerService }
val titleResId = scrobbler.scrobblerService.titleResId
val user = MutableLiveData<ScrobblerUser?>(null)
val onLoggedOut = SingleLiveEvent<Unit>()
val content = scrobbler.observeAllScrobblingInfo()
.onStart { loadingCounter.increment() }
.onFirst { loadingCounter.decrement() }
.catch { errorEvent.postCall(it) }
.map { buildContentList(it) }
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
init {
scrobbler.user
.onEach { user.postValue(it) }
.launchIn(viewModelScope + Dispatchers.Default)
}
fun onAuthCodeReceived(authCode: String) {
launchLoadingJob(Dispatchers.Default) {
val newUser = scrobbler.authorize(authCode)
user.postValue(newUser)
}
}
fun logout() {
launchLoadingJob(Dispatchers.Default) {
scrobbler.logout()
user.postValue(null)
onLoggedOut.postCall(Unit)
}
}
private fun buildContentList(list: List<ScrobblingInfo>): List<ListModel> {
if (list.isEmpty()) {
return listOf(
EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.nothing_here,
textSecondary = R.string.scrobbling_empty_hint,
actionStringRes = 0,
),
)
}
val grouped = list.groupBy { it.status }
val statuses = enumValues<ScrobblingStatus>()
val result = ArrayList<ListModel>(list.size + statuses.size)
for (st in statuses) {
val subList = grouped[st]
if (subList.isNullOrEmpty()) {
continue
}
result.add(st)
result.addAll(subList)
}
return result
}
@AssistedFactory
interface Factory {
fun create(service: ScrobblerService): ScrobblerConfigViewModel
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
fun scrobblingHeaderAD() = adapterDelegate<ScrobblingStatus, ListModel>(R.layout.item_header) {
bind {
(itemView as TextView).text = context.resources
.getStringArray(R.array.scrobbling_statuses)
.getOrNull(item.ordinal)
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemScrobblingMangaBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
fun scrobblingMangaAD(
clickListener: OnListItemClickListener<ScrobblingInfo>,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ScrobblingInfo, ListModel, ItemScrobblingMangaBinding>(
{ layoutInflater, parent -> ItemScrobblingMangaBinding.inflate(layoutInflater, parent, false) },
) {
val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(clickListenerAdapter)
bind {
binding.imageViewCover.newImageRequest(item.coverUrl, null)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title
binding.ratingBar.rating = item.rating * binding.ratingBar.numStars
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
class ScrobblingMangaAdapter(
clickListener: OnListItemClickListener<ScrobblingInfo>,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(scrobblingMangaAD(clickListener, coil, lifecycleOwner))
.addDelegate(scrobblingHeaderAD())
.addDelegate(emptyStateListAD(coil, null))
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when {
oldItem is ScrobblingInfo && newItem is ScrobblingInfo -> {
oldItem.targetId == newItem.targetId && oldItem.mangaId == newItem.mangaId
}
oldItem is ScrobblingStatus && newItem is ScrobblingStatus -> {
oldItem.ordinal == newItem.ordinal
}
oldItem is EmptyState && newItem is EmptyState -> true
else -> false
}
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.ui.selector
package org.koitharu.kotatsu.scrobbling.common.ui.selector
import android.app.Dialog
import android.content.DialogInterface
@@ -24,10 +24,10 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ScrobblerMangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ScrobblerSelectorAdapter
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerMangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerSelectorAdapter
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.utils.ext.getDisplayMessage

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.ui.selector
package org.koitharu.kotatsu.scrobbling.common.ui.selector
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
@@ -18,9 +18,9 @@ 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.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.setTextAndVisible
import org.koitharu.kotatsu.utils.ext.textAndVisible

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter
import android.content.Context
import android.graphics.Canvas
@@ -8,7 +8,7 @@ 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.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.ext.getItem
class ScrobblerMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
@@ -9,8 +9,8 @@ import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
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
import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import kotlin.jvm.internal.Intrinsics
class ScrobblerSelectorAdapter(

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
@@ -7,7 +7,7 @@ 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.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.model
package org.koitharu.kotatsu.scrobbling.common.ui.selector.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.scrobbling.domain.model
enum class ScrobblingStatus {
PLANNED,
READING,
RE_READING,
COMPLETED,
ON_HOLD,
DROPPED,
}

View File

@@ -7,9 +7,9 @@ import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerType
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType
import javax.inject.Inject
import javax.inject.Provider

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.scrobbling.mal.data
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
private const val JSON = "application/json"

View File

@@ -9,20 +9,18 @@ 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.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.toIntUp
import org.koitharu.kotatsu.scrobbling.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
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.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.utils.PKCEGenerator
private const val REDIRECT_URI = "kotatsu://mal-auth"
private const val BASE_OAUTH_URL = "https://myanimelist.net"
private const val BASE_WEB_URL = "https://myanimelist.net"
private const val BASE_API_URL = "https://api.myanimelist.net/v2"
private const val AVATAR_STUB = "https://cdn.myanimelist.net/images/questionmark_50.gif"
@@ -30,12 +28,12 @@ class MALRepository(
private val okHttp: OkHttpClient,
private val storage: ScrobblerStorage,
private val db: MangaDatabase,
) : ScrobblerRepository {
) : org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository {
private var codeVerifier: String = getPKCEChallengeCode()
override val oauthUrl: String
get() = "$BASE_OAUTH_URL/v1/oauth2/authorize?" +
get() = "$BASE_WEB_URL/v1/oauth2/authorize?" +
"response_type=code" +
"&client_id=${BuildConfig.MAL_CLIENT_ID}" +
"&redirect_uri=$REDIRECT_URI" +
@@ -61,7 +59,7 @@ class MALRepository(
}
val request = Request.Builder()
.post(body.build())
.url("${BASE_OAUTH_URL}/v1/oauth2/token")
.url("${BASE_WEB_URL}/v1/oauth2/token")
val response = okHttp.newCall(request.build()).await().parseJson()
storage.accessToken = response.getString("access_token")
@@ -83,17 +81,15 @@ class MALRepository(
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val url = BASE_API_URL.toHttpUrl().newBuilder()
.addPathSegment("manga")
.addQueryParameter("offset", offset.toFloat().toIntUp().toString())
.addQueryParameter("offset", offset.toString())
.addQueryParameter("nsfw", "true")
.addQueryParameter(
"q",
query.take(64)
) // WARNING! MAL API throws a 400 when the query is over 64 characters
// WARNING! MAL API throws a 400 when the query is over 64 characters
.addQueryParameter("q", query.take(64))
.build()
val request = Request.Builder().url(url).get().build()
val response = okHttp.newCall(request).await().parseJson()
val data = response.getJSONArray("data")
return data.mapJSON { jsonToManga(it) }
return data.mapJSONNotNull { jsonToManga(it) }
}
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
@@ -181,7 +177,7 @@ class MALRepository(
return codeVerifier
}
private fun jsonToManga(json: JSONObject): ScrobblerManga {
private fun jsonToManga(json: JSONObject): ScrobblerManga? {
for (i in 0 until json.length()) {
val node = json.getJSONObject("node")
return ScrobblerManga(
@@ -189,23 +185,17 @@ class MALRepository(
name = node.getString("title"),
altName = null,
cover = node.getJSONObject("main_picture").getString("large"),
url = "https://myanimelist.net/manga/${node.getLong("id")}"
url = "$BASE_WEB_URL/manga/${node.getLong("id")}",
)
}
return ScrobblerManga(
id = 1,
name = "",
altName = null,
cover = "",
url = ""
)
return null
}
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"),
name = json.getString("title"),
cover = json.getJSONObject("main_picture").getString("large"),
url = "https://myanimelist.net/manga/${json.getLong("id")}",
url = "$BASE_WEB_URL/manga/${json.getLong("id")}",
descriptionHtml = json.getString("synopsis"),
)

View File

@@ -1,12 +1,9 @@
package org.koitharu.kotatsu.scrobbling.mal.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.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository
import javax.inject.Inject
import javax.inject.Singleton

View File

@@ -1,86 +0,0 @@
package org.koitharu.kotatsu.scrobbling.mal.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 dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser
import org.koitharu.kotatsu.utils.PreferenceIconTarget
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class MALSettingsFragment : BasePreferenceFragment(R.string.mal) {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var viewModelFactory: MALSettingsViewModel.Factory
private val viewModel by assistedViewModels {
viewModelFactory.create(arguments?.getString(ARG_AUTH_CODE))
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_mal)
}
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: ScrobblerUser?) {
val pref = findPreference<Preference>(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<Preference>(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 = "mal_user"
private const val KEY_LOGOUT = "mal_logout"
private const val ARG_AUTH_CODE = "auth_code"
fun newInstance(authCode: String?) = MALSettingsFragment().withArgs(1) {
putString(ARG_AUTH_CODE, authCode)
}
}
}

View File

@@ -1,57 +0,0 @@
package org.koitharu.kotatsu.scrobbling.mal.ui
import androidx.lifecycle.MutableLiveData
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository
class MALSettingsViewModel @AssistedInject constructor(
private val repository: MALRepository,
@Assisted authCode: String?,
) : BaseViewModel() {
val authorizationUrl: String
get() = repository.oauthUrl
val user = MutableLiveData<ScrobblerUser?>()
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.cachedUser?.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())
}
@AssistedFactory
interface Factory {
fun create(authCode: String?): MALSettingsViewModel
}
}

View File

@@ -7,9 +7,9 @@ import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerType
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType
import javax.inject.Inject
import javax.inject.Provider

View File

@@ -4,7 +4,7 @@ import okhttp3.Interceptor
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
private const val USER_AGENT_SHIKIMORI = "Kotatsu"

View File

@@ -14,13 +14,13 @@ 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.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
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.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.utils.ext.toRequestBody
private const val REDIRECT_URI = "kotatsu://shikimori-auth"

View File

@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.scrobbling.shikimori.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import javax.inject.Inject
import javax.inject.Singleton

View File

@@ -1,86 +0,0 @@
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 dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser
import org.koitharu.kotatsu.utils.PreferenceIconTarget
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var viewModelFactory: ShikimoriSettingsViewModel.Factory
private val viewModel by assistedViewModels {
viewModelFactory.create(arguments?.getString(ARG_AUTH_CODE))
}
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: ScrobblerUser?) {
val pref = findPreference<Preference>(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<Preference>(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)
}
}
}

View File

@@ -1,57 +0,0 @@
package org.koitharu.kotatsu.scrobbling.shikimori.ui
import androidx.lifecycle.MutableLiveData
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
class ShikimoriSettingsViewModel @AssistedInject constructor(
private val repository: ShikimoriRepository,
@Assisted authCode: String?,
) : BaseViewModel() {
val authorizationUrl: String
get() = repository.oauthUrl
val user = MutableLiveData<ScrobblerUser?>()
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.cachedUser?.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())
}
@AssistedFactory
interface Factory {
fun create(authCode: String?): ShikimoriSettingsViewModel
}
}

View File

@@ -20,7 +20,8 @@ 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.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
@@ -130,28 +131,28 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
AppSettings.KEY_SHIKIMORI -> {
if (!shikimoriRepository.isAuthorized) {
launchScrobblerAuth(shikimoriRepository)
true
} else {
super.onPreferenceTreeClick(preference)
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.SHIKIMORI))
}
true
}
AppSettings.KEY_MAL -> {
if (!malRepository.isAuthorized) {
launchScrobblerAuth(malRepository)
true
} else {
super.onPreferenceTreeClick(preference)
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.MAL))
}
true
}
AppSettings.KEY_ANILIST -> {
if (!aniListRepository.isAuthorized) {
launchScrobblerAuth(aniListRepository)
true
} else {
super.onPreferenceTreeClick(preference)
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.ANILIST))
}
true
}
else -> super.onPreferenceTreeClick(preference)
@@ -217,7 +218,10 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
}.show()
}
private fun bindScrobblerSummary(key: String, repository: ScrobblerRepository) {
private fun bindScrobblerSummary(
key: String,
repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
) {
val pref = findPreference<Preference>(key) ?: return
if (!repository.isAuthorized) {
pref.setSummary(R.string.disabled)
@@ -242,7 +246,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
}
}
private fun launchScrobblerAuth(repository: ScrobblerRepository) {
private fun launchScrobblerAuth(repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository) {
runCatching {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(repository.oauthUrl)

View File

@@ -3,7 +3,6 @@ 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
@@ -24,9 +23,6 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.anilist.ui.AniListSettingsFragment
import org.koitharu.kotatsu.scrobbling.mal.ui.MALSettingsFragment
import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
@@ -126,10 +122,8 @@ 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_HISTORY -> HistorySettingsFragment()
ACTION_TRACKER -> TrackerSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
@@ -145,21 +139,6 @@ class SettingsActivity :
}
}
private fun handleUri(uri: Uri?): Fragment? {
when (uri?.host) {
HOST_SHIKIMORI_AUTH ->
return ShikimoriSettingsFragment.newInstance(authCode = uri.getQueryParameter("code"))
HOST_ANILIST_AUTH ->
return AniListSettingsFragment.newInstance(authCode = uri.getQueryParameter("code"))
HOST_MAL_AUTH ->
return MALSettingsFragment.newInstance(authCode = uri.getQueryParameter("code"))
}
finishAfterTransition()
return null
}
companion object {
private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
@@ -171,20 +150,12 @@ class SettingsActivity :
private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
private const val EXTRA_SOURCE = "source"
private const val HOST_SHIKIMORI_AUTH = "shikimori-auth"
private const val HOST_ANILIST_AUTH = "anilist-auth"
private const val HOST_MAL_AUTH = "mal-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)

View File

@@ -41,8 +41,6 @@ class FeedFragment :
private val viewModel by viewModels<FeedViewModel>()
private var feedAdapter: FeedAdapter? = null
private var paddingVertical = 0
private var paddingHorizontal = 0
override fun onInflateView(
inflater: LayoutInflater,
@@ -57,8 +55,6 @@ class FeedFragment :
setHasFixedSize(true)
addOnScrollListener(PaginationScrollListener(4, this@FeedFragment))
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
paddingHorizontal = spacing
paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
val decoration = TypedSpacingItemDecoration(
FeedAdapter.ITEM_TYPE_FEED to 0,
fallbackSpacing = spacing,

View File

@@ -2,7 +2,12 @@ package org.koitharu.kotatsu.utils.ext
import android.os.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
var isFirstCall = true
@@ -11,6 +16,8 @@ fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
action(it)
isFirstCall = false
}
}.onCompletion {
isFirstCall = true
}
}

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 723 B

After

Width:  |  Height:  |  Size: 723 B

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -2,6 +2,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="18"
android:viewportHeight="18">
<path

View File

@@ -2,6 +2,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="30.72"
android:viewportHeight="30.72">
<path

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap
xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@drawable/ic_shikimori_raw"
android:tint="?colorControlNormal" />

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapsingToolbarLayout"
style="?attr/collapsingToolbarLayoutMediumStyle"
android:layout_width="match_parent"
android:layout_height="?attr/collapsingToolbarLayoutMediumSize"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
app:toolbarId="@id/toolbar">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_avatar"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center_vertical|end"
android:layout_marginHorizontal="@dimen/toolbar_button_margin"
android:background="?selectableItemBackgroundBorderless"
android:padding="1dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Circle"
app:strokeColor="?colorOutline"
app:strokeWidth="1dp"
tools:src="@tools:sample/avatars" />
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingRight="@dimen/list_spacing"
android:paddingBottom="@dimen/grid_spacing_outer"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:listitem="@layout/item_scrobbling_manga" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:layout_anchor="@id/recyclerView"
app:layout_anchorGravity="center"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/list_selector"
android:clipChildren="false"
android:padding="@dimen/list_spacing">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="42dp"
android:layout_height="42dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
tools:src="@sample/covers" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toTopOf="@+id/ratingBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
tools:text="@sample/titles" />
<RatingBar
android:id="@+id/ratingBar"
style="?ratingBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="2dp"
android:isIndicator="true"
android:max="1"
android:numStars="5"
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toBottomOf="@+id/textView_title" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -416,4 +416,6 @@
<string name="theme_name_mion">Mion</string>
<string name="theme_name_rikka">Rikka</string>
<string name="theme_name_sakura">Sakura</string>
<string name="nothing_here">There is nothing here</string>
<string name="scrobbling_empty_hint">To track reading progress, select Menu → Track on the manga details screen.</string>
</resources>

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:initialExpandedChildrenCount="5">
<Preference
android:key="al_user"
android:persistent="false"
android:title="@string/loading_"
app:iconSpaceReserved="true" />
<Preference
android:key="al_logout"
android:persistent="false"
android:title="@string/logout"
app:allowDividerAbove="true" />
</PreferenceScreen>

View File

@@ -22,23 +22,20 @@
<PreferenceCategory android:title="@string/tracking">
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment"
android:icon="@drawable/ic_shikimori"
<Preference
android:key="shikimori"
android:title="@string/shikimori" />
android:title="@string/shikimori"
app:icon="@drawable/ic_shikimori" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.scrobbling.mal.ui.MALSettingsFragment"
android:key="mal"
android:icon="@drawable/ic_mal"
android:title="@string/mal" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.scrobbling.anilist.ui.AniListSettingsFragment"
android:icon="@drawable/ic_anilist"
<Preference
android:key="anilist"
android:title="@string/anilist" />
android:title="@string/anilist"
app:icon="@drawable/ic_anilist" />
<Preference
android:key="mal"
android:title="@string/mal"
app:icon="@drawable/ic_mal" />
</PreferenceCategory>

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:initialExpandedChildrenCount="5">
<Preference
android:key="mal_user"
android:persistent="false"
android:title="@string/loading_"
app:iconSpaceReserved="true" />
<Preference
android:key="mal_logout"
android:persistent="false"
android:title="@string/logout"
app:allowDividerAbove="true" />
</PreferenceScreen>

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:initialExpandedChildrenCount="5">
<Preference
android:key="shiki_user"
android:persistent="false"
android:title="@string/loading_"
app:iconSpaceReserved="true" />
<Preference
android:key="shiki_logout"
android:persistent="false"
android:title="@string/logout"
app:allowDividerAbove="true" />
</PreferenceScreen>