From f61497ffd906087e353c2a01e0e6aec16fb108c6 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 9 May 2022 12:57:38 +0300 Subject: [PATCH] Shikimori manga tracking selection list --- app/build.gradle | 2 +- .../kotatsu/base/ui/BaseBottomSheet.kt | 9 ++ .../kotatsu/details/ui/DetailsActivity.kt | 20 ++-- .../kotatsu/details/ui/DetailsFragment.kt | 1 + .../kotatsu/details/ui/DetailsViewModel.kt | 20 +--- .../kotatsu/settings/SettingsActivity.kt | 6 ++ .../kotatsu/shikimori/ShikimoriModule.kt | 2 + .../shikimori/data/ShikimoriAuthenticator.kt | 8 +- .../shikimori/data/ShikimoriRepository.kt | 32 +++++- .../shikimori/data/model/ShikimoriManga.kt | 13 ++- .../shikimori/ui/ShikimoriSettingsFragment.kt | 4 +- .../selector/ShikimoriSelectorBottomSheet.kt | 97 +++++++++++++++++++ .../ui/selector/ShikimoriSelectorViewModel.kt | 69 +++++++++++++ .../ui/selector/adapter/ShikimoriMangaAD.kt | 52 ++++++++++ .../adapter/ShikimoriSelectorAdapter.kt | 40 ++++++++ .../org/koitharu/kotatsu/utils/ext/CoilExt.kt | 2 +- .../res/layout-w600dp/fragment_details.xml | 3 +- app/src/main/res/layout/fragment_details.xml | 3 +- .../main/res/layout/sheet_shiki_selector.xml | 46 +++++++++ app/src/main/res/menu/opt_details.xml | 6 ++ app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_content.xml | 12 +-- 24 files changed, 398 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt create mode 100644 app/src/main/res/layout/sheet_shiki_selector.xml diff --git a/app/build.gradle b/app/build.gradle index 913934c49..ddd401a15 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -66,7 +66,7 @@ android { } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) - implementation('com.github.nv95:kotatsu-parsers:b495e5e457') { + implementation('com.github.nv95:kotatsu-parsers:44e6842025') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt index 75503afc5..647cd93ae 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt @@ -49,6 +49,15 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() { } } + fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) { + val b = behavior ?: return + b.addBottomSheetCallback(callback) + val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet) + if (rootView != null) { + callback.onStateChanged(rootView, b.state) + } + } + protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index a1920bf80..04363de41 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 @@ -45,6 +45,7 @@ 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.search.ui.global.GlobalSearchActivity +import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorBottomSheet import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.getDisplayMessage @@ -151,14 +152,11 @@ class DetailsActivity : override fun onPrepareOptionsMenu(menu: Menu): Boolean { val manga = viewModel.manga.value - menu.findItem(R.id.action_save).isVisible = - manga?.source != null && manga.source != MangaSource.LOCAL - menu.findItem(R.id.action_delete).isVisible = - manga?.source == MangaSource.LOCAL - menu.findItem(R.id.action_browser).isVisible = - manga?.source != MangaSource.LOCAL - menu.findItem(R.id.action_shortcut).isVisible = - ShortcutManagerCompat.isRequestPinShortcutSupported(this) + menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL + menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL + menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL + menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this) + menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isShikimoriAvailable return super.onPrepareOptionsMenu(menu) } @@ -209,6 +207,12 @@ class DetailsActivity : } true } + R.id.action_shiki_track -> { + viewModel.manga.value?.let { + ShikimoriSelectorBottomSheet.show(supportFragmentManager, it) + } + true + } R.id.action_shortcut -> { viewModel.manga.value?.let { lifecycleScope.launch { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index 99c7319e3..7aa5d96a0 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 @@ -34,6 +34,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState 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.ext.* 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 2f242ead6..d5ab2e2c2 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 @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.details.ui import androidx.core.os.LocaleListCompat -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.asFlow import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope @@ -28,7 +27,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct @@ -90,7 +88,8 @@ class DetailsViewModel( .asLiveData(viewModelScope.coroutineContext) val onMangaRemoved = SingleLiveEvent() - val shikimoriInfo = MutableLiveData() + val isShikimoriAvailable: Boolean + get() = shikimoriRepository.isAuthorized val branches = mangaData.map { it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() @@ -216,7 +215,6 @@ class DetailsViewModel( }.onFailure { error -> if (BuildConfig.DEBUG) error.printStackTrace() }.getOrNull() - findShikimoriManga(manga) } private fun mapChapters( @@ -329,18 +327,4 @@ class DetailsViewModel( it.chapter.name.contains(query, ignoreCase = true) } } - - private fun findShikimoriManga(manga: Manga) { - if (!shikimoriRepository.isAuthorized) { - return - } - launchJob(Dispatchers.Default) { - val data = runCatching { - shikimoriRepository.findMangaInfo(manga) - }.getOrNull() - if (data != null) { - shikimoriInfo.postValue(data) - } - } - } } \ 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 75f20b8ff..bce7c3c31 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -121,6 +121,7 @@ class SettingsActivity : Intent.ACTION_VIEW -> handleUri(intent.data) ?: return ACTION_READER -> ReaderSettingsFragment() ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() + ACTION_SHIKIMORI -> ShikimoriSettingsFragment() ACTION_SOURCE -> SourceSettingsFragment.newInstance( intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL ) @@ -146,6 +147,7 @@ class SettingsActivity : private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" + private const val ACTION_SHIKIMORI = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SHIKIMORI_SETTINGS" private const val EXTRA_SOURCE = "source" private const val HOST_SHIKIMORI_AUTH = "shikimori-auth" @@ -156,6 +158,10 @@ class SettingsActivity : Intent(context, SettingsActivity::class.java) .setAction(ACTION_READER) + fun newShikimoriSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_SHIKIMORI) + fun newSuggestionsSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_SUGGESTIONS) diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt index 7011ad798..64a2bda32 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt @@ -9,6 +9,7 @@ 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 { @@ -23,4 +24,5 @@ val shikimoriModule 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/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt index b62d5bff0..6a73b5829 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt @@ -15,15 +15,15 @@ class ShikimoriAuthenticator( override fun authenticate(route: Route?, response: Response): Request? { val accessToken = storage.accessToken ?: return null if (!isRequestWithAccessToken(response)) { - return null; + return null } - synchronized (this) { + synchronized(this) { val newAccessToken = storage.accessToken ?: return null if (accessToken != newAccessToken) { - return newRequestWithAccessToken(response.request, newAccessToken); + return newRequestWithAccessToken(response.request, newAccessToken) } val updatedAccessToken = refreshAccessToken() ?: return null - return newRequestWithAccessToken(response.request, updatedAccessToken); + return newRequestWithAccessToken(response.request, updatedAccessToken) } } 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 index c4ae8d251..2cfca1505 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt @@ -1,11 +1,15 @@ package org.koitharu.kotatsu.shikimori.data import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.* +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 @@ -13,6 +17,8 @@ import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser 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/api/" +private const val MANGA_PAGE_SIZE = 10 class ShikimoriRepository( private val okHttp: OkHttpClient, @@ -53,16 +59,32 @@ class ShikimoriRepository( return ShikimoriUser(response) } + 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("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 findMangaInfo(manga: Manga): ShikimoriMangaInfo? { val q = manga.title.urlEncoded() val request = Request.Builder() .get() - .url("https://shikimori.one/api/mangas?limit=20&search=$q&censored=false") + .url("https://shikimori.one/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.minByOrNull { - it.name.levenshteinDistance(manga.title) - } ?: return null + 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) } diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt index 8ac0411fb..0369ea255 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt @@ -1,19 +1,24 @@ package org.koitharu.kotatsu.shikimori.data.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( val id: Long, val name: String, + val altName: String?, val cover: String, val url: String, -) { +) : ListModel { constructor(json: JSONObject) : this( id = json.getLong("id"), name = json.getString("name"), - cover = json.getJSONObject("image").getString("preview"), - url = json.getString("url"), + 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 { @@ -24,6 +29,7 @@ class ShikimoriManga( if (id != other.id) return false if (name != other.name) return false + if (altName != other.altName) return false if (cover != other.cover) return false if (url != other.url) return false @@ -33,6 +39,7 @@ class ShikimoriManga( override fun hashCode(): Int { var result = id.hashCode() result = 31 * result + name.hashCode() + result = 31 * result + altName.hashCode() result = 31 * result + cover.hashCode() result = 31 * result + url.hashCode() return result diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt index ed4e3a600..27fa2ed7a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt @@ -18,8 +18,6 @@ import org.koitharu.kotatsu.utils.PreferenceIconTarget import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.withArgs -private const val KEY_USER = "shiki_user" - class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { private val viewModel by viewModel { @@ -64,6 +62,8 @@ class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { companion object { + private const val KEY_USER = "shiki_user" + private const val ARG_AUTH_CODE = "auth_code" fun newInstance(authCode: String?) = ShikimoriSettingsFragment().withArgs(1) { 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 new file mode 100644 index 000000000..484c71aa3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt @@ -0,0 +1,97 @@ +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.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) + with(binding.recyclerView) { + adapter = listAdapter + addOnScrollListener(PaginationScrollListener(4, this@ShikimoriSelectorBottomSheet)) + } + binding.imageViewUser.setOnClickListener(this) + + viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } + 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) { + } + + 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/shikimori/ui/selector/ShikimoriSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt new file mode 100644 index 000000000..7e671f1e0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt @@ -0,0 +1,69 @@ +package org.koitharu.kotatsu.shikimori.ui.selector + +import androidx.lifecycle.LiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter +import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct + +class ShikimoriSelectorViewModel( + val manga: Manga, + private val repository: ShikimoriRepository, +) : BaseViewModel() { + + private val shikiMangaList = MutableStateFlow?>(null) + private val hasNextPage = MutableStateFlow(false) + private var loadingJob: Job? = null + + val content: LiveData> = combine( + shikiMangaList.filterNotNull(), + hasNextPage + ) { list, isHasNextPage -> + when { + list.isEmpty() -> listOf() + isHasNextPage -> list + LoadingFooter + else -> list + } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + + val avatar = liveData { + emit(runCatching { repository.getUser().avatar }.getOrNull()) + } + + val isEmpty: Boolean + get() = shikiMangaList.value.isNullOrEmpty() + + init { + loadList(append = false) + } + + fun loadList(append: Boolean) { + if (loadingJob?.isActive == true) { + return + } + if (append && !hasNextPage.value) { + return + } + loadingJob = launchLoadingJob(Dispatchers.Default) { + val offset = if (append) shikiMangaList.value?.size ?: 0 else 0 + val list = repository.findManga(manga.title, offset) + if (!append) { + shikiMangaList.value = list + } else if (list.isNotEmpty()) { + shikiMangaList.value = shikiMangaList.value?.plus(list) ?: list + } + hasNextPage.value = list.isNotEmpty() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt new file mode 100644 index 000000000..4685806c8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.shikimori.ui.selector.adapter + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import coil.request.Disposable +import coil.size.Scale +import coil.util.CoilUtils +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.ItemMangaListBinding +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.textAndVisible + +fun shikimoriMangaAD( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } +) { + + var imageRequest: Disposable? = null + + itemView.setOnClickListener { + clickListener.onItemClick(item, it) + } + + bind { + imageRequest?.dispose() + binding.textViewTitle.text = item.name + binding.textViewSubtitle.textAndVisible = item.altName + imageRequest = binding.imageViewCover.newImageRequest(item.cover) + .placeholder(R.drawable.ic_placeholder) + .fallback(R.drawable.ic_placeholder) + .error(R.drawable.ic_placeholder) + .scale(Scale.FILL) + .allowRgb565(true) + .lifecycle(lifecycleOwner) + .enqueueWith(coil) + } + + onViewRecycled { + imageRequest?.dispose() + imageRequest = null + CoilUtils.dispose(binding.imageViewCover) + binding.imageViewCover.setImageDrawable(null) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt new file mode 100644 index 000000000..5457aa078 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.shikimori.ui.selector.adapter + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.list.ui.adapter.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 + +class ShikimoriSelectorAdapter( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + clickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(loadingStateAD()) + .addDelegate(shikimoriMangaAD(lifecycleOwner, coil, clickListener)) + .addDelegate(loadingFooterAD()) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return when { + oldItem === newItem -> true + oldItem is ShikimoriManga && newItem is ShikimoriManga -> oldItem.id == newItem.id + else -> false + } + } + + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt index 53cf3bdb2..16416bf51 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt @@ -11,7 +11,7 @@ import com.google.android.material.progressindicator.BaseProgressIndicator import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener -fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context) +fun ImageView.newImageRequest(url: String?) = ImageRequest.Builder(context) .data(url) .crossfade(true) .target(this) diff --git a/app/src/main/res/layout-w600dp/fragment_details.xml b/app/src/main/res/layout-w600dp/fragment_details.xml index 936a0ac6c..17cb204f4 100644 --- a/app/src/main/res/layout-w600dp/fragment_details.xml +++ b/app/src/main/res/layout-w600dp/fragment_details.xml @@ -160,7 +160,7 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/opt_details.xml b/app/src/main/res/menu/opt_details.xml index 97236c2f0..d6cc9be85 100644 --- a/app/src/main/res/menu/opt_details.xml +++ b/app/src/main/res/menu/opt_details.xml @@ -17,6 +17,12 @@ android:visible="false" app:showAsAction="never" /> + + Название Изменить Изменить категорию + Отслеживание \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 282d15034..461b93d8b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -21,6 +21,8 @@ 16dp 2dp 12dp + 24dp + 32dp 124dp 4dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7199a5b89..aca50b55e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -289,4 +289,5 @@ Name Edit Edit category + Tracking \ 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 178cec046..0c0400bf2 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -11,9 +11,13 @@ + + - -