From 01c23bc3b8b273565e2e5a45b6aeb9b1c5f7ea62 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 26 Jul 2023 12:11:42 +0300 Subject: [PATCH] Manga preview in list on tablet --- .../kotatsu/list/ui/MangaListFragment.kt | 4 +- .../list/ui/preview/PreviewFragment.kt | 165 ++++++++++++++++++ .../list/ui/preview/PreviewViewModel.kt | 82 +++++++++ .../kotatsu/search/ui/MangaListActivity.kt | 35 +++- app/src/main/res/drawable/ic_expand.xml | 12 ++ .../activity_manga_list.xml | 14 +- app/src/main/res/layout/fragment_preview.xml | 165 ++++++++++++++++++ 7 files changed, 462 insertions(+), 15 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt create mode 100644 app/src/main/res/drawable/ic_expand.xml create mode 100644 app/src/main/res/layout/fragment_preview.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 7ea25cb6d..dabdb6b7d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -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)) + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt new file mode 100644 index 000000000..3c30c014d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt @@ -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(), 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) { + requireViewBinding().chipsTags.setChips(chips) + } + + private fun closeSelf() { + ((activity as? MangaListActivity)?.hidePreview()) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt new file mode 100644 index 000000000..753e3782c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt @@ -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(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() + for (span in spans) { + spannable.removeSpan(span) + } + return spannable.trim() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index e5c3320a7..25c389f23 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -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 { + viewBinding.cardSide?.updateLayoutParams { 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?) { 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, 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, diff --git a/app/src/main/res/drawable/ic_expand.xml b/app/src/main/res/drawable/ic_expand.xml new file mode 100644 index 000000000..e529f2e16 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout-w600dp-land/activity_manga_list.xml b/app/src/main/res/layout-w600dp-land/activity_manga_list.xml index 6d5eae09b..36cf59f1a 100644 --- a/app/src/main/res/layout-w600dp-land/activity_manga_list.xml +++ b/app/src/main/res/layout-w600dp-land/activity_manga_list.xml @@ -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" /> + app:layout_constraintWidth_percent="0.4"> diff --git a/app/src/main/res/layout/fragment_preview.xml b/app/src/main/res/layout/fragment_preview.xml new file mode 100644 index 000000000..127ef5308 --- /dev/null +++ b/app/src/main/res/layout/fragment_preview.xml @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +