Shikimori interaction implementation

This commit is contained in:
Koitharu
2022-06-22 12:46:48 +03:00
parent 0695103589
commit ec89ba0155
57 changed files with 1280 additions and 417 deletions

View File

@@ -31,9 +31,9 @@ import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.readerModule import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule
import org.koitharu.kotatsu.search.searchModule import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.shikimori.shikimoriModule
import org.koitharu.kotatsu.suggestions.suggestionsModule import org.koitharu.kotatsu.suggestions.suggestionsModule
import org.koitharu.kotatsu.tracker.trackerModule import org.koitharu.kotatsu.tracker.trackerModule
import org.koitharu.kotatsu.widget.WidgetUpdater import org.koitharu.kotatsu.widget.WidgetUpdater

View File

@@ -6,8 +6,14 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.* import org.koitharu.kotatsu.core.db.dao.MangaDao
import org.koitharu.kotatsu.core.db.entity.* 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.core.db.migrations.*
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity 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.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity 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.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity 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, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class,
], ],
version = 11, version = 12,
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
@@ -50,6 +59,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val suggestionDao: SuggestionDao abstract val suggestionDao: SuggestionDao
abstract val bookmarksDao: BookmarksDao abstract val bookmarksDao: BookmarksDao
abstract val scrobblingDao: ScrobblingDao
} }
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
@@ -67,6 +78,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
Migration8To9(), Migration8To9(),
Migration9To10(), Migration9To10(),
Migration10To11(), Migration10To11(),
Migration11To12(),
).addCallback( ).addCallback(
DatabasePrePopulateCallback(context.resources) DatabasePrePopulateCallback(context.resources)
).build() ).build()

View File

@@ -0,0 +1,25 @@
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()
)
}
}

View File

@@ -8,6 +8,6 @@ val detailsModule
get() = module { get() = module {
viewModel { intent -> viewModel { intent ->
DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get()) DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get(), get())
} }
} }

View File

@@ -42,8 +42,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState 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.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorBottomSheet
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : class DetailsActivity :
@@ -156,7 +156,7 @@ class DetailsActivity :
menu.findItem(R.id.action_delete).isVisible = 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_browser).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this) menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this)
menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isShikimoriAvailable menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable
return super.onPrepareOptionsMenu(menu) return super.onPrepareOptionsMenu(menu)
} }
@@ -199,7 +199,7 @@ class DetailsActivity :
} }
R.id.action_shiki_track -> { R.id.action_shiki_track -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
ShikimoriSelectorBottomSheet.show(supportFragmentManager, it) ScrobblingSelectorBottomSheet.show(supportFragmentManager, it)
} }
true true
} }

View File

