Manga preview in list on tablet

This commit is contained in:
Koitharu
2023-07-26 12:11:42 +03:00
parent 7c7106a63c
commit 01c23bc3b8
7 changed files with 462 additions and 15 deletions

View File

@@ -135,7 +135,9 @@ abstract class MangaListFragment :
override fun onItemClick(item: Manga, view: View) {
if (selectionController?.onItemClick(item.id) != true) {
startActivity(DetailsActivity.newIntent(context ?: return, item))
if ((activity as? MangaListActivity)?.showPreview(item) != true) {
startActivity(DetailsActivity.newIntent(context ?: return, item))
}
}
}

View File

@@ -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())
}
}

View File

@@ -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()
}
}

View File

@@ -6,8 +6,10 @@ import android.os.Bundle
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import androidx.core.graphics.Insets
import androidx.core.os.bundleOf
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import dagger.hilt.android.AndroidEntryPoint
@@ -15,7 +17,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
@@ -27,8 +31,10 @@ import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.list.ui.preview.PreviewFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
@@ -66,8 +72,9 @@ class MangaListActivity :
left = insets.left,
right = insets.right,
)
viewBinding.cardFilter?.updateLayoutParams<MarginLayoutParams> {
viewBinding.cardSide?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = marginStart + insets.bottom
topMargin = marginStart + insets.top
}
}
@@ -77,6 +84,13 @@ class MangaListActivity :
}
}
fun showPreview(manga: Manga): Boolean = setSideFragment(
PreviewFragment::class.java,
bundleOf(MangaIntent.KEY_MANGA to ParcelableManga(manga, true)),
)
fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null)
private fun initList(source: MangaSource, tags: Set<MangaTag>?) {
val fm = supportFragmentManager
val existingFragment = fm.findFragmentById(R.id.container)
@@ -100,12 +114,9 @@ class MangaListActivity :
}
private fun initFilter(filterOwner: FilterOwner) {
if (viewBinding.containerFilter != null) {
if (supportFragmentManager.findFragmentById(R.id.container_filter) == null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.container_filter, FilterSheetFragment::class.java, null)
}
if (viewBinding.containerSide != null) {
if (supportFragmentManager.findFragmentById(R.id.container_side) == null) {
setSideFragment(FilterSheetFragment::class.java, null)
}
} else if (viewBinding.containerFilterHeader != null) {
if (supportFragmentManager.findFragmentById(R.id.container_filter_header) == null) {
@@ -135,6 +146,16 @@ class MangaListActivity :
return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner
}
private fun setSideFragment(cls: Class<out Fragment>, args: Bundle?) = if (viewBinding.containerSide != null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.container_side, cls, args)
}
true
} else {
false
}
private class ApplyFilterRunnable(
private val filterOwner: FilterOwner,
private val tags: Set<MangaTag>,

View File

@@ -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>

View File

@@ -11,7 +11,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toEndOf="@id/container"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
@@ -28,13 +28,13 @@
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/card_filter"
app:layout_constraintEnd_toStartOf="@id/card_side"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar"
tools:layout="@layout/fragment_list" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_filter"
android:id="@+id/card_side"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/side_card_offset"
@@ -44,13 +44,13 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/container"
app:layout_constraintTop_toBottomOf="@id/appbar"
app:layout_constraintWidth_max="400dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_max="600dp"
app:layout_constraintWidth_min="320dp"
app:layout_constraintWidth_percent="0.35">
app:layout_constraintWidth_percent="0.4">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/container_filter"
android:id="@+id/container_side"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:layout="@layout/sheet_filter" />

View File

@@ -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>