Support multiple scrobblers

This commit is contained in:
Koitharu
2022-08-13 12:40:03 +03:00
parent 5abbddba1e
commit 49a7408715
13 changed files with 249 additions and 88 deletions

View File

@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
@@ -69,7 +70,6 @@ class DetailsFragment :
super.onViewCreated(view, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.scrobblingLayout.root.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
@@ -203,35 +203,22 @@ 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)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
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)
}
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
var adapter = binding.recyclerViewScrobbling.adapter as? ScrollingInfoAdapter
binding.recyclerViewScrobbling.isGone = scrobblings.isEmpty()
if (adapter != null) {
adapter.items = scrobblings
} else {
adapter = ScrollingInfoAdapter(viewLifecycleOwner, coil, childFragmentManager)
adapter.items = scrobblings
binding.recyclerViewScrobbling.adapter = adapter
binding.recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration())
}
}
override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return
when (v.id) {
R.id.scrobbling_layout -> {
ScrobblingInfoBottomSheet.show(childFragmentManager)
}
R.id.textView_author -> {
startActivity(
SearchActivity.newIntent(

View File

@@ -38,6 +38,7 @@ 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.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
@@ -54,13 +55,11 @@ class DetailsViewModel @AssistedInject constructor(
mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter,
mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel() {
private val scrobbler = scrobblers.first() // TODO support multiple scrobblers
private val delegate = MangaDetailsDelegate(
intent = intent,
settings = settings,
@@ -121,10 +120,13 @@ class DetailsViewModel @AssistedInject constructor(
val onMangaRemoved = SingleLiveEvent<Manga>()
val isScrobblingAvailable: Boolean
get() = scrobbler.isAvailable
get() = scrobblers.any { it.isAvailable }
val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId)
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val scrobblingInfo: LiveData<List<ScrobblingInfo>> = combine(
scrobblers.map { it.observeScrobblingInfo(delegate.mangaId) },
) { scrobblingInfo ->
scrobblingInfo.filterNotNull()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val branches: LiveData<List<String?>> = delegate.manga.map {
val chapters = it?.chapters ?: return@map emptyList()
@@ -238,21 +240,27 @@ class DetailsViewModel @AssistedInject constructor(
}
fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
for (scrobbler in scrobblers) {
if (!scrobbler.isAvailable) continue
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
}
}
fun unregisterScrobbling() {
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId,
)
for (scrobbler in scrobblers) {
if (!scrobbler.isAvailable) continue
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId,
)
}
}
}

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
fun scrobblingInfoAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
fragmentManager: FragmentManager,
) = adapterDelegateViewBinding<ScrobblingInfo, ScrobblingInfo, ItemScrobblingInfoBinding>(
{ layoutInflater, parent -> ItemScrobblingInfoBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener {
ScrobblingInfoBottomSheet.show(fragmentManager, bindingAdapterPosition)
}
bind {
binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title
binding.textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, item.scrobbler.iconResId, 0)
binding.ratingBar.rating = item.rating * binding.ratingBar.numStars
binding.textViewStatus.text = item.status?.let {
context.resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal)
}
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -26,10 +26,7 @@ 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.crossfade
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.*
@AndroidEntryPoint
class ScrobblingInfoBottomSheet :
@@ -40,11 +37,17 @@ class ScrobblingInfoBottomSheet :
PopupMenu.OnMenuItemClickListener {
private val viewModel by activityViewModels<DetailsViewModel>()
private var scrobblerIndex: Int = -1
@Inject
lateinit var coil: ImageLoader
private var menu: PopupMenu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
scrobblerIndex = requireArguments().getInt(ARG_INDEX, scrobblerIndex)
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding {
return SheetScrobblingBinding.inflate(inflater, container, false)
}
@@ -95,14 +98,15 @@ class ScrobblingInfoBottomSheet :
when (v.id) {
R.id.button_menu -> menu?.show()
R.id.imageView_cover -> {
val coverUrl = viewModel.scrobblingInfo.value?.coverUrl ?: return
val coverUrl = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.coverUrl ?: return
val options = scaleUpActivityOptionsOf(v)
startActivity(ImageActivity.newIntent(v.context, coverUrl), options.toBundle())
}
}
}
private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
val scrobbling = scrobblings.getOrNull(scrobblerIndex)
if (scrobbling == null) {
dismissAllowingStateLoss()
return
@@ -122,17 +126,10 @@ class ScrobblingInfoBottomSheet :
.enqueueWith(coil)
}
companion object {
private const val TAG = "ScrobblingInfoBottomSheet"
fun show(fm: FragmentManager) = ScrobblingInfoBottomSheet().show(fm, TAG)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_browser -> {
val url = viewModel.scrobblingInfo.value?.externalUrl ?: return false
val url = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.externalUrl ?: return false
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(
Intent.createChooser(intent, getString(R.string.open_in_browser)),
@@ -150,4 +147,14 @@ class ScrobblingInfoBottomSheet :
}
return true
}
companion object {
private const val TAG = "ScrobblingInfoBottomSheet"
private const val ARG_INDEX = "index"
fun show(fm: FragmentManager, index: Int) = ScrobblingInfoBottomSheet().withArgs(1) {
putInt(ARG_INDEX, index)
}.show(fm, TAG)
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
class ScrobblingItemDecoration() : RecyclerView.ItemDecoration() {
private var spacing: Int = -1
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
if (spacing == -1) {
spacing = parent.context.resources.getDimensionPixelOffset(R.dimen.scrobbling_list_spacing)
}
outRect.set(0, spacing, 0, 0)
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
class ScrollingInfoAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
fragmentManager: FragmentManager,
) : AsyncListDifferDelegationAdapter<ScrobblingInfo>(DiffCallback()) {
init {
delegatesManager.addDelegate(scrobblingInfoAD(lifecycleOwner, coil, fragmentManager))
}
private class DiffCallback : DiffUtil.ItemCallback<ScrobblingInfo>() {
override fun areItemsTheSame(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Boolean {
return oldItem.scrobbler == newItem.scrobbler
}
override fun areContentsTheSame(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Any? {
return Unit
}
}
}

View File

@@ -4,8 +4,11 @@ import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.*
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 dagger.hilt.android.AndroidEntryPoint
@@ -33,7 +36,8 @@ class ScrobblingSelectorBottomSheet :
View.OnClickListener,
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener {
DialogInterface.OnKeyListener,
AdapterView.OnItemSelectedListener {
@Inject
lateinit var viewModelFactory: ScrobblingSelectorViewModel.Factory
@@ -68,6 +72,7 @@ class ScrobblingSelectorBottomSheet :
}
binding.buttonDone.setOnClickListener(this)
initOptionsMenu()
initSpinner()
viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it }
viewModel.selectedItemId.observe(viewLifecycleOwner) {
@@ -133,6 +138,12 @@ class ScrobblingSelectorBottomSheet :
return false
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.setScrobblerIndex(position)
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
private fun onError(e: Throwable) {
Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
if (viewModel.isEmpty) {
@@ -150,6 +161,21 @@ class ScrobblingSelectorBottomSheet :
searchView.queryHint = searchMenuItem.title
}
private fun initSpinner() {
val entries = viewModel.availableScrobblers
if (entries.size <= 1) {
binding.spinnerScrobblers.isVisible = false
return
}
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, entries)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerScrobblers.adapter = adapter
viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) {
binding.spinnerScrobblers.setSelection(it)
}
binding.spinnerScrobblers.onItemSelectedListener = this
}
companion object {
private const val TAG = "ScrobblingSelectorBottomSheet"

View File

@@ -21,21 +21,28 @@ 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
import org.koitharu.kotatsu.utils.ext.requireValue
class ScrobblingSelectorViewModel @AssistedInject constructor(
@Assisted val manga: Manga,
scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) : BaseViewModel() {
private val scrobbler = scrobblers.first() // TODO support multiple scrobblers
val availableScrobblers = scrobblers.filter { it.isAvailable }
private val shikiMangaList = MutableStateFlow<List<ScrobblerManga>?>(null)
val selectedScrobblerIndex = MutableLiveData(0)
private val scrobblerMangaList = MutableStateFlow<List<ScrobblerManga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null
private var doneJob: Job? = null
private var initJob: Job? = null
private val currentScrobbler: Scrobbler
get() = availableScrobblers[selectedScrobblerIndex.requireValue()]
val content: LiveData<List<ListModel>> = combine(
shikiMangaList.filterNotNull(),
scrobblerMangaList.filterNotNull(),
hasNextPage,
) { list, isHasNextPage ->
when {
@@ -50,19 +57,10 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
val onClose = SingleLiveEvent<Unit>()
val isEmpty: Boolean
get() = shikiMangaList.value.isNullOrEmpty()
get() = scrobblerMangaList.value.isNullOrEmpty()
init {
launchJob(Dispatchers.Default) {
try {
val info = scrobbler.getScrobblingInfoOrNull(manga.id)
if (info != null) {
selectedItemId.postValue(info.targetId)
}
} finally {
loadList(append = false)
}
}
initialize()
}
fun search(query: String) {
@@ -79,12 +77,12 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
return
}
loadingJob = launchLoadingJob(Dispatchers.Default) {
val offset = if (append) shikiMangaList.value?.size ?: 0 else 0
val list = scrobbler.findManga(checkNotNull(searchQuery.value), offset)
val offset = if (append) scrobblerMangaList.value?.size ?: 0 else 0
val list = currentScrobbler.findManga(checkNotNull(searchQuery.value), offset)
if (!append) {
shikiMangaList.value = list
scrobblerMangaList.value = list
} else if (list.isNotEmpty()) {
shikiMangaList.value = shikiMangaList.value?.plus(list) ?: list
scrobblerMangaList.value = scrobblerMangaList.value?.plus(list) ?: list
}
hasNextPage.value = list.isNotEmpty()
}
@@ -99,11 +97,34 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
onClose.call(Unit)
}
doneJob = launchJob(Dispatchers.Default) {
scrobbler.linkManga(manga.id, targetId)
currentScrobbler.linkManga(manga.id, targetId)
onClose.postCall(Unit)
}
}
fun setScrobblerIndex(index: Int) {
if (index == selectedScrobblerIndex.value || index !in availableScrobblers.indices) return
selectedScrobblerIndex.value = index
initialize()
}
private fun initialize() {
initJob?.cancel()
loadingJob?.cancel()
hasNextPage.value = false
scrobblerMangaList.value = null
initJob = launchJob(Dispatchers.Default) {
try {
val info = currentScrobbler.getScrobblingInfoOrNull(manga.id)
if (info != null) {
selectedItemId.postValue(info.targetId)
}
} finally {
loadList(append = false)
}
}
}
@AssistedFactory
interface Factory {

View File

@@ -157,19 +157,24 @@
app:layout_constraintTop_toBottomOf="@id/textView_bookmarks"
tools:listitem="@layout/item_bookmark" />
<include
android:id="@+id/scrobbling_layout"
layout="@layout/layout_scrobbling_info"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_scrobbling"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:nestedScrollingEnabled="false"
android:orientation="vertical"
android:overScrollMode="never"
android:scrollbars="none"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks"
tools:itemCount="2"
tools:listitem="@layout/item_scrobbling_info"
tools:visibility="visible" />
<org.koitharu.kotatsu.base.ui.widgets.SelectableTextView
@@ -185,7 +190,7 @@
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/scrobbling_layout"
app:layout_constraintTop_toBottomOf="@id/recyclerView_scrobbling"
tools:ignore="UnusedAttribute"
tools:text="@tools:sample/lorem/random[250]" />

View File

@@ -169,19 +169,24 @@
app:layout_constraintTop_toBottomOf="@id/textView_bookmarks"
tools:listitem="@layout/item_bookmark" />
<include
android:id="@+id/scrobbling_layout"
layout="@layout/layout_scrobbling_info"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_scrobbling"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:nestedScrollingEnabled="false"
android:orientation="vertical"
android:overScrollMode="never"
android:scrollbars="none"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks"
tools:itemCount="2"
tools:listitem="@layout/item_scrobbling_info"
tools:visibility="visible" />
<org.koitharu.kotatsu.base.ui.widgets.SelectableTextView
@@ -197,7 +202,7 @@
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/scrobbling_layout"
app:layout_constraintTop_toBottomOf="@id/recyclerView_scrobbling"
tools:ignore="UnusedAttribute"
tools:text="@tools:sample/lorem/random[250]" />

View File

@@ -3,10 +3,9 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/scrobbling_layout"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
app:contentPadding="8dp">
<RelativeLayout

View File

@@ -24,6 +24,12 @@
</org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar>
<Spinner
android:id="@+id/spinner_scrobblers"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@android:layout/simple_spinner_item" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"

View File

@@ -29,6 +29,7 @@
<dimen name="widget_cover_width">84dp</dimen>
<dimen name="reading_progress_stroke">4dp</dimen>
<dimen name="reading_progress_text_size">10dp</dimen>
<dimen name="scrobbling_list_spacing">12dp</dimen>
<dimen name="search_suggestions_manga_height">124dp</dimen>
<dimen name="search_suggestions_manga_spacing">4dp</dimen>