@@ -17,6 +17,7 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -31,6 +32,7 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding 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.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -39,9 +41,9 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState 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.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
@@ -68,6 +70,7 @@ class DetailsFragment :
binding.buttonRead.setOnClickListener(this) binding.buttonRead.setOnClickListener(this)
binding.buttonRead.setOnLongClickListener(this) binding.buttonRead.setOnLongClickListener(this)
binding.imageViewCover.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this)
binding.scrobblingLayout.root.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
@@ -75,6 +78,7 @@ class DetailsFragment :
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
addMenuProvider(DetailsMenuProvider()) addMenuProvider(DetailsMenuProvider())
} }
@@ -210,12 +214,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) { override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
when (v.id) { when (v.id) {
R.id.button_favorite -> { R.id.button_favorite -> {
FavouriteCategoriesBottomSheet.show(childFragmentManager, manga) FavouriteCategoriesBottomSheet.show(childFragmentManager, manga)
} }
R.id.scrobbling_layout -> {
ScrobblingInfoBottomSheet.show(childFragmentManager)
}
R.id.button_read -> { R.id.button_read -> {
val chapterId = viewModel.readingHistory.value?.chapterId val chapterId = viewModel.readingHistory.value?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {

View File

@@ -26,7 +26,8 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository 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.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -41,7 +42,7 @@ class DetailsViewModel(
mangaDataRepository: MangaDataRepository, mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val shikimoriRepository: ShikimoriRepository, private val scrobbler: Scrobbler,
) : BaseViewModel() { ) : BaseViewModel() {
private val delegate = MangaDetailsDelegate( private val delegate = MangaDetailsDelegate(
@@ -81,8 +82,11 @@ class DetailsViewModel(
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Manga>()
val isShikimoriAvailable: Boolean val isScrobblingAvailable: Boolean
get() = shikimoriRepository.isAuthorized get() = scrobbler.isAvailable
val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId)
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val branches: LiveData<List<String?>> = delegate.manga.map { val branches: LiveData<List<String?>> = delegate.manga.map {
val chapters = it?.chapters ?: return@map emptyList() val chapters = it?.chapters ?: return@map emptyList()
@@ -192,6 +196,17 @@ class DetailsViewModel(
} }
} }
fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad() delegate.doLoad()
} }

View File

@@ -0,0 +1,119 @@
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.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.RatingBar
import android.widget.Toast
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.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class ScrobblingInfoBottomSheet :
BaseBottomSheet<SheetScrobblingBinding>(),
AdapterView.OnItemSelectedListener,
RatingBar.OnRatingBarChangeListener,
View.OnClickListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
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.buttonOpen.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.updateScrobbling(
rating = binding.ratingBar.rating / binding.ratingBar.numStars,
status = enumValues<ScrobblingStatus>().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<ScrobblingStatus>().getOrNull(binding.spinnerStatus.selectedItemPosition),
)
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_open -> {
val url = viewModel.scrobblingInfo.value?.externalUrl ?: return
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(
Intent.createChooser(intent, getString(R.string.open_in_browser))
)
}
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)
}
}

View File

@@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule val historyModule
get() = module { get() = module {
factory { HistoryRepository(get(), get(), get()) } factory { HistoryRepository(get(), get(), get(), getAll()) }
viewModel { HistoryListViewModel(get(), get(), get(), get()) } viewModel { HistoryListViewModel(get(), get(), get(), get()) }
} }

View File

@@ -13,6 +13,8 @@ import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag 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.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
@@ -20,6 +22,7 @@ class HistoryRepository(
private val db: MangaDatabase, private val db: MangaDatabase,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblers: List<Scrobbler>,
) { ) {
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> { suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
@@ -78,6 +81,10 @@ class HistoryRepository(
) )
) )
trackingRepository.syncWithHistory(manga, chapterId) trackingRepository.syncWithHistory(manga, chapterId)
val chapter = manga.chapters?.find { x -> x.id == chapterId }
if (chapter != null) {
scrobblers.forEach { it.tryScrobble(manga.id, chapter) }
}
} }
} }

View File

@@ -0,0 +1,20 @@
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<ScrobblingEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(entity: ScrobblingEntity)
@Update
abstract suspend fun update(entity: ScrobblingEntity)
}

View File

@@ -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,
)
}

View File

@@ -0,0 +1,78 @@
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<ScrobblerMangaInfo>()
protected val statuses = EnumMap<ScrobblingStatus, String>(ScrobblingStatus::class.java)
abstract val isAvailable: Boolean
abstract suspend fun findManga(query: String, offset: Int): List<ScrobblerManga>
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<ScrobblingInfo?> {
return db.scrobblingDao.observe(scrobblerService.id, mangaId)
.map { it?.toScrobblingInfo(mangaId) }
}
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
}

View File

@@ -1,11 +1,8 @@
package org.koitharu.kotatsu.shikimori.data.model package org.koitharu.kotatsu.scrobbling.domain.model
import org.json.JSONObject
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
class ShikimoriManga( class ScrobblerManga(
val id: Long, val id: Long,
val name: String, val name: String,
val altName: String?, val altName: String?,
@@ -13,19 +10,11 @@ class ShikimoriManga(
val url: String, val url: String,
) : ListModel { ) : ListModel {
constructor(json: JSONObject) : this(
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"),
)
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
other as ShikimoriManga other as ScrobblerManga
if (id != other.id) return false if (id != other.id) return false
if (name != other.name) return false if (name != other.name) return false
@@ -46,6 +35,6 @@ class ShikimoriManga(
} }
override fun toString(): String { override fun toString(): String {
return "ShikimoriManga #$id \"$name\" $url" return "ScrobblerManga #$id \"$name\" $url"
} }
} }

View File

@@ -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,
)

View File

@@ -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)
}

View File

@@ -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
}
}

View File

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

View File

@@ -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()) }
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.shikimori.data package org.koitharu.kotatsu.scrobbling.shikimori.data
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator import okhttp3.Authenticator

