From ec89ba0155dcbd69f4136555f6a5fa7d1b8bfdc2 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 22 Jun 2022 12:46:48 +0300 Subject: [PATCH] Shikimori interaction implementation --- .../java/org/koitharu/kotatsu/KotatsuApp.kt | 2 +- .../koitharu/kotatsu/core/db/MangaDatabase.kt | 18 +- .../core/db/migrations/Migration11To12.kt | 25 +++ .../koitharu/kotatsu/details/DetailsModule.kt | 2 +- .../kotatsu/details/ui/DetailsActivity.kt | 6 +- .../kotatsu/details/ui/DetailsFragment.kt | 33 ++- .../kotatsu/details/ui/DetailsViewModel.kt | 23 +- .../scrobbling/ScrobblingInfoBottomSheet.kt | 119 +++++++++++ .../koitharu/kotatsu/history/HistoryModule.kt | 2 +- .../history/domain/HistoryRepository.kt | 7 + .../kotatsu/scrobbling/data/ScrobblingDao.kt | 20 ++ .../scrobbling/data/ScrobblingEntity.kt | 35 ++++ .../kotatsu/scrobbling/domain/Scrobbler.kt | 78 +++++++ .../domain/model/ScrobblerManga.kt} | 19 +- .../domain/model/ScrobblerMangaInfo.kt | 9 + .../domain/model/ScrobblerService.kt | 14 ++ .../scrobbling/domain/model/ScrobblingInfo.kt | 52 +++++ .../domain/model/ScrobblingStatus.kt | 11 + .../scrobbling/shikimori/ShikimoriModule.kt | 32 +++ .../shikimori/data/ShikimoriAuthenticator.kt | 2 +- .../shikimori/data/ShikimoriInterceptor.kt | 9 +- .../shikimori/data/ShikimoriRepository.kt | 196 ++++++++++++++++++ .../shikimori/data/ShikimoriStorage.kt | 4 +- .../shikimori/data/model/ShikimoriUser.kt | 2 +- .../shikimori/domain/ShikimoriScrobbler.kt | 64 ++++++ .../shikimori/ui/ShikimoriSettingsFragment.kt | 4 +- .../ui/ShikimoriSettingsViewModel.kt | 10 +- .../selector/ScrobblingSelectorBottomSheet.kt | 155 ++++++++++++++ .../selector/ScrobblingSelectorViewModel.kt} | 57 +++-- .../adapter/ShikiMangaSelectionDecoration.kt | 6 +- .../ui/selector/adapter/ShikimoriMangaAD.kt | 8 +- .../adapter/ShikimoriSelectorAdapter.kt | 10 +- .../settings/ContentSettingsFragment.kt | 32 +-- .../settings/HistorySettingsFragment.kt | 35 ++++ .../kotatsu/settings/SettingsActivity.kt | 2 +- .../kotatsu/shikimori/ShikimoriModule.kt | 28 --- .../shikimori/data/ShikimoriRepository.kt | 145 ------------- .../data/model/ShikimoriMangaInfo.kt | 20 -- .../selector/ShikimoriSelectorBottomSheet.kt | 106 ---------- .../kotatsu/utils/ext/CollectionExt.kt | 9 + .../koitharu/kotatsu/utils/ext/CommonExt.kt | 6 +- .../koitharu/kotatsu/utils/ext/LiveDataExt.kt | 11 +- .../main/res/drawable-hdpi/ic_shikimori.png | Bin 0 -> 1361 bytes .../main/res/drawable-mdpi/ic_shikimori.png | Bin 0 -> 723 bytes .../main/res/drawable-xhdpi/ic_shikimori.png | Bin 0 -> 1695 bytes .../main/res/drawable-xxhdpi/ic_shikimori.png | Bin 0 -> 3177 bytes .../res/drawable-xxxhdpi/ic_shikimori.png | Bin 0 -> 3743 bytes .../res/layout-w600dp/fragment_details.xml | 15 ++ app/src/main/res/layout/fragment_details.xml | 17 +- .../res/layout/layout_scrobbling_info.xml | 68 ++++++ app/src/main/res/layout/sheet_scrobbling.xml | 113 ++++++++++ ...ctor.xml => sheet_scrobbling_selector.xml} | 14 +- app/src/main/res/menu/opt_shiki_selector.xml | 13 ++ app/src/main/res/values/arrays.xml | 8 + app/src/main/res/values/strings.xml | 7 +- app/src/main/res/xml/pref_content.xml | 5 - app/src/main/res/xml/pref_history.xml | 9 + 57 files changed, 1280 insertions(+), 417 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt rename app/src/main/java/org/koitharu/kotatsu/{shikimori/data/model/ShikimoriManga.kt => scrobbling/domain/model/ScrobblerManga.kt} (57%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/data/ShikimoriAuthenticator.kt (96%) rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/data/ShikimoriInterceptor.kt (65%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/data/ShikimoriStorage.kt (87%) rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/data/model/ShikimoriUser.kt (93%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/ui/ShikimoriSettingsFragment.kt (95%) rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/ui/ShikimoriSettingsViewModel.kt (76%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt rename app/src/main/java/org/koitharu/kotatsu/{shikimori/ui/selector/ShikimoriSelectorViewModel.kt => scrobbling/ui/selector/ScrobblingSelectorViewModel.kt} (57%) rename app/src/main/java/org/koitharu/kotatsu/{shikimori => scrobbling}/ui/selector/adapter/ShikiMangaSelectionDecoration.kt (87%) rename app/src/main/java/org/koitharu/kotatsu/{shikimori => scrobbling}/ui/selector/adapter/ShikimoriMangaAD.kt (85%) rename app/src/main/java/org/koitharu/kotatsu/{shikimori => scrobbling}/ui/selector/adapter/ShikimoriSelectorAdapter.kt (82%) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt create mode 100644 app/src/main/res/drawable-hdpi/ic_shikimori.png create mode 100644 app/src/main/res/drawable-mdpi/ic_shikimori.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_shikimori.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_shikimori.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_shikimori.png create mode 100644 app/src/main/res/layout/layout_scrobbling_info.xml create mode 100644 app/src/main/res/layout/sheet_scrobbling.xml rename app/src/main/res/layout/{sheet_shiki_selector.xml => sheet_scrobbling_selector.xml} (81%) create mode 100644 app/src/main/res/menu/opt_shiki_selector.xml 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 0000000000000000000000000000000000000000..28bf0507f04337711bd3c6f286f43260602aa8b9 GIT binary patch literal 1361 zcmV-X1+MyuP))3Hz$B1LPJ%Yj z4qC&dL44MHySTp;ronr#GfPh_g6rX%h6tJ&9=e5oIqXP#I9$_$$Arb~MFB0~1ULmwhx6f*a8Kv= zB`aE*h4hi%!W+FyzX;ZY7Sduy%{GXUl%uuemO^_&SC|7EsuiT;8WmBjq3Yr~q@k$$!BAW*7F&owW4KN=od!n)J4s$+!}-_*oDkLQIPYP$Nmo2 zzo%EYVpCg|Q$NAzJE16gTwFmepg67PcDk?*t|KePRujf4a2~9KibR>U;CT!l2>rrD zPHxl8Zi|yG0~4m(s31|y9?(+yqNzcrKNd_KH^I5#*&+15hk5I}ygU?3hg)Z8yMPPn z)Jgx*XUdyn+kToZ)wv)NoVwVL)(STUFIiC$(hg% z%tc10y>qy@Wykxk!o5(EAkzey>@>R*#b#c)8hU}@>cnlqMUV9_Trh_Nf ziGOR^gn4q|d-C_yR5fGqb%G&?lmawVaFFS7Yhqy79NCh+E;sc%ghE3#nrM zZS$=sf6N&=Won z{nPaNi!Z6qTnjgbYg*PhbZ{#`T`RYcj`t>X2YtZY@@?py_;GkL{2k}8Hx(#I3k`?q z&;cd|0nRxBJX5*#L*Z1I0Y+hcOWS)-extyn#$vGUbb-O}80d)QEebLhy#hCb^LSzy z32VS3$6G1SmRzdV88x+UrFC8^n^Z@)9B3hZ#boKrc+|KXCWE2F}K{>rd8cOqv;WFi*nEB4H2Yc{s{WWCyCeW%x{k=)-9Xmpg$}D z>*4Hh-C!9X1ScLZ{aLLbPk2tOqu0V~VPTIK<$~Ov+u>~(1a7mAbb@DO?GUg7qvW zQfr`FWVo9G%+^}eFP7M>k^12aQ1N}1C&;k1&X{|2*muJ374$}#_pRtgp9?F>d}FbU zUlg8$EDgStTN|61{s!<1q-5*z&O?_ocs`g}J-3+P-1}tMn%UhhMSEt_&y23*A|}WI za36T^i~jTgzYUa<<7}3oRt$U@{6a9q^_Pz!?O^U4e(LB;`bF9p9o`q50DcAgjHgla zTFLkM9WN9%T3&}HA-LpkbU9iS~fBUeYBAXrrVyh}N THtKp&#f{PdgG;!R{h~OH9_mb$A3<;1)cG zcLmnBU~V$Jg*!z8`f)X)i72O{cI}Y>zM?m)&a4WPWoCmYV<_UD$Q?S~{Ls_?JV#om=8o|4%8<_9LU|dST z>b3BUSrZuaB+W~o<+;e?KZ8!-)v*{Bgt6DPSA(LiH@i$@^WFqFl~|Z3Ub9Z4bc{>bg?PI)T}+0Zh5=X=`}4PP`V@g9T;tE>dhBC~i_X z&J@tHWpEh$Rr?+Zoe literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..69ab19103640fcd67b7ba7000f547282004f29ae GIT binary patch literal 1695 zcmV;Q24MM#P)Lgy zy)pN`?>y|2=~_GcX68oC!XJ?>lsOa>^_M zGr(W)8~hHo{U`hq>is-CZ`DMO0*h1Q>To371dqTt_@<<#NqDBhbHj?T4XgmO!jwS) zJ1tRxKluGCya%VkHQ=)>$x*-&+%m8$*Ga)qd^a>|;|suKFgrNXIrUa`@F8C80k7m} z(59j7sBjD{7CPIeY))U&ZycOu;k%}SZ96d?6)q1O!49xQkE31HKWBA4c(fl1Y)&Pc z!Ts!ko4KIpgn3{=n7=|<2h3Thl{y6$rsOl=BHkDt0Nr4}#rjodAy@@=0zKHX z;o4aNZTyNbI{NJ^;Y@fHypR68-wHXX*THL`>+Y<;ev}+rDD4dV0H47J@G&^m?}L8a z>nG6bOgRH@!MD9Auy0VHX`K|?FtXkelz4)xeqBX>gczWlL4%p=>F1_S*1fOmoWbh` zFmQDdXTc8JS#jaVnX&W!*3Ul)H^YnInrk(CbqXvQD%U0UD$a^+Jr}OVlexkSJkRwF z*a~I=UG;--2{cCICiB>xO3ndYWRt{1O;`IAdKblRz_Nz)Jz+;M*Ga`jT(~d5-S9ZP3@&s>y^R<@OdPj>V_{nO0*n>b zS1LIQqy?>PHi(&}zS_mvRxEGAX6C6!D~jog17cea-UauGo}(0B(d`pmY}6&Oq8B^L&Gu@xZ--se=pCM%RIuGB|7VfeW;2esm9I zT`RUVM5+`pci9V!yfN>waqU#L2BWm1i*yu=>!a9KulFwgHDHLFEmOc9l|I=VWiYh5 zG*@#YZPJcI=MHyT|3{cbaRh0(uMc7hPGM zXp4SlpfT5RbYBORP-X;!SQN8Zqk9xn^xGj?fvL+ap#OLK>KfE!eDCbJH*ofbNYx@9 z6)JbC{l?BZ&35omxD|}1D?!@HwDO_?zVlg{VH&OVUW!g(=R*Gu;(jf-5cEX$Axi;2 z@+jpfSRdX2w~yv1Mp*r{TT2zFO1{bzIFRqpPI_=Bzc&o`dKulXt~pzB;(%+x>@gM1 z!H%2XRHeiEeN%x{ZsW#1c9d)Rp}8oUiD^qT!7L?m6ez363{qFB0?tO6Xyfh+QqkOW z({Yq9;9?hhp39SmwpfU#ByriNz8V@{LX_U4KcozB+q-n-D z?llV+vo6vZ(8dk>#roa+ECb_1&s-yAHgCFM0CRU&D{U1xi`ouIYP7RX`7vM$;u(>P zyQ*FjF}ID;%r@ZnT@~MZB%>0wfvt@K?g6fbn5AkCC&TFw(*ySrt_ky)q8O;`uLy*W@(C zAt`kFL5>32y9ZC3CLMB*dU?&jII_Ce^TG1xfu>hw4(^Zr`^rP!qIkIG-l0@-6!45* z8&@DPW}4HM&3cFIG?2?#md-UiI)#kP|?cZTl|dO35*y pUOO~F%TH8bf(~y+<->jk{sxNwV?Y;JblU&`002ovPDHLkV1il+HZuSK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f4002413e22b0f525f819e8435d2e3c88e6f10b1 GIT binary patch literal 3177 zcmV-v43_hWP) z1&l386$W4ycXyp-ahG7hW^oN65Q66|?(RA)4#71+b{7aPL4wPB*y8TGxVt<52VF_g zbl;ZS!&{Pjl0SXt-tOwEzfK*ingfopk(CS_u;FJl0~>+{G-N~2hM@I~$VP*9L4!`7 zHcuCPJNUzT^K4^GkG)D01rY}!0~uE!kec+c;D&iDHAir}rmj{=0wmN^6&>_vie z1{Vx&8=NXAZT&u?4t9b!2eUM3EBgA)f_>t}-R1q}1og0j|6 zme1Qwht8Pm@q)7kyx$p`=Xo-2eYZ7@4SG<(CQXtl1R3zHgL?&B_3MLo1wRdVsviX6 z5wTEP+)off`&~&=n&1I}po}40N3<<;sDaRe^1bC*fV@H|4LnUiOKB))VTmKL$T#!hzD>U;^>r*k zX7LHZT^m9^l!e=)=gr2Te>4$UG>Vp9FA(huM0cH_ljJ4TbSq$Vh4SAD z_6BbZjtqX${ImyL4e=+!Qpwxma6Ny~WJ=@4m z&JIP!5=3*J6&wT%F>`2id~jA1OJIYO~>y-4qjDoNV8q_ z?`grFKpNvaB)VTauMJ)vs1Z#v)>8&5w7*GkyWl*5z-+1@!OU9*>Ik7v_raKC#4T(nEqP1T_7yKpttr z24x9?24Awq4G0klBSjLbmSmvY0ZnC3OawbswvEapc`XM3HW`=%lesUTMZDjj``_OL z6^)RE(?TBdEP>JmZ)EBEUM+}*D%knHc&JjzL{>ow2M}Z#mwU89gV;mS`-20K{7RB` zi=geim<^}{H%pB+7@(b5B?%sgM>Kd^P)Zi1IEIMbs5a`0)W){Z2Qk?vV+eAO4MJ=7 zJ*ygPev>_0;|9xhQ*pCqHW5^+g~fIuV@y9ryn50t`XvFkYGLN-WVYv6@(}Q}zcz zb#aZ2DrE}Zs=WlI2Q;{yUOXq?-K^2pGBA-kkHyZF!xBTg9s2Uhz*g2|f*4QvkX?+q zq64K?gN$Od4dx>Oqc4F}VYFw(v(+L`lmAH(YO$4kpFrKYh-#(XjPnKZvF$*lFBRe; z+sdGCBK*4q%CVg?q(M*4Q+6^+F4Nae*Y}nO^+iCb(eN6M$0$FvPh22ltJRP_Hy)A3 z)N;lA@svOSSdYO@#v7U&c}Xe8Z2|`^kSUxf_nVYk1l)bE3=xEVhS@2OzI)9C+u((a zosPMeMi^Y_@Tz|Jt&1Q@ti3pctWy^KdQo6&yIz(r&EJ+@EtVXjEF*E3l|h5`OB}TF zXv`2nUQ}jFyijNDI;`wz$Kd^e?Ol6kl31N~;=}2)vC>75XSE@=5_Hz$U3U)ZvI|WW za&#rJqVwwp0#)8wd!Gx$L%lMFAP;W`cMmQch;b}AnK|uZ_5`mR=;;taBJx86%dm)qb#EZGsD&p}tG3+NmZb?&TiFR1X~GVOjTGEg z$q+$uCd508S%jSv8_Bn7p}9Pwcdi`|Elp4z2kEqYO#Szo0R!xFmkbePgAqZ`tRA+j zDk2uSw?&F#iQO_#@0l%25+tH!6J*qNHBn9JD#6_Xb(iM_A8$Hdj_yV@9WO*rq7|It1;*WNl(hcbcPsEkWl|;_Sb2`Hy zXjuIZG40NB^Cp0X)}l_Nl+ia1lTpq{OH8|>|4w4cmrD?2)@guPrWohaz78pa+%Pa0 zaS>Z`vA_};BV~}5sC=!Km*o#9Sy#1XV^Pi~jL?@!vMxcMrx8yL*q*j9meK(p#XxC} z98GqvHp3GQ2@TZ#qk^^uI7DGbg3Dhu7S%>4I}Q(o9;0Plg6xFuGz7F~(ZG~LbgcGX z>=nbRmSgs7o7L{P+^ZC}EYY^mScY4jXQ=9_Y$8vTwv0YavJOFt?3R_jh-=*m z*5>nk+7ldwN?#CUk+}FQf=(>Y?4{!l_Lk)$qiEp zVicX&q9wI)WoOiZ%=#$R?dY6vp_EVMUaJm9^^MKaXWRUbhRLFxZ4q3`_DB8i*c5`S zZ_Eq!45U13Ane2uL7zb$Gg(XCWAcz-(%PD&ugbFRU6RiCk0r>=aLGKAI*et&3$Qt2 z6t&?g1^Ok)=xb{$Kgh-G%oee9{oj~fm_@x9?Rj=!CsPJ(R-_1f17*-f%9{(bX7cTk ziQ=6`VI$c`G1=hJyV94j8nkfV|9_g>5Hv7X8-g|jt+$*v8nn@%^)%?8T7#iyt%6v) P00000NkvXXu0mjfE&2FM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cd989aec44321ba67229c990f2e31410e9d56293 GIT binary patch literal 3743 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 JPDHLkV1h@~7~%i` literal 0 HcmV?d00001 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