Manga preview in list on tablet
parent
7c7106a63c
commit
01c23bc3b8
@ -0,0 +1,165 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.preview
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.method.LinkMovementMethod
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.request.SuccessResult
|
||||||
|
import coil.util.CoilUtils
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.crossfade
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
|
import org.koitharu.kotatsu.databinding.FragmentPreviewBinding
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||||
|
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||||
|
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickListener, ChipsView.OnChipClickListener {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
private val viewModel: PreviewViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPreviewBinding {
|
||||||
|
return FragmentPreviewBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: FragmentPreviewBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
binding.buttonClose.isVisible = activity is MangaListActivity
|
||||||
|
binding.buttonClose.setOnClickListener(this)
|
||||||
|
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
|
||||||
|
binding.chipsTags.onChipClickListener = this
|
||||||
|
binding.textViewAuthor.setOnClickListener(this)
|
||||||
|
binding.imageViewCover.setOnClickListener(this)
|
||||||
|
binding.buttonOpen.setOnClickListener(this)
|
||||||
|
|
||||||
|
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
|
||||||
|
viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged)
|
||||||
|
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
val manga = viewModel.manga.value
|
||||||
|
when (v.id) {
|
||||||
|
R.id.button_close -> closeSelf()
|
||||||
|
R.id.button_open -> startActivity(
|
||||||
|
DetailsActivity.newIntent(v.context, manga),
|
||||||
|
scaleUpActivityOptionsOf(requireView()),
|
||||||
|
)
|
||||||
|
|
||||||
|
R.id.textView_author -> startActivity(
|
||||||
|
SearchActivity.newIntent(
|
||||||
|
context = v.context,
|
||||||
|
source = manga.source,
|
||||||
|
query = manga.author ?: return,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
R.id.imageView_cover -> startActivity(
|
||||||
|
ImageActivity.newIntent(
|
||||||
|
v.context,
|
||||||
|
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||||
|
manga.source,
|
||||||
|
),
|
||||||
|
scaleUpActivityOptionsOf(v),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||||
|
|
||||||
|
override fun onChipClick(chip: Chip, data: Any?) {
|
||||||
|
val tag = data as? MangaTag ?: return
|
||||||
|
val filter = (activity as? FilterOwner)?.filter
|
||||||
|
if (filter == null) {
|
||||||
|
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
|
||||||
|
} else {
|
||||||
|
filter.onTagItemClick(FilterItem.Tag(tag, false))
|
||||||
|
closeSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onMangaUpdated(manga: Manga) {
|
||||||
|
with(requireViewBinding()) {
|
||||||
|
// Main
|
||||||
|
loadCover(manga)
|
||||||
|
textViewTitle.text = manga.title
|
||||||
|
textViewSubtitle.textAndVisible = manga.altTitle
|
||||||
|
textViewAuthor.textAndVisible = manga.author
|
||||||
|
if (manga.hasRating) {
|
||||||
|
ratingBar.rating = manga.rating * ratingBar.numStars
|
||||||
|
ratingBar.isVisible = true
|
||||||
|
} else {
|
||||||
|
ratingBar.isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDescriptionChanged(description: CharSequence?) {
|
||||||
|
val tv = requireViewBinding().textViewDescription
|
||||||
|
if (description.isNullOrBlank()) {
|
||||||
|
tv.setText(R.string.no_description)
|
||||||
|
} else {
|
||||||
|
tv.text = description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCover(manga: Manga) {
|
||||||
|
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
|
||||||
|
val lastResult = CoilUtils.result(requireViewBinding().imageViewCover)
|
||||||
|
if (lastResult is SuccessResult && lastResult.request.data == imageUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = ImageRequest.Builder(context ?: return)
|
||||||
|
.target(requireViewBinding().imageViewCover)
|
||||||
|
.size(CoverSizeResolver(requireViewBinding().imageViewCover))
|
||||||
|
.data(imageUrl)
|
||||||
|
.tag(manga.source)
|
||||||
|
.crossfade(requireContext())
|
||||||
|
.lifecycle(viewLifecycleOwner)
|
||||||
|
.placeholderMemoryCacheKey(manga.coverUrl)
|
||||||
|
val previousDrawable = lastResult?.drawable
|
||||||
|
if (previousDrawable != null) {
|
||||||
|
request.fallback(previousDrawable)
|
||||||
|
.placeholder(previousDrawable)
|
||||||
|
.error(previousDrawable)
|
||||||
|
} else {
|
||||||
|
request.fallback(R.drawable.ic_placeholder)
|
||||||
|
.placeholder(R.drawable.ic_placeholder)
|
||||||
|
.error(R.drawable.ic_error_placeholder)
|
||||||
|
}
|
||||||
|
request.enqueueWith(coil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTagsChipsChanged(chips: List<ChipsView.ChipModel>) {
|
||||||
|
requireViewBinding().chipsTags.setChips(chips)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeSelf() {
|
||||||
|
((activity as? MangaListActivity)?.hidePreview())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.preview
|
||||||
|
|
||||||
|
import android.text.Html
|
||||||
|
import android.text.SpannableString
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.ForegroundColorSpan
|
||||||
|
import androidx.core.text.getSpans
|
||||||
|
import androidx.core.text.parseAsHtml
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.sanitize
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class PreviewViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val extraProvider: ListExtraProvider,
|
||||||
|
private val repositoryFactory: MangaRepository.Factory,
|
||||||
|
private val imageGetter: Html.ImageGetter,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val manga = MutableStateFlow(
|
||||||
|
savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga,
|
||||||
|
)
|
||||||
|
|
||||||
|
val description = manga
|
||||||
|
.distinctUntilChangedBy { it.description.orEmpty() }
|
||||||
|
.transformLatest {
|
||||||
|
val description = it.description
|
||||||
|
if (description.isNullOrEmpty()) {
|
||||||
|
emit(null)
|
||||||
|
} else {
|
||||||
|
emit(description.parseAsHtml().filterSpans().sanitize())
|
||||||
|
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
|
val tagsChips = manga.map {
|
||||||
|
it.tags.map { tag ->
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
title = tag.title,
|
||||||
|
tint = extraProvider.getTagTint(tag),
|
||||||
|
icon = 0,
|
||||||
|
data = tag,
|
||||||
|
isCheckable = false,
|
||||||
|
isChecked = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
val repo = repositoryFactory.create(manga.value.source)
|
||||||
|
manga.value = repo.getDetails(manga.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Spanned.filterSpans(): CharSequence {
|
||||||
|
val spannable = SpannableString.valueOf(this)
|
||||||
|
val spans = spannable.getSpans<ForegroundColorSpan>()
|
||||||
|
for (span in spans) {
|
||||||
|
spannable.removeSpan(span)
|
||||||
|
}
|
||||||
|
return spannable.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M10,21V19H6.41L10.91,14.5L9.5,13.09L5,17.59V14H3V21H10M14.5,10.91L19,6.41V10H21V3H14V5H17.59L13.09,9.5L14.5,10.91Z" />
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
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:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/imageView_cover"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:background="?colorSecondaryContainer"
|
||||||
|
android:foreground="?selectableItemBackground"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:layout_constraintDimensionRatio="H,13:18"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintWidth_percent="0.3"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
|
||||||
|
tools:background="@tools:sample/backgrounds/scenic[5]"
|
||||||
|
tools:ignore="ContentDescription,UnusedAttribute" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="5"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/button_open"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/imageView_cover"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_goneMarginEnd="16dp"
|
||||||
|
tools:text="@tools:sample/lorem" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/button_open"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:background="?selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/details"
|
||||||
|
android:padding="12dp"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/button_close"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:srcCompat="@drawable/ic_expand" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/button_close"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:background="?selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/close"
|
||||||
|
android:padding="12dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:srcCompat="?actionModeCloseDrawable"
|
||||||
|
app:tint="?colorControlNormal" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_subtitle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/imageView_cover"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/textView_title"
|
||||||
|
tools:text="@tools:sample/lorem[12]" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_author"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:background="@drawable/list_selector"
|
||||||
|
android:padding="2dp"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textColor="?attr/colorTertiary"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/imageView_cover"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/textView_subtitle"
|
||||||
|
tools:text="@tools:sample/full_names" />
|
||||||
|
|
||||||
|
<RatingBar
|
||||||
|
android:id="@+id/rating_bar"
|
||||||
|
style="?ratingBarStyleSmall"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:isIndicator="true"
|
||||||
|
android:max="1"
|
||||||
|
android:numStars="5"
|
||||||
|
android:stepSize="0.5"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/imageView_cover"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/textView_author"
|
||||||
|
tools:rating="4" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/barrier_header"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:barrierDirection="bottom"
|
||||||
|
app:barrierMargin="8dp"
|
||||||
|
app:constraint_referenced_ids="imageView_cover,rating_bar" />
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
|
android:id="@+id/chips_tags"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
app:chipSpacingHorizontal="6dp"
|
||||||
|
app:chipSpacingVertical="6dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/barrier_header" />
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.core.ui.widgets.SelectableTextView
|
||||||
|
android:id="@+id/textView_description"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/margin_normal"
|
||||||
|
android:layout_marginTop="@dimen/margin_small"
|
||||||
|
android:layout_marginEnd="@dimen/margin_normal"
|
||||||
|
android:lineSpacingMultiplier="1.2"
|
||||||
|
android:paddingBottom="@dimen/margin_normal"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/chips_tags"
|
||||||
|
tools:ignore="UnusedAttribute"
|
||||||
|
tools:text="@tools:sample/lorem/random" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
Loading…
Reference in New Issue