View File

@@ -1,7 +1,8 @@
package org.koitharu.kotatsu.shikimori.data package org.koitharu.kotatsu.scrobbling.shikimori.data
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
private const val USER_AGENT_SHIKIMORI = "Kotatsu" private const val USER_AGENT_SHIKIMORI = "Kotatsu"
@@ -14,6 +15,10 @@ class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor
storage.accessToken?.let { storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
} }
return chain.proceed(request.build()) val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) {
throw IOException("${response.code} ${response.message}")
}
return response
} }
} }

View File

@@ -0,0 +1,196 @@
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.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 CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU"
private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8"
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=$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", CLIENT_ID)
body.add("client_secret", 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
}
fun logout() {
storage.clear()
}
suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
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"),
)
}

View File

@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.shikimori.data package org.koitharu.kotatsu.scrobbling.shikimori.data
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
private const val PREF_NAME = "shikimori" private const val PREF_NAME = "shikimori"
private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_ACCESS_TOKEN = "access_token"

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.shikimori.data.model package org.koitharu.kotatsu.scrobbling.shikimori.data.model
import org.json.JSONObject import org.json.JSONObject

View File

@@ -0,0 +1,64 @@
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<ScrobblerManga> {
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 getMangaInfo(id: Long): ScrobblerMangaInfo {
return repository.getMangaInfo(id)
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.shikimori.ui package org.koitharu.kotatsu.scrobbling.shikimori.ui
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
@@ -13,7 +13,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
import org.koitharu.kotatsu.utils.PreferenceIconTarget import org.koitharu.kotatsu.utils.PreferenceIconTarget
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.shikimori.ui package org.koitharu.kotatsu.scrobbling.shikimori.ui
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
class ShikimoriSettingsViewModel( class ShikimoriSettingsViewModel(
private val repository: ShikimoriRepository, private val repository: ShikimoriRepository,
@@ -34,7 +34,7 @@ class ShikimoriSettingsViewModel(
private fun loadUser() = launchJob(Dispatchers.Default) { private fun loadUser() = launchJob(Dispatchers.Default) {
val userModel = if (repository.isAuthorized) { val userModel = if (repository.isAuthorized) {
repository.getCachedUser()?.let(user::postValue) repository.getCachedUser()?.let(user::postValue)
repository.getUser() repository.loadUser()
} else { } else {
null null
} }
@@ -43,6 +43,6 @@ class ShikimoriSettingsViewModel(
private fun authorize(code: String) = launchJob(Dispatchers.Default) { private fun authorize(code: String) = launchJob(Dispatchers.Default) {
repository.authorize(code) repository.authorize(code)
user.postValue(repository.getUser()) user.postValue(repository.loadUser())
} }
} }

View File

@@ -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<SheetScrobblingSelectorBinding>(),
OnListItemClickListener<ScrobblerManga>,
PaginationScrollListener.Callback,
View.OnClickListener,
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener {
private val viewModel by viewModel<ScrobblingSelectorViewModel> {
parametersOf(requireNotNull(requireArguments().getParcelable<ParcelableManga>(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)
}
}

View File

@@ -1,10 +1,9 @@
package org.koitharu.kotatsu.shikimori.ui.selector package org.koitharu.kotatsu.scrobbling.ui.selector
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,18 +14,20 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class ShikimoriSelectorViewModel( class ScrobblingSelectorViewModel(
val manga: Manga, val manga: Manga,
private val repository: ShikimoriRepository, private val scrobbler: Scrobbler,
) : BaseViewModel() { ) : BaseViewModel() {
private val shikiMangaList = MutableStateFlow<List<ShikimoriManga>?>(null) private val shikiMangaList = MutableStateFlow<List<ScrobblerManga>?>(null)
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null private var loadingJob: Job? = null
private var doneJob: Job? = null
val content: LiveData<List<ListModel>> = combine( val content: LiveData<List<ListModel>> = combine(
shikiMangaList.filterNotNull(), shikiMangaList.filterNotNull(),
@@ -39,17 +40,29 @@ class ShikimoriSelectorViewModel(
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val selectedItemId = MutableLiveData(RecyclerView.NO_ID) val selectedItemId = MutableLiveData(NO_ID)
val searchQuery = MutableLiveData(manga.title)
val avatar = liveData(viewModelScope.coroutineContext + Dispatchers.Default) { val onClose = SingleLiveEvent<Unit>()
emit(repository.getCachedUser()?.avatar)
emit(runCatching { repository.getUser().avatar }.getOrNull())
}
val isEmpty: Boolean val isEmpty: Boolean
get() = shikiMangaList.value.isNullOrEmpty() get() = shikiMangaList.value.isNullOrEmpty()
init { 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) loadList(append = false)
} }
@@ -62,7 +75,7 @@ class ShikimoriSelectorViewModel(
} }
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
val offset = if (append) shikiMangaList.value?.size ?: 0 else 0 val offset = if (append) shikiMangaList.value?.size ?: 0 else 0
val list = repository.findManga(manga.title, offset) val list = scrobbler.findManga(checkNotNull(searchQuery.value), offset)
if (!append) { if (!append) {
shikiMangaList.value = list shikiMangaList.value = list
} else if (list.isNotEmpty()) { } else if (list.isNotEmpty()) {
@@ -71,4 +84,18 @@ class ShikimoriSelectorViewModel(
hasNextPage.value = list.isNotEmpty() 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)
}
}
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.shikimori.ui.selector.adapter package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
@@ -8,7 +8,7 @@ import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.ext.getItem import org.koitharu.kotatsu.utils.ext.getItem
class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) { class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
@@ -24,7 +24,7 @@ class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration
override fun getItemId(parent: RecyclerView, child: View): Long { override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(ShikimoriManga::class.java) ?: return NO_ID val item = holder.getItem(ScrobblerManga::class.java) ?: return NO_ID
return item.id return item.id
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.shikimori.ui.selector.adapter package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
@@ -10,7 +10,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
@@ -18,8 +18,8 @@ import org.koitharu.kotatsu.utils.ext.textAndVisible
fun shikimoriMangaAD( fun shikimoriMangaAD(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
coil: ImageLoader, coil: ImageLoader,
clickListener: OnListItemClickListener<ShikimoriManga>, clickListener: OnListItemClickListener<ScrobblerManga>,
) = adapterDelegateViewBinding<ShikimoriManga, ListModel, ItemMangaListBinding>( ) = adapterDelegateViewBinding<ScrobblerManga, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
) { ) {

View File

@@ -1,20 +1,20 @@
package org.koitharu.kotatsu.shikimori.ui.selector.adapter package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import kotlin.jvm.internal.Intrinsics
class ShikimoriSelectorAdapter( class ShikimoriSelectorAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
coil: ImageLoader, coil: ImageLoader,
clickListener: OnListItemClickListener<ShikimoriManga>, clickListener: OnListItemClickListener<ScrobblerManga>,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init { init {
@@ -28,7 +28,7 @@ class ShikimoriSelectorAdapter(
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when { return when {
oldItem === newItem -> true oldItem === newItem -> true
oldItem is ShikimoriManga && newItem is ShikimoriManga -> oldItem.id == newItem.id oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id
else -> false else -> false
} }
} }

View File

@@ -1,14 +1,10 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import java.io.File import java.io.File
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -20,7 +16,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.utils.SliderPreference import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -31,7 +26,6 @@ class ContentSettingsFragment :
StorageSelectDialog.OnStorageSelectListener { StorageSelectDialog.OnStorageSelectListener {
private val storageManager by inject<LocalStorageManager>() private val storageManager by inject<LocalStorageManager>()
private val shikimoriRepository by inject<ShikimoriRepository>(mode = LazyThreadSafetyMode.NONE)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_content) addPreferencesFromResource(R.xml.pref_content)
@@ -55,12 +49,12 @@ class ContentSettingsFragment :
).names() ).names()
setDefaultValueCompat(DoHProvider.NONE.name) setDefaultValueCompat(DoHProvider.NONE.name)
} }
bindRemoteSourcesSummary()
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
bindRemoteSourcesSummary()
settings.subscribe(this) settings.subscribe(this)
} }
@@ -96,14 +90,6 @@ class ContentSettingsFragment :
.show() .show()
true true
} }
AppSettings.KEY_SHIKIMORI -> {
if (!shikimoriRepository.isAuthorized) {
showShikimoriDialog()
true
} else {
super.onPreferenceTreeClick(preference)
}
}
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
@@ -125,20 +111,4 @@ class ContentSettingsFragment :
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)
} }
} }
private fun showShikimoriDialog() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.shikimori)
.setMessage(R.string.shikimori_info)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.sign_in) { _, _ ->
runCatching {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(shikimoriRepository.oauthUrl)
startActivity(intent)
}.onFailure {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_LONG).show()
}
}.show()
}
} }

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.preference.Preference 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.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager 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.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
@@ -25,6 +28,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
private val trackerRepo by inject<TrackingRepository>(mode = LazyThreadSafetyMode.NONE) private val trackerRepo by inject<TrackingRepository>(mode = LazyThreadSafetyMode.NONE)
private val searchRepository by inject<MangaSearchRepository>(mode = LazyThreadSafetyMode.NONE) private val searchRepository by inject<MangaSearchRepository>(mode = LazyThreadSafetyMode.NONE)
private val storageManager by inject<LocalStorageManager>(mode = LazyThreadSafetyMode.NONE) private val storageManager by inject<LocalStorageManager>(mode = LazyThreadSafetyMode.NONE)
private val shikimoriRepository by inject<ShikimoriRepository>(mode = LazyThreadSafetyMode.NONE)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_history) 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 { override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) { return when (preference.key) {
AppSettings.KEY_PAGES_CACHE_CLEAR -> { AppSettings.KEY_PAGES_CACHE_CLEAR -> {
@@ -81,6 +90,14 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
} }
true true
} }
AppSettings.KEY_SHIKIMORI -> {
if (!shikimoriRepository.isAuthorized) {
launchShikimoriAuth()
true
} else {
super.onPreferenceTreeClick(preference)
}
}
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
@@ -142,4 +159,22 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
} }
}.show() }.show()
} }
private fun bindShikimoriSummary() {
findPreference<Preference>(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()
}
}
} }

