diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index bc98a2e45..0ab9b2d1e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -31,9 +31,9 @@ import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.reader.readerModule import org.koitharu.kotatsu.remotelist.remoteListModule +import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule import org.koitharu.kotatsu.search.searchModule import org.koitharu.kotatsu.settings.settingsModule -import org.koitharu.kotatsu.shikimori.shikimoriModule import org.koitharu.kotatsu.suggestions.suggestionsModule import org.koitharu.kotatsu.tracker.trackerModule import org.koitharu.kotatsu.widget.WidgetUpdater diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 2c74455f8..82d5052aa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -6,8 +6,14 @@ import androidx.room.Room import androidx.room.RoomDatabase import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarksDao -import org.koitharu.kotatsu.core.db.dao.* -import org.koitharu.kotatsu.core.db.entity.* +import org.koitharu.kotatsu.core.db.dao.MangaDao +import org.koitharu.kotatsu.core.db.dao.PreferencesDao +import org.koitharu.kotatsu.core.db.dao.TagsDao +import org.koitharu.kotatsu.core.db.dao.TrackLogsDao +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity +import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.migrations.* import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity @@ -15,6 +21,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouritesDao import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryEntity +import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao +import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity import org.koitharu.kotatsu.suggestions.data.SuggestionDao import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.tracker.data.TrackEntity @@ -26,8 +34,9 @@ import org.koitharu.kotatsu.tracker.data.TracksDao MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, + ScrobblingEntity::class, ], - version = 11, + version = 12, ) abstract class MangaDatabase : RoomDatabase() { @@ -50,6 +59,8 @@ abstract class MangaDatabase : RoomDatabase() { abstract val suggestionDao: SuggestionDao abstract val bookmarksDao: BookmarksDao + + abstract val scrobblingDao: ScrobblingDao } fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( @@ -67,6 +78,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( Migration8To9(), Migration9To10(), Migration10To11(), + Migration11To12(), ).addCallback( DatabasePrePopulateCallback(context.resources) ).build() \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt new file mode 100644 index 000000000..8c3f2800f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt @@ -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() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt index 916b75de1..88b40e2be 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt @@ -8,6 +8,6 @@ val detailsModule get() = module { viewModel { intent -> - DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get()) + DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 46a6aff01..44ee1e7ff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -42,8 +42,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity -import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorBottomSheet import org.koitharu.kotatsu.utils.ext.getDisplayMessage class DetailsActivity : @@ -156,7 +156,7 @@ class DetailsActivity : menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this) - menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isShikimoriAvailable + menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable return super.onPrepareOptionsMenu(menu) } @@ -199,7 +199,7 @@ class DetailsActivity : } R.id.action_shiki_track -> { viewModel.manga.value?.let { - ShikimoriSelectorBottomSheet.show(supportFragmentManager, it) + ScrobblingSelectorBottomSheet.show(supportFragmentManager, it) } true } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index ab29930dc..c54b58391 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -17,6 +17,7 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader import coil.request.ImageRequest +import coil.size.Scale import coil.util.CoilUtils import com.google.android.material.chip.Chip import kotlinx.coroutines.launch @@ -31,6 +32,7 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.databinding.FragmentDetailsBinding +import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.image.ui.ImageActivity 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.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.SearchActivity -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.* @@ -68,6 +70,7 @@ class DetailsFragment : binding.buttonRead.setOnClickListener(this) binding.buttonRead.setOnLongClickListener(this) binding.imageViewCover.setOnClickListener(this) + binding.scrobblingLayout.root.setOnClickListener(this) binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.chipsTags.onChipClickListener = this viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) @@ -75,6 +78,7 @@ class DetailsFragment : viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) + viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) addMenuProvider(DetailsMenuProvider()) } @@ -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) { val manga = viewModel.manga.value ?: return when (v.id) { R.id.button_favorite -> { FavouriteCategoriesBottomSheet.show(childFragmentManager, manga) } + R.id.scrobbling_layout -> { + ScrobblingInfoBottomSheet.show(childFragmentManager) + } R.id.button_read -> { val chapterId = viewModel.readingHistory.value?.chapterId if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 05b9537b6..42e0ec024 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -26,7 +26,8 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.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.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct @@ -41,7 +42,7 @@ class DetailsViewModel( mangaDataRepository: MangaDataRepository, private val bookmarksRepository: BookmarksRepository, private val settings: AppSettings, - private val shikimoriRepository: ShikimoriRepository, + private val scrobbler: Scrobbler, ) : BaseViewModel() { private val delegate = MangaDetailsDelegate( @@ -81,8 +82,11 @@ class DetailsViewModel( }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) val onMangaRemoved = SingleLiveEvent() - val isShikimoriAvailable: Boolean - get() = shikimoriRepository.isAuthorized + val isScrobblingAvailable: Boolean + get() = scrobbler.isAvailable + + val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId) + .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) val branches: LiveData> = delegate.manga.map { val chapters = it?.chapters ?: return@map emptyList() @@ -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) { delegate.doLoad() } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt new file mode 100644 index 000000000..dd68d16ab --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt @@ -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(), + AdapterView.OnItemSelectedListener, + RatingBar.OnRatingBarChangeListener, + View.OnClickListener { + + private val viewModel by sharedViewModel() + private val coil by inject(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().getOrNull(position), + ) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + + override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) { + if (fromUser) { + viewModel.updateScrobbling( + rating = rating / ratingBar.numStars, + status = enumValues().getOrNull(binding.spinnerStatus.selectedItemPosition), + ) + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt index 246cb3a5f..e155bb4eb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt @@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel val historyModule get() = module { - factory { HistoryRepository(get(), get(), get()) } + factory { HistoryRepository(get(), get(), get(), getAll()) } viewModel { HistoryListViewModel(get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt index 4519b60e4..024b9e9e3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -13,6 +13,8 @@ import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.toMangaHistory import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.tryScrobble import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.ext.mapItems @@ -20,6 +22,7 @@ class HistoryRepository( private val db: MangaDatabase, private val trackingRepository: TrackingRepository, private val settings: AppSettings, + private val scrobblers: List, ) { suspend fun getList(offset: Int, limit: Int = 20): List { @@ -78,6 +81,10 @@ class HistoryRepository( ) ) trackingRepository.syncWithHistory(manga, chapterId) + val chapter = manga.chapters?.find { x -> x.id == chapterId } + if (chapter != null) { + scrobblers.forEach { it.tryScrobble(manga.id, chapter) } + } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt new file mode 100644 index 000000000..38b798b94 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt @@ -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 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(entity: ScrobblingEntity) + + @Update + abstract suspend fun update(entity: ScrobblingEntity) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt new file mode 100644 index 000000000..dc4e02d8e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt @@ -0,0 +1,35 @@ +package org.koitharu.kotatsu.scrobbling.data + +import androidx.room.ColumnInfo +import androidx.room.Entity + +@Entity( + tableName = "scrobblings", + primaryKeys = ["scrobbler", "id", "manga_id"], +) +class ScrobblingEntity( + @ColumnInfo(name = "scrobbler") val scrobbler: Int, + @ColumnInfo(name = "id") val id: Int, + @ColumnInfo(name = "manga_id") val mangaId: Long, + @ColumnInfo(name = "target_id") val targetId: Long, + @ColumnInfo(name = "status") val status: String?, + @ColumnInfo(name = "chapter") val chapter: Int, + @ColumnInfo(name = "comment") val comment: String?, + @ColumnInfo(name = "rating") val rating: Float, +) { + + fun copy( + status: String?, + comment: String?, + rating: Float, + ) = ScrobblingEntity( + scrobbler = scrobbler, + id = id, + mangaId = mangaId, + targetId = targetId, + status = status, + chapter = chapter, + comment = comment, + rating = rating, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt new file mode 100644 index 000000000..5f7e42cc8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt @@ -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() + protected val statuses = EnumMap(ScrobblingStatus::class.java) + + abstract val isAvailable: Boolean + + abstract suspend fun findManga(query: String, offset: Int): List + + abstract suspend fun linkManga(mangaId: Long, targetId: Long) + + abstract suspend fun scrobble(mangaId: Long, chapter: MangaChapter) + + suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null + return entity.toScrobblingInfo(mangaId) + } + + abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?) + + fun observeScrobblingInfo(mangaId: Long): Flow { + return db.scrobblingDao.observe(scrobblerService.id, mangaId) + .map { it?.toScrobblingInfo(mangaId) } + } + + protected abstract suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo + + private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? { + val mangaInfo = infoCache.getOrElse(targetId) { + runCatching { + getMangaInfo(targetId) + }.onFailure { + it.printStackTraceDebug() + }.onSuccess { + infoCache.put(targetId, it) + }.getOrNull() ?: return null + } + return ScrobblingInfo( + scrobbler = scrobblerService, + mangaId = mangaId, + targetId = targetId, + status = statuses.findKey(status), + chapter = chapter, + comment = comment, + rating = rating, + title = mangaInfo.name, + coverUrl = mangaInfo.cover, + description = mangaInfo.descriptionHtml.parseAsHtml(), + externalUrl = mangaInfo.url, + ) + } +} + +suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean { + return runCatching { + scrobble(mangaId, chapter) + }.onFailure { + it.printStackTraceDebug() + }.isSuccess +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt similarity index 57% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt index 0369ea255..9e28c9d7d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt @@ -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.parsers.util.json.getStringOrNull -import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl -class ShikimoriManga( +class ScrobblerManga( val id: Long, val name: String, val altName: String?, @@ -13,19 +10,11 @@ class ShikimoriManga( val url: String, ) : 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 { if (this === other) return true if (javaClass != other?.javaClass) return false - other as ShikimoriManga + other as ScrobblerManga if (id != other.id) return false if (name != other.name) return false @@ -46,6 +35,6 @@ class ShikimoriManga( } override fun toString(): String { - return "ShikimoriManga #$id \"$name\" $url" + return "ScrobblerManga #$id \"$name\" $url" } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt new file mode 100644 index 000000000..940262041 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +class ScrobblerMangaInfo( + val id: Long, + val name: String, + val cover: String, + val url: String, + val descriptionHtml: String, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt new file mode 100644 index 000000000..45038ed12 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.koitharu.kotatsu.R + +enum class ScrobblerService( + val id: Int, + @StringRes val titleResId: Int, + @DrawableRes val iconResId: Int, +) { + + SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt new file mode 100644 index 000000000..87393d6ec --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +class ScrobblingInfo( + val scrobbler: ScrobblerService, + val mangaId: Long, + val targetId: Long, + val status: ScrobblingStatus?, + val chapter: Int, + val comment: String?, + val rating: Float, + val title: String, + val coverUrl: String, + val description: CharSequence?, + val externalUrl: String, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ScrobblingInfo + + if (scrobbler != other.scrobbler) return false + if (mangaId != other.mangaId) return false + if (targetId != other.targetId) return false + if (status != other.status) return false + if (chapter != other.chapter) return false + if (comment != other.comment) return false + if (rating != other.rating) return false + if (title != other.title) return false + if (coverUrl != other.coverUrl) return false + if (description != other.description) return false + if (externalUrl != other.externalUrl) return false + + return true + } + + override fun hashCode(): Int { + var result = scrobbler.hashCode() + result = 31 * result + mangaId.hashCode() + result = 31 * result + targetId.hashCode() + result = 31 * result + (status?.hashCode() ?: 0) + result = 31 * result + chapter + result = 31 * result + (comment?.hashCode() ?: 0) + result = 31 * result + rating.hashCode() + result = 31 * result + title.hashCode() + result = 31 * result + coverUrl.hashCode() + result = 31 * result + (description?.hashCode() ?: 0) + result = 31 * result + externalUrl.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt new file mode 100644 index 000000000..cfb408094 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +enum class ScrobblingStatus { + + PLANNED, + READING, + RE_READING, + COMPLETED, + ON_HOLD, + DROPPED, +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt new file mode 100644 index 000000000..ec3c65b57 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.scrobbling.shikimori + +import okhttp3.OkHttpClient +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriStorage +import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler +import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsViewModel +import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorViewModel + +val shikimoriModule + get() = module { + single { ShikimoriStorage(androidContext()) } + factory { + val okHttp = OkHttpClient.Builder().apply { + authenticator(ShikimoriAuthenticator(get(), ::get)) + addInterceptor(ShikimoriInterceptor(get())) + }.build() + ShikimoriRepository(okHttp, get(), get()) + } + factory { ShikimoriScrobbler(get(), get()) } bind Scrobbler::class + viewModel { params -> + ShikimoriSettingsViewModel(get(), params.getOrNull()) + } + viewModel { params -> ScrobblingSelectorViewModel(params[0], get()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt similarity index 96% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt index 6bf1381c2..8a94bf98a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.shikimori.data +package org.koitharu.kotatsu.scrobbling.shikimori.data import kotlinx.coroutines.runBlocking import okhttp3.Authenticator diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt similarity index 65% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt index 33ff454c3..f203f2e4c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt @@ -1,7 +1,8 @@ -package org.koitharu.kotatsu.shikimori.data +package org.koitharu.kotatsu.scrobbling.shikimori.data import okhttp3.Interceptor import okhttp3.Response +import okio.IOException import org.koitharu.kotatsu.core.network.CommonHeaders private const val USER_AGENT_SHIKIMORI = "Kotatsu" @@ -14,6 +15,10 @@ class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor storage.accessToken?.let { 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 } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt new file mode 100644 index 000000000..6d1c60549 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt @@ -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 { + val page = offset / MANGA_PAGE_SIZE + val pageOffset = offset % MANGA_PAGE_SIZE + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("mangas") + .addEncodedQueryParameter("page", (page + 1).toString()) + .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString()) + .addEncodedQueryParameter("censored", false.toString()) + .addQueryParameter("search", query) + .build() + val request = Request.Builder().url(url).get().build() + val response = okHttp.newCall(request).await().parseJsonArray() + val list = response.mapJSON { ScrobblerManga(it) } + return if (pageOffset != 0) list.drop(pageOffset) else list + } + + suspend fun createRate(mangaId: Long, shikiMangaId: Long) { + val user = getCachedUser() ?: loadUser() + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("target_id", shikiMangaId) + put("target_type", "Manga") + put("user_id", user.id) + } + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .build() + val request = Request.Builder().url(url).post(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + saveRate(response, mangaId) + } + + suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("chapters", chapter.number) + } + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .addPathSegment(rateId.toString()) + .build() + val request = Request.Builder().url(url).patch(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + saveRate(response, mangaId) + } + + suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("score", rating.toString()) + if (comment != null) { + put("text", comment) + } + if (status != null) { + put("status", status) + } + } + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .addPathSegment(rateId.toString()) + .build() + val request = Request.Builder().url(url).patch(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + saveRate(response, mangaId) + } + + suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + val request = Request.Builder() + .get() + .url("${BASE_URL}api/mangas/$id") + val response = okHttp.newCall(request.build()).await().parseJson() + return ScrobblerMangaInfo(response) + } + + private suspend fun saveRate(json: JSONObject, mangaId: Long) { + val entity = ScrobblingEntity( + scrobbler = ScrobblerService.SHIKIMORI.id, + id = json.getInt("id"), + mangaId = mangaId, + targetId = json.getLong("target_id"), + status = json.getString("status"), + chapter = json.getInt("chapters"), + comment = json.getString("text"), + rating = json.getDouble("score").toFloat() / 10f, + ) + db.scrobblingDao.insert(entity) + } + + private fun ScrobblerManga(json: JSONObject) = ScrobblerManga( + id = json.getLong("id"), + name = json.getString("name"), + altName = json.getStringOrNull("russian"), + cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), + url = json.getString("url").toAbsoluteUrl("shikimori.one"), + ) + + private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( + id = json.getLong("id"), + name = json.getString("name"), + cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), + url = json.getString("url").toAbsoluteUrl("shikimori.one"), + descriptionHtml = json.getString("description_html"), + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt similarity index 87% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt index 210432670..0dfe0421e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt @@ -1,9 +1,9 @@ -package org.koitharu.kotatsu.shikimori.data +package org.koitharu.kotatsu.scrobbling.shikimori.data import android.content.Context import androidx.core.content.edit 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 KEY_ACCESS_TOKEN = "access_token" diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt similarity index 93% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt index d42188a63..79ecfa6c5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.shikimori.data.model +package org.koitharu.kotatsu.scrobbling.shikimori.data.model import org.json.JSONObject diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt new file mode 100644 index 000000000..4bdb7db1f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt @@ -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 { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt similarity index 95% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt index aa7cbc6a4..10098a239 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.shikimori.ui +package org.koitharu.kotatsu.scrobbling.shikimori.ui import android.content.Intent import android.net.Uri @@ -13,7 +13,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser import org.koitharu.kotatsu.utils.PreferenceIconTarget import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.withArgs diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt similarity index 76% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt index 88e62f08d..ef8f73b85 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt @@ -1,10 +1,10 @@ -package org.koitharu.kotatsu.shikimori.ui +package org.koitharu.kotatsu.scrobbling.shikimori.ui import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.Dispatchers import org.koitharu.kotatsu.base.ui.BaseViewModel -import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser class ShikimoriSettingsViewModel( private val repository: ShikimoriRepository, @@ -34,7 +34,7 @@ class ShikimoriSettingsViewModel( private fun loadUser() = launchJob(Dispatchers.Default) { val userModel = if (repository.isAuthorized) { repository.getCachedUser()?.let(user::postValue) - repository.getUser() + repository.loadUser() } else { null } @@ -43,6 +43,6 @@ class ShikimoriSettingsViewModel( private fun authorize(code: String) = launchJob(Dispatchers.Default) { repository.authorize(code) - user.postValue(repository.getUser()) + user.postValue(repository.loadUser()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt new file mode 100644 index 000000000..276502ec7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt @@ -0,0 +1,155 @@ +package org.koitharu.kotatsu.scrobbling.ui.selector + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.* +import android.widget.Toast +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.FragmentManager +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikiMangaSelectionDecoration +import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter +import org.koitharu.kotatsu.utils.BottomSheetToolbarController +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.withArgs + +class ScrobblingSelectorBottomSheet : + BaseBottomSheet(), + OnListItemClickListener, + PaginationScrollListener.Callback, + View.OnClickListener, + MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener, + DialogInterface.OnKeyListener { + + private val viewModel by viewModel { + parametersOf(requireNotNull(requireArguments().getParcelable(MangaIntent.KEY_MANGA)).manga) + } + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding { + return SheetScrobblingSelectorBinding.inflate(inflater, container, false) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).also { + it.setOnKeyListener(this) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.toolbar.setNavigationOnClickListener { dismiss() } + addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) + val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this) + val decoration = ShikiMangaSelectionDecoration(view.context) + with(binding.recyclerView) { + adapter = listAdapter + addItemDecoration(decoration) + addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorBottomSheet)) + } + binding.buttonDone.setOnClickListener(this) + initOptionsMenu() + + viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } + viewModel.selectedItemId.observe(viewLifecycleOwner) { + decoration.checkedItemId = it + binding.recyclerView.invalidateItemDecorations() + } + viewModel.onError.observe(viewLifecycleOwner, ::onError) + viewModel.onClose.observe(viewLifecycleOwner) { + dismiss() + } + viewModel.searchQuery.observe(viewLifecycleOwner) { + binding.toolbar.subtitle = it + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_done -> viewModel.onDoneClick() + } + } + + override fun onItemClick(item: ScrobblerManga, view: View) { + viewModel.selectedItemId.value = item.id + } + + override fun onScrolledToEnd() { + viewModel.loadList(append = true) + } + + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + setExpanded(isExpanded = true, isLocked = true) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + val searchView = (item.actionView as? SearchView) ?: return false + searchView.setQuery("", false) + searchView.post { setExpanded(isExpanded = false, isLocked = false) } + return true + } + + override fun onQueryTextSubmit(query: String?): Boolean { + if (query == null || query.length < 3) { + return false + } + viewModel.search(query) + binding.toolbar.menu.findItem(R.id.action_search)?.collapseActionView() + return true + } + + override fun onQueryTextChange(newText: String?): Boolean = false + + override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + val menuItem = binding.toolbar.menu.findItem(R.id.action_search) ?: return false + if (menuItem.isActionViewExpanded) { + if (event?.action == KeyEvent.ACTION_UP) { + menuItem.collapseActionView() + } + return true + } + } + return false + } + + private fun onError(e: Throwable) { + Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() + if (viewModel.isEmpty) { + dismissAllowingStateLoss() + } + } + + private fun initOptionsMenu() { + binding.toolbar.inflateMenu(R.menu.opt_shiki_selector) + val searchMenuItem = binding.toolbar.menu.findItem(R.id.action_search) + searchMenuItem.setOnActionExpandListener(this) + val searchView = searchMenuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.setIconifiedByDefault(false) + searchView.queryHint = searchMenuItem.title + } + + companion object { + + private const val TAG = "ScrobblingSelectorBottomSheet" + + fun show(fm: FragmentManager, manga: Manga) = + ScrobblingSelectorBottomSheet().withArgs(1) { + putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false)) + }.show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt similarity index 57% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt index 4488d0875..2c881b23b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt @@ -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.MutableLiveData -import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope -import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job 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.LoadingState import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -class ShikimoriSelectorViewModel( +class ScrobblingSelectorViewModel( val manga: Manga, - private val repository: ShikimoriRepository, + private val scrobbler: Scrobbler, ) : BaseViewModel() { - private val shikiMangaList = MutableStateFlow?>(null) + private val shikiMangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private var loadingJob: Job? = null + private var doneJob: Job? = null val content: LiveData> = combine( shikiMangaList.filterNotNull(), @@ -39,17 +40,29 @@ class ShikimoriSelectorViewModel( } }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) - val selectedItemId = MutableLiveData(RecyclerView.NO_ID) - - val avatar = liveData(viewModelScope.coroutineContext + Dispatchers.Default) { - emit(repository.getCachedUser()?.avatar) - emit(runCatching { repository.getUser().avatar }.getOrNull()) - } + val selectedItemId = MutableLiveData(NO_ID) + val searchQuery = MutableLiveData(manga.title) + val onClose = SingleLiveEvent() val isEmpty: Boolean get() = shikiMangaList.value.isNullOrEmpty() init { + launchJob(Dispatchers.Default) { + try { + val info = scrobbler.getScrobblingInfoOrNull(manga.id) + if (info != null) { + selectedItemId.postValue(info.targetId) + } + } finally { + loadList(append = false) + } + } + } + + fun search(query: String) { + loadingJob?.cancel() + searchQuery.value = query loadList(append = false) } @@ -62,7 +75,7 @@ class ShikimoriSelectorViewModel( } loadingJob = launchLoadingJob(Dispatchers.Default) { 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) { shikiMangaList.value = list } else if (list.isNotEmpty()) { @@ -71,4 +84,18 @@ class ShikimoriSelectorViewModel( hasNextPage.value = list.isNotEmpty() } } + + fun onDoneClick() { + if (doneJob?.isActive == true) { + return + } + val targetId = selectedItemId.value ?: NO_ID + if (targetId == NO_ID) { + onClose.call(Unit) + } + doneJob = launchJob(Dispatchers.Default) { + scrobbler.linkManga(manga.id, targetId) + onClose.postCall(Unit) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt similarity index 87% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt index 72f758bc3..3cd806a99 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt @@ -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.graphics.Canvas @@ -8,7 +8,7 @@ import android.view.View import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga import org.koitharu.kotatsu.utils.ext.getItem class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) { @@ -24,7 +24,7 @@ class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration override fun getItemId(parent: RecyclerView, child: View): Long { 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 } diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt similarity index 85% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt index 4685806c8..f786b5d95 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt @@ -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 coil.ImageLoader @@ -10,7 +10,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.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.newImageRequest import org.koitharu.kotatsu.utils.ext.textAndVisible @@ -18,8 +18,8 @@ import org.koitharu.kotatsu.utils.ext.textAndVisible fun shikimoriMangaAD( lifecycleOwner: LifecycleOwner, coil: ImageLoader, - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt similarity index 82% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt index 5457aa078..90c6af56b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt @@ -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.recyclerview.widget.DiffUtil import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import kotlin.jvm.internal.Intrinsics import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga -import kotlin.jvm.internal.Intrinsics +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga class ShikimoriSelectorAdapter( lifecycleOwner: LifecycleOwner, coil: ImageLoader, - clickListener: OnListItemClickListener, + clickListener: OnListItemClickListener, ) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { @@ -28,7 +28,7 @@ class ShikimoriSelectorAdapter( override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { return when { 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 } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index 011d7664e..ed9cae71e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -1,14 +1,10 @@ package org.koitharu.kotatsu.settings -import android.content.Intent import android.content.SharedPreferences -import android.net.Uri import android.os.Bundle import android.view.View import androidx.preference.ListPreference import androidx.preference.Preference -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import java.io.File import kotlinx.coroutines.launch 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.parsers.util.names 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.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.viewLifecycleScope @@ -31,7 +26,6 @@ class ContentSettingsFragment : StorageSelectDialog.OnStorageSelectListener { private val storageManager by inject() - private val shikimoriRepository by inject(mode = LazyThreadSafetyMode.NONE) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_content) @@ -55,12 +49,12 @@ class ContentSettingsFragment : ).names() setDefaultValueCompat(DoHProvider.NONE.name) } - bindRemoteSourcesSummary() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() + bindRemoteSourcesSummary() settings.subscribe(this) } @@ -96,14 +90,6 @@ class ContentSettingsFragment : .show() true } - AppSettings.KEY_SHIKIMORI -> { - if (!shikimoriRepository.isAuthorized) { - showShikimoriDialog() - true - } 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) } } - - 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() - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt index dfa8a7bd0..c4c5f46ba 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.settings +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.View import androidx.preference.Preference @@ -14,6 +16,7 @@ import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.FileSize @@ -25,6 +28,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach private val trackerRepo by inject(mode = LazyThreadSafetyMode.NONE) private val searchRepository by inject(mode = LazyThreadSafetyMode.NONE) private val storageManager by inject(mode = LazyThreadSafetyMode.NONE) + private val shikimoriRepository by inject(mode = LazyThreadSafetyMode.NONE) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_history) @@ -50,6 +54,11 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } } + override fun onResume() { + super.onResume() + bindShikimoriSummary() + } + override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_PAGES_CACHE_CLEAR -> { @@ -81,6 +90,14 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } true } + AppSettings.KEY_SHIKIMORI -> { + if (!shikimoriRepository.isAuthorized) { + launchShikimoriAuth() + true + } else { + super.onPreferenceTreeClick(preference) + } + } else -> super.onPreferenceTreeClick(preference) } } @@ -142,4 +159,22 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } }.show() } + + private fun bindShikimoriSummary() { + findPreference(AppSettings.KEY_SHIKIMORI)?.summary = if (shikimoriRepository.isAuthorized) { + getString(R.string.logged_in_as, shikimoriRepository.getCachedUser()?.nickname) + } else { + getString(R.string.disabled) + } + } + + private fun launchShikimoriAuth() { + runCatching { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(shikimoriRepository.oauthUrl) + startActivity(intent) + }.onFailure { + Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt index bce7c3c31..ea566d4bb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -23,7 +23,7 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsFragment +import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment import org.koitharu.kotatsu.utils.ext.isScrolledToTop class SettingsActivity : diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt deleted file mode 100644 index 64a2bda32..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt +++ /dev/null @@ -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()) } - } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt deleted file mode 100644 index 5accdf614..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt +++ /dev/null @@ -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 { - 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 { - 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 { - 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) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt deleted file mode 100644 index 22adad008..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt +++ /dev/null @@ -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"), - ) -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt deleted file mode 100644 index ed2cf4997..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt +++ /dev/null @@ -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(), - OnListItemClickListener, - PaginationScrollListener.Callback, - View.OnClickListener { - - private val viewModel by viewModel { - parametersOf(requireNotNull(requireArguments().getParcelable(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) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 0ab153da6..f66a6dc22 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -34,4 +34,13 @@ fun List.asArrayList(): ArrayList = if (this is ArrayList<*>) { this as ArrayList } else { ArrayList(this) +} + +fun Map.findKey(value: V): K? { + for ((k, v) in entries) { + if (v == value) { + return k + } + } + return null } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt index dc6974749..22f95e4a4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt @@ -1,18 +1,20 @@ package org.koitharu.kotatsu.utils.ext +import android.content.ActivityNotFoundException import android.content.res.Resources +import java.io.FileNotFoundException +import java.net.SocketTimeoutException import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException -import java.io.FileNotFoundException -import java.net.SocketTimeoutException fun Throwable.getDisplayMessage(resources: Resources) = when (this) { is AuthRequiredException -> resources.getString(R.string.auth_required) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required) + is ActivityNotFoundException, is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is FileNotFoundException -> resources.getString(R.string.file_not_found) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt index e3cd66b43..3ac39ee9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt @@ -4,10 +4,10 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.liveData -import kotlinx.coroutines.Deferred import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.utils.BufferedObserver fun LiveData.observeNotNull(owner: LifecycleOwner, observer: Observer) { @@ -18,6 +18,10 @@ fun LiveData.observeNotNull(owner: LifecycleOwner, observer: Observer } } +fun LiveData.requireValue(): T = checkNotNull(value) { + "LiveData value is null" +} + fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver) { var previous: T? = null this.observe(owner) { @@ -26,6 +30,7 @@ fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: Buffere } } +@Deprecated("Use variant with default value") fun Flow.asLiveDataDistinct( context: CoroutineContext = EmptyCoroutineContext ): LiveData = liveData(context) { @@ -36,6 +41,10 @@ fun Flow.asLiveDataDistinct( } } +fun StateFlow.asLiveDataDistinct( + context: CoroutineContext = EmptyCoroutineContext +): LiveData = asLiveDataDistinct(context, value) + fun Flow.asLiveDataDistinct( context: CoroutineContext = EmptyCoroutineContext, defaultValue: T diff --git a/app/src/main/res/drawable-hdpi/ic_shikimori.png b/app/src/main/res/drawable-hdpi/ic_shikimori.png new file mode 100644 index 000000000..28bf0507f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_shikimori.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_shikimori.png b/app/src/main/res/drawable-mdpi/ic_shikimori.png new file mode 100644 index 000000000..d16d155d9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_shikimori.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_shikimori.png b/app/src/main/res/drawable-xhdpi/ic_shikimori.png new file mode 100644 index 000000000..69ab19103 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_shikimori.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_shikimori.png b/app/src/main/res/drawable-xxhdpi/ic_shikimori.png new file mode 100644 index 000000000..f4002413e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_shikimori.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_shikimori.png b/app/src/main/res/drawable-xxxhdpi/ic_shikimori.png new file mode 100644 index 000000000..cd989aec4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_shikimori.png differ diff --git a/app/src/main/res/layout-w600dp/fragment_details.xml b/app/src/main/res/layout-w600dp/fragment_details.xml index 2131010e1..f00741b07 100644 --- a/app/src/main/res/layout-w600dp/fragment_details.xml +++ b/app/src/main/res/layout-w600dp/fragment_details.xml @@ -187,6 +187,21 @@ app:layout_constraintTop_toBottomOf="@id/textView_bookmarks" tools:listitem="@layout/item_bookmark" /> + + + + diff --git a/app/src/main/res/layout/layout_scrobbling_info.xml b/app/src/main/res/layout/layout_scrobbling_info.xml new file mode 100644 index 000000000..079308b0d --- /dev/null +++ b/app/src/main/res/layout/layout_scrobbling_info.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_scrobbling.xml b/app/src/main/res/layout/sheet_scrobbling.xml new file mode 100644 index 000000000..2894ecb5d --- /dev/null +++ b/app/src/main/res/layout/sheet_scrobbling.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_shiki_selector.xml b/app/src/main/res/layout/sheet_scrobbling_selector.xml similarity index 81% rename from app/src/main/res/layout/sheet_shiki_selector.xml rename to app/src/main/res/layout/sheet_scrobbling_selector.xml index 73ca60544..34a489cd1 100644 --- a/app/src/main/res/layout/sheet_shiki_selector.xml +++ b/app/src/main/res/layout/sheet_scrobbling_selector.xml @@ -19,14 +19,14 @@ app:navigationIcon="?actionModeCloseDrawable" app:title="@string/tracking"> - + android:layout_marginEnd="4dp" + android:text="@string/done" /> diff --git a/app/src/main/res/menu/opt_shiki_selector.xml b/app/src/main/res/menu/opt_shiki_selector.xml new file mode 100644 index 000000000..0f0d58fbd --- /dev/null +++ b/app/src/main/res/menu/opt_shiki_selector.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 176b70687..612506b1d 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -45,4 +45,12 @@ @string/right_to_left @string/webtoon + + @string/status_planned + @string/status_reading + @string/status_re_reading + @string/status_completed + @string/status_on_hold + @string/status_dropped + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e97191287..0ce4ecaee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,7 +271,6 @@ Removal completed Are you sure you want to download all selected manga with all its chapters? This action can consume a lot of traffic and storage Shikimori - Sign in into your Shikimori account to get more features Parallel downloads Download slowdown Helps avoid blocking your IP address @@ -304,4 +303,10 @@ Helps with background updates checks Something went wrong. Please submit a bug report to the developers to help us fix it. Send + Planned + Reading + Re-reading + Completed + On hold + Dropped \ No newline at end of file diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml index 65470e164..ffd9f261f 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -13,11 +13,6 @@ android:key="suggestions" android:title="@string/suggestions" /> - - + + + + + + \ No newline at end of file