Improve scrobbling ui

This commit is contained in:
Koitharu
2023-02-01 20:21:20 +02:00
parent 205a2e10a5
commit fd26de7619
15 changed files with 166 additions and 97 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@
/.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/render.experimental.xml
/.idea/inspectionProfiles/
.DS_Store
/build
/captures

View File

@@ -1,17 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
<inspection_tool class="FillClass" enabled="true" level="INFORMATION" enabled_by_default="true">
<option name="withoutDefaultValues" value="true" />
</inspection_tool>
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="KotlinFunctionArgumentsHelper" enabled="true" level="INFORMATION" enabled_by_default="true">
<option name="withoutDefaultValues" value="true" />
</inspection_tool>
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

View File

@@ -9,13 +9,13 @@ import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.R as materialR
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
import org.koitharu.kotatsu.utils.ext.displayCompat
import com.google.android.material.R as materialR
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
@@ -27,6 +27,9 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
protected val behavior: BottomSheetBehavior<*>?
get() = (dialog as? BottomSheetDialog)?.behavior
val isExpanded: Boolean
get() = behavior?.state == BottomSheetBehavior.STATE_EXPANDED
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,

View File

@@ -42,7 +42,7 @@ class DetailsMenuProvider(
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(activity)
menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
@@ -60,11 +60,13 @@ class DetailsMenuProvider(
}
}
}
R.id.action_favourite -> {
viewModel.manga.value?.let {
FavouriteCategoriesBottomSheet.show(activity.supportFragmentManager, it)
}
}
R.id.action_delete -> {
val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(activity)
@@ -76,6 +78,7 @@ class DetailsMenuProvider(
.setNegativeButton(android.R.string.cancel, null)
.show()
}
R.id.action_save -> {
viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0
@@ -87,21 +90,25 @@ class DetailsMenuProvider(
}
}
}
R.id.action_browser -> {
viewModel.manga.value?.let {
activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.title))
}
}
R.id.action_related -> {
viewModel.manga.value?.let {
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title))
}
}
R.id.action_shiki_track -> {
R.id.action_scrobbling -> {
viewModel.manga.value?.let {
ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it)
ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it, null)
}
}
R.id.action_shortcut -> {
viewModel.manga.value?.let {
activity.lifecycleScope.launch {
@@ -112,6 +119,7 @@ class DetailsMenuProvider(
}
}
}
else -> return false
}
return true

View File

@@ -256,29 +256,24 @@ class DetailsViewModel @AssistedInject constructor(
}
}
fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
for (info in scrobblingInfo.value ?: return) {
val scrobbler = scrobblers.first { it.scrobblerService == info.scrobbler }
if (!scrobbler.isAvailable) continue
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
}
fun unregisterScrobbling() {
for (scrobbler in scrobblers) {
if (!scrobbler.isAvailable) continue
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId,
)
}
fun unregisterScrobbling(index: Int) {
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId,
)
}
}
@@ -315,6 +310,19 @@ class DetailsViewModel @AssistedInject constructor(
return spannable.trim()
}
private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value?.getOrNull(index)
val scrobbler = if (info != null) {
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable }
} else {
null
}
if (scrobbler == null) {
errorEvent.call(IllegalStateException("Scrobbler [$index] is not available"))
}
return scrobbler
}
@AssistedFactory
interface Factory {

View File

@@ -15,9 +15,7 @@ import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
@@ -26,7 +24,12 @@ import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.utils.ext.*
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.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class ScrobblingInfoBottomSheet :
@@ -41,6 +44,7 @@ class ScrobblingInfoBottomSheet :
@Inject
lateinit var coil: ImageLoader
private var menu: PopupMenu? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -78,6 +82,7 @@ class ScrobblingInfoBottomSheet :
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.updateScrobbling(
index = scrobblerIndex,
rating = binding.ratingBar.rating / binding.ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(position),
)
@@ -88,6 +93,7 @@ class ScrobblingInfoBottomSheet :
override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) {
if (fromUser) {
viewModel.updateScrobbling(
index = scrobblerIndex,
rating = rating / ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(binding.spinnerStatus.selectedItemPosition),
)
@@ -115,15 +121,15 @@ class ScrobblingInfoBottomSheet :
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(context)
.lifecycle(viewLifecycleOwner)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_error_placeholder)
.enqueueWith(coil)
binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
binding.imageViewCover.newImageRequest(scrobbling.coverUrl)?.apply {
lifecycle(viewLifecycleOwner)
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
enqueueWith(coil)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
@@ -135,13 +141,16 @@ class ScrobblingInfoBottomSheet :
Intent.createChooser(intent, getString(R.string.open_in_browser)),
)
}
R.id.action_unregister -> {
viewModel.unregisterScrobbling()
viewModel.unregisterScrobbling(scrobblerIndex)
dismiss()
}
R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga)
val scrobblerService = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.scrobbler
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga, scrobblerService)
dismiss()
}
}

View File

@@ -8,13 +8,12 @@ import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import coil.ImageLoader
import com.google.android.material.tabs.TabLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
@@ -23,11 +22,14 @@ 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.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikiMangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.requireParcelable
import org.koitharu.kotatsu.utils.ext.withArgs
@@ -42,7 +44,8 @@ class ScrobblingSelectorBottomSheet :
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener,
AdapterView.OnItemSelectedListener {
TabLayout.OnTabSelectedListener,
ListStateHolderListener {
@Inject
lateinit var viewModelFactory: ScrobblingSelectorViewModel.Factory
@@ -68,7 +71,7 @@ class ScrobblingSelectorBottomSheet :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, coil, this)
val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, coil, this, this)
val decoration = ShikiMangaSelectionDecoration(view.context)
with(binding.recyclerView) {
adapter = listAdapter
@@ -77,7 +80,7 @@ class ScrobblingSelectorBottomSheet :
}
binding.buttonDone.setOnClickListener(this)
initOptionsMenu()
initSpinner()
initTabs()
viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it }
viewModel.selectedItemId.observe(viewLifecycleOwner) {
@@ -103,6 +106,12 @@ class ScrobblingSelectorBottomSheet :
viewModel.selectedItemId.value = item.id
}
override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() {
openSearch()
}
override fun onScrolledToEnd() {
viewModel.loadList(append = true)
}
@@ -143,11 +152,23 @@ class ScrobblingSelectorBottomSheet :
return false
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.setScrobblerIndex(position)
override fun onTabSelected(tab: TabLayout.Tab) {
viewModel.setScrobblerIndex(tab.position)
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
override fun onTabReselected(tab: TabLayout.Tab?) {
if (!isExpanded) {
setExpanded(isExpanded = true, isLocked = behavior?.isDraggable == false)
}
binding.recyclerView.firstVisibleItemPosition = 0
}
private fun openSearch() {
val menuItem = binding.headerBar.menu.findItem(R.id.action_search) ?: return
menuItem.expandActionView()
}
private fun onError(e: Throwable) {
Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
@@ -166,32 +187,41 @@ class ScrobblingSelectorBottomSheet :
searchView.queryHint = searchMenuItem.title
}
private fun initSpinner() {
private fun initTabs() {
val entries = viewModel.availableScrobblers
val tabs = binding.tabs
if (entries.size <= 1) {
binding.spinnerScrobblers.isVisible = false
tabs.isVisible = false
return
}
val adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_item,
entries.map { getString(it.scrobblerService.titleResId) },
)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerScrobblers.adapter = adapter
viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) {
binding.spinnerScrobblers.setSelection(it)
val selectedId = arguments?.getInt(ARG_SCROBBLER, -1) ?: -1
tabs.removeAllTabs()
tabs.clearOnTabSelectedListeners()
tabs.addOnTabSelectedListener(this)
for (entry in entries) {
val tab = tabs.newTab()
tab.tag = entry.scrobblerService
tab.setIcon(entry.scrobblerService.iconResId)
tab.setText(entry.scrobblerService.titleResId)
tabs.addTab(tab)
if (entry.scrobblerService.id == selectedId) {
tab.select()
}
}
binding.spinnerScrobblers.onItemSelectedListener = this
tabs.isVisible = true
}
companion object {
private const val TAG = "ScrobblingSelectorBottomSheet"
private const val ARG_SCROBBLER = "scrobbler"
fun show(fm: FragmentManager, manga: Manga) =
ScrobblingSelectorBottomSheet().withArgs(1) {
fun show(fm: FragmentManager, manga: Manga, scrobblerService: ScrobblerService?) =
ScrobblingSelectorBottomSheet().withArgs(2) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false))
if (scrobblerService != null) {
putInt(ARG_SCROBBLER, scrobblerService.id)
}
}.show(fm, TAG)
}
}