View File

@@ -23,7 +23,7 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsFragment import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment
import org.koitharu.kotatsu.utils.ext.isScrolledToTop import org.koitharu.kotatsu.utils.ext.isScrolledToTop
class SettingsActivity : class SettingsActivity :

View File

@@ -1,28 +0,0 @@
package org.koitharu.kotatsu.shikimori
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.shikimori.data.ShikimoriAuthenticator
import org.koitharu.kotatsu.shikimori.data.ShikimoriInterceptor
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.shikimori.data.ShikimoriStorage
import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsViewModel
import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorViewModel
val shikimoriModule
get() = module {
single { ShikimoriStorage(androidContext()) }
factory {
val okHttp = OkHttpClient.Builder().apply {
authenticator(ShikimoriAuthenticator(get(), ::get))
addInterceptor(ShikimoriInterceptor(get()))
}.build()
ShikimoriRepository(okHttp, get())
}
viewModel { params ->
ShikimoriSettingsViewModel(get(), params.getOrNull())
}
viewModel { params -> ShikimoriSelectorViewModel(params[0], get()) }
}

View File

@@ -1,145 +0,0 @@
package org.koitharu.kotatsu.shikimori.data
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.await
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.urlEncoded
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser
import org.koitharu.kotatsu.utils.ext.toRequestBody
private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU"
private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8"
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,
) {
val oauthUrl: String
get() = "${BASE_URL}oauth/authorize?client_id=$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", CLIENT_ID)
body.add("client_secret", 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 getUser(): 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
}
fun logout() {
storage.clear()
}
suspend fun findManga(query: String, offset: Int): List<ShikimoriManga> {
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 { ShikimoriManga(it) }
return if (pageOffset != 0) list.drop(pageOffset) else list
}
suspend fun trackManga(manga: Manga, shikiMangaId: Long) {
val user = getCachedUser() ?: getUser()
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()
}
suspend fun findMangaInfo(manga: Manga): ShikimoriMangaInfo? {
val q = manga.title.urlEncoded()
val request = Request.Builder()
.get()
.url("${BASE_URL}api/mangas?limit=5&search=$q&censored=false")
val response = okHttp.newCall(request.build()).await().parseJsonArray()
val candidates = response.mapJSON { ShikimoriManga(it) }
val bestCandidate = candidates.filter {
it.name.equals(manga.title, ignoreCase = true) || it.name.equals(manga.altTitle, ignoreCase = true)
}.singleOrNull() ?: return null
return getMangaInfo(bestCandidate.id)
}
suspend fun getRelatedManga(id: Long): List<ShikimoriManga> {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/mangas/$id/related")
val response = okHttp.newCall(request.build()).await().parseJsonArray()
return response.mapJSON { jo -> ShikimoriManga(jo) }
}
suspend fun getSimilarManga(id: Long): List<ShikimoriManga> {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/mangas/$id/similar")
val response = okHttp.newCall(request.build()).await().parseJsonArray()
return response.mapJSON { jo -> ShikimoriManga(jo) }
}
suspend fun getMangaInfo(id: Long): ShikimoriMangaInfo {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/mangas/$id")
val response = okHttp.newCall(request.build()).await().parseJson()
return ShikimoriMangaInfo(response)
}
}

View File

@@ -1,20 +0,0 @@
package org.koitharu.kotatsu.shikimori.data.model
import org.json.JSONObject
class ShikimoriMangaInfo(
val id: Long,
val name: String,
val cover: String,
val url: String,
val descriptionHtml: String,
) {
constructor(json: JSONObject) : this(
id = json.getLong("id"),
name = json.getString("name"),
cover = json.getJSONObject("image").getString("preview"),
url = json.getString("url"),
descriptionHtml = json.getString("description_html"),
)
}

View File

@@ -1,106 +0,0 @@
package org.koitharu.kotatsu.shikimori.ui.selector
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.FragmentManager
import coil.transform.CircleCropTransformation
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.SheetShikiSelectorBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga
import org.koitharu.kotatsu.shikimori.ui.selector.adapter.ShikiMangaSelectionDecoration
import org.koitharu.kotatsu.shikimori.ui.selector.adapter.ShikimoriSelectorAdapter
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.withArgs
class ShikimoriSelectorBottomSheet :
BaseBottomSheet<SheetShikiSelectorBinding>(),
OnListItemClickListener<ShikimoriManga>,
PaginationScrollListener.Callback,
View.OnClickListener {
private val viewModel by viewModel<ShikimoriSelectorViewModel> {
parametersOf(requireNotNull(requireArguments().getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga)
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetShikiSelectorBinding {
return SheetShikiSelectorBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.subtitle = viewModel.manga.title
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@ShikimoriSelectorBottomSheet))
}
binding.imageViewUser.setOnClickListener(this)
viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it }
viewModel.selectedItemId.observe(viewLifecycleOwner) {
decoration.checkedItemId = it
binding.recyclerView.invalidateItemDecorations()
}
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.avatar.observe(viewLifecycleOwner, ::setUserAvatar)
}
override fun onClick(v: View) {
when (v.id) {
R.id.imageView_user -> startActivity(SettingsActivity.newShikimoriSettingsIntent(v.context))
}
}
override fun onItemClick(item: ShikimoriManga, view: View) {
viewModel.selectedItemId.value = item.id
}
override fun onScrolledToEnd() {
viewModel.loadList(append = true)
}
private fun onError(e: Throwable) {
Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
if (viewModel.isEmpty) {
dismissAllowingStateLoss()
}
}
private fun setUserAvatar(url: String?) {
val iconSize = resources.getDimensionPixelSize(R.dimen.action_bar_item_size)
binding.imageViewUser.newImageRequest(url)
.transformations(CircleCropTransformation())
.size(iconSize, iconSize)
.enqueueWith(get())
}
companion object {
private const val TAG = "ShikimoriSelectorBottomSheet"
fun show(fm: FragmentManager, manga: Manga) =
ShikimoriSelectorBottomSheet().withArgs(1) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false))
}.show(fm, TAG)
}
}