View File

@@ -12,7 +12,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
@@ -46,7 +48,7 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
hasNextPage,
) { list, isHasNextPage ->
when {
list.isEmpty() -> listOf()
list.isEmpty() -> listOf(emptyResultsHint())
isHasNextPage -> list + LoadingFooter
else -> list
}
@@ -125,6 +127,13 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
}
}
private fun emptyResultsHint() = EmptyHint(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = R.string.search,
)
@AssistedFactory
interface Factory {

View File

@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun shikimoriMangaAD(
fun scrobblingMangaAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
clickListener: OnListItemClickListener<ScrobblerManga>,

View File

@@ -4,23 +4,27 @@ 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.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import kotlin.jvm.internal.Intrinsics
class ShikimoriSelectorAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
clickListener: OnListItemClickListener<ScrobblerManga>,
stateHolderListener: ListStateHolderListener,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(loadingStateAD())
.addDelegate(shikimoriMangaAD(lifecycleOwner, coil, clickListener))
.addDelegate(scrobblingMangaAD(lifecycleOwner, coil, clickListener))
.addDelegate(loadingFooterAD())
.addDelegate(emptyHintAD(stateHolderListener))
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
@@ -37,4 +41,4 @@ class ShikimoriSelectorAdapter(
return Intrinsics.areEqual(oldItem, newItem)
}
}
}
}

View File

@@ -217,7 +217,8 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
viewLifecycleScope.launch {
pref.summary = withContext(Dispatchers.Default) {
runCatching {
repository.loadUser().nickname
val user = repository.loadUser()
getString(R.string.logged_in_as, user.nickname)
}.getOrElse {
it.printStackTraceDebug()
it.getDisplayMessage(resources)

File diff suppressed because one or more lines are too long

View File

@@ -36,6 +36,17 @@
tools:background="@sample/covers[9]"
tools:ignore="ContentDescription,UnusedAttribute" />
<ImageView
android:id="@+id/imageView_logo"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="@dimen/card_indicator_offset"
app:layout_constraintBottom_toBottomOf="@id/imageView_cover"
app:layout_constraintEnd_toEndOf="@id/imageView_cover"
app:tint="?colorControlLight"
tools:ignore="ContentDescription"
tools:src="@drawable/ic_shikimori" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"

View File

@@ -24,11 +24,13 @@
</org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar>
<Spinner
android:id="@+id/spinner_scrobblers"
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@android:layout/simple_spinner_item" />
android:visibility="gone"
app:tabGravity="start"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
@@ -36,7 +38,6 @@
android:layout_height="wrap_content"
android:clipToPadding="false"
android:padding="@dimen/grid_spacing"
android:scrollbarStyle="outsideOverlay"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_manga_list" />

View File

@@ -32,7 +32,7 @@
app:showAsAction="never" />
<item
android:id="@+id/action_shiki_track"
android:id="@+id/action_scrobbling"
android:orderInCategory="50"
android:title="@string/tracking"
app:showAsAction="never" />