View File

@@ -34,4 +34,13 @@ fun <T> List<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
this as ArrayList<T> this as ArrayList<T>
} else { } else {
ArrayList(this) ArrayList(this)
}
fun <K, V> Map<K, V>.findKey(value: V): K? {
for ((k, v) in entries) {
if (v == value) {
return k
}
}
return null
} }

View File

@@ -1,18 +1,20 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.content.ActivityNotFoundException
import android.content.res.Resources import android.content.res.Resources
import java.io.FileNotFoundException
import java.net.SocketTimeoutException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import java.io.FileNotFoundException
import java.net.SocketTimeoutException
fun Throwable.getDisplayMessage(resources: Resources) = when (this) { fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
is ActivityNotFoundException,
is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported) is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found) is FileNotFoundException -> resources.getString(R.string.file_not_found)

View File

@@ -4,10 +4,10 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import kotlinx.coroutines.Deferred
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.utils.BufferedObserver import org.koitharu.kotatsu.utils.BufferedObserver
fun <T> LiveData<T?>.observeNotNull(owner: LifecycleOwner, observer: Observer<T>) { fun <T> LiveData<T?>.observeNotNull(owner: LifecycleOwner, observer: Observer<T>) {
@@ -18,6 +18,10 @@ fun <T> LiveData<T?>.observeNotNull(owner: LifecycleOwner, observer: Observer<T>
} }
} }
fun <T> LiveData<T>.requireValue(): T = checkNotNull(value) {
"LiveData value is null"
}
fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver<T>) { fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver<T>) {
var previous: T? = null var previous: T? = null
this.observe(owner) { this.observe(owner) {
@@ -26,6 +30,7 @@ fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: Buffere
} }
} }
@Deprecated("Use variant with default value")
fun <T> Flow<T>.asLiveDataDistinct( fun <T> Flow<T>.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext context: CoroutineContext = EmptyCoroutineContext
): LiveData<T> = liveData(context) { ): LiveData<T> = liveData(context) {
@@ -36,6 +41,10 @@ fun <T> Flow<T>.asLiveDataDistinct(
} }
} }
fun <T> StateFlow<T>.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext
): LiveData<T> = asLiveDataDistinct(context, value)
fun <T> Flow<T>.asLiveDataDistinct( fun <T> Flow<T>.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
defaultValue: T defaultValue: T

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -187,6 +187,21 @@
app:layout_constraintTop_toBottomOf="@id/textView_bookmarks" app:layout_constraintTop_toBottomOf="@id/textView_bookmarks"
tools:listitem="@layout/item_bookmark" /> tools:listitem="@layout/item_bookmark" />
<include
android:id="@+id/scrobbling_layout"
layout="@layout/layout_scrobbling_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks"
tools:visibility="visible" />
<TextView <TextView
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"

View File

@@ -191,6 +191,21 @@
app:layout_constraintTop_toBottomOf="@id/textView_bookmarks" app:layout_constraintTop_toBottomOf="@id/textView_bookmarks"
tools:listitem="@layout/item_bookmark" /> tools:listitem="@layout/item_bookmark" />
<include
android:id="@+id/scrobbling_layout"
layout="@layout/layout_scrobbling_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks"
tools:visibility="visible" />
<TextView <TextView
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"
@@ -204,7 +219,7 @@
android:textIsSelectable="true" android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks" app:layout_constraintTop_toBottomOf="@id/scrobbling_layout"
tools:ignore="UnusedAttribute" tools:ignore="UnusedAttribute"
tools:text="@tools:sample/lorem/random[250]" /> tools:text="@tools:sample/lorem/random[250]" />

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
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:id="@+id/scrobbling_layout"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:contentPadding="8dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="2dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="46dp"
android:layout_height="46dp"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:contentDescription="@null"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true"
android:layout_marginStart="8dp"
android:layout_toEndOf="@id/imageView_cover"
android:drawablePadding="8dp"
android:gravity="center_vertical"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
app:drawableTint="?colorControlNormal"
tools:drawableEndCompat="@drawable/ic_shikimori"
tools:text="@string/tracking" />
<RatingBar
android:id="@+id/ratingBar"
style="@style/Widget.AppCompat.RatingBar.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/textView_title"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_toEndOf="@id/imageView_cover"
android:isIndicator="true"
android:max="1"
android:numStars="5" />
<TextView
android:id="@+id/textView_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@id/ratingBar"
android:layout_marginStart="4dp"
android:layout_marginBottom="-2dp"
android:layout_toEndOf="@id/ratingBar"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
tools:text="Reading" />
</RelativeLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
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">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:foreground="?selectableItemBackground"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="H,13:18"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.3"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
tools:background="@tools:sample/backgrounds/scenic"
tools:ignore="ContentDescription,UnusedAttribute" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="6dp"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
app:layout_constraintEnd_toStartOf="@id/button_open"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/lorem[15]" />
<ImageButton
android:id="@+id/button_open"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/open_in_browser"
android:padding="4dp"
android:src="@drawable/ic_open_external"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?android:colorControlNormal" />
<RatingBar
android:id="@+id/ratingBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="16dp"
android:numStars="5"
android:stepSize="0.5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_title"
tools:rating="3.5"
tools:text="@tools:sample/lorem[12]" />
<Spinner
android:id="@+id/spinner_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="16dp"
android:entries="@array/scrobbling_statuses"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/ratingBar" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_header"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="imageView_cover,spinner_status" />
<TextView
android:id="@+id/textView_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:lineSpacingMultiplier="1.2"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_header"
tools:ignore="UnusedAttribute"
tools:text="@tools:sample/lorem/random[250]" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -19,14 +19,14 @@
app:navigationIcon="?actionModeCloseDrawable" app:navigationIcon="?actionModeCloseDrawable"
app:title="@string/tracking"> app:title="@string/tracking">
<ImageView <Button
android:id="@+id/imageView_user" android:id="@+id/button_done"
android:layout_width="?attr/actionBarSize" style="@style/Widget.Material3.Button.UnelevatedButton"
android:layout_height="?attr/actionBarSize" android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end" android:layout_gravity="end"
android:scaleType="centerInside" android:layout_marginEnd="4dp"
android:background="?actionBarItemBackground" android:text="@string/done" />
tools:src="@tools:sample/avatars" />
</com.google.android.material.appbar.MaterialToolbar> </com.google.android.material.appbar.MaterialToolbar>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search"
android:icon="?actionModeWebSearchDrawable"
android:title="@string/search"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="ifRoom|collapseActionView" />
</menu>

View File

@@ -45,4 +45,12 @@
<item>@string/right_to_left</item> <item>@string/right_to_left</item>
<item>@string/webtoon</item> <item>@string/webtoon</item>
</string-array> </string-array>
<string-array name="scrobbling_statuses">
<item>@string/status_planned</item>
<item>@string/status_reading</item>
<item>@string/status_re_reading</item>
<item>@string/status_completed</item>
<item>@string/status_on_hold</item>
<item>@string/status_dropped</item>
</string-array>
</resources> </resources>

View File

@@ -271,7 +271,6 @@
<string name="removal_completed">Removal completed</string> <string name="removal_completed">Removal completed</string>
<string name="batch_manga_save_confirm">Are you sure you want to download all selected manga with all its chapters? This action can consume a lot of traffic and storage</string> <string name="batch_manga_save_confirm">Are you sure you want to download all selected manga with all its chapters? This action can consume a lot of traffic and storage</string>
<string name="shikimori" translatable="false">Shikimori</string> <string name="shikimori" translatable="false">Shikimori</string>
<string name="shikimori_info">Sign in into your Shikimori account to get more features</string>
<string name="parallel_downloads">Parallel downloads</string> <string name="parallel_downloads">Parallel downloads</string>
<string name="download_slowdown">Download slowdown</string> <string name="download_slowdown">Download slowdown</string>
<string name="download_slowdown_summary">Helps avoid blocking your IP address</string> <string name="download_slowdown_summary">Helps avoid blocking your IP address</string>
@@ -304,4 +303,10 @@
<string name="disable_battery_optimization_summary">Helps with background updates checks</string> <string name="disable_battery_optimization_summary">Helps with background updates checks</string>
<string name="crash_text">Something went wrong. Please submit a bug report to the developers to help us fix it.</string> <string name="crash_text">Something went wrong. Please submit a bug report to the developers to help us fix it.</string>
<string name="send">Send</string> <string name="send">Send</string>
<string name="status_planned">Planned</string>
<string name="status_reading">Reading</string>
<string name="status_re_reading">Re-reading</string>
<string name="status_completed">Completed</string>
<string name="status_on_hold">On hold</string>
<string name="status_dropped">Dropped</string>
</resources> </resources>

View File

@@ -13,11 +13,6 @@
android:key="suggestions" android:key="suggestions"
android:title="@string/suggestions" /> android:title="@string/suggestions" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsFragment"
android:title="@string/shikimori"
android:key="shikimori" />
<ListPreference <ListPreference
android:entries="@array/doh_providers" android:entries="@array/doh_providers"
android:key="doh" android:key="doh"

View File

@@ -39,4 +39,13 @@
android:persistent="false" android:persistent="false"
android:title="@string/clear_cookies" /> android:title="@string/clear_cookies" />
<PreferenceCategory android:title="@string/tracking">
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment"
android:key="shikimori"
android:title="@string/shikimori" />
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>