Manga details activity
This commit is contained in:
@@ -1,72 +1,90 @@
|
||||
package org.koitharu.kotatsu.domain.repository
|
||||
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.parseAsHtml
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.domain.MangaRepository
|
||||
import org.koitharu.kotatsu.domain.exceptions.ParseException
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.parseHtml
|
||||
import org.koitharu.kotatsu.utils.ext.safe
|
||||
import org.koitharu.kotatsu.utils.ext.withDomain
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
|
||||
class ReadmangaRepository(loaderContext: MangaLoaderContext) : MangaRepository(loaderContext) {
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tags: Set<String>?
|
||||
): List<Manga> {
|
||||
val doc = loaderContext.get("https://readmanga.me/list?sortType=updated&offset=$offset")
|
||||
.parseHtml()
|
||||
val root = doc.body().getElementById("mangaBox")
|
||||
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
|
||||
return root.select("div.tile").mapNotNull { node ->
|
||||
val imgDiv = node.selectFirst("div.img") ?: return@mapNotNull null
|
||||
val descDiv = node.selectFirst("div.desc") ?: return@mapNotNull null
|
||||
val href = imgDiv.selectFirst("a").attr("href")?.withDomain("readmanga.me")
|
||||
?: return@mapNotNull null
|
||||
val title = descDiv.selectFirst("h3")?.selectFirst("a")?.text()
|
||||
?: return@mapNotNull null
|
||||
Manga(
|
||||
id = href.longHashCode(),
|
||||
url = href,
|
||||
localizedTitle = title,
|
||||
title = descDiv.selectFirst("h4")?.text() ?: title,
|
||||
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(),
|
||||
summary = "",
|
||||
rating = safe {
|
||||
node.selectFirst("div.rating")
|
||||
?.attr("title")
|
||||
?.substringBefore(' ')
|
||||
?.toFloatOrNull()
|
||||
?.div(10f)
|
||||
} ?: -1f,
|
||||
tags = safe {
|
||||
descDiv.selectFirst("div.tile-info")
|
||||
?.select("a.element-link")
|
||||
?.map {
|
||||
MangaTag(
|
||||
title = it.text(),
|
||||
key = it.attr("href").substringAfterLast('/')
|
||||
)
|
||||
}?.toSet()
|
||||
}.orEmpty(),
|
||||
state = when {
|
||||
node.selectFirst("div.tags")
|
||||
?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
source = MangaSource.READMANGA_RU
|
||||
)
|
||||
}
|
||||
}
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tags: Set<String>?
|
||||
): List<Manga> {
|
||||
val doc = loaderContext.get("https://readmanga.me/list?sortType=updated&offset=$offset")
|
||||
.parseHtml()
|
||||
val root = doc.body().getElementById("mangaBox")
|
||||
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
|
||||
return root.select("div.tile").mapNotNull { node ->
|
||||
val imgDiv = node.selectFirst("div.img") ?: return@mapNotNull null
|
||||
val descDiv = node.selectFirst("div.desc") ?: return@mapNotNull null
|
||||
val href = imgDiv.selectFirst("a").attr("href")?.withDomain("readmanga.me")
|
||||
?: return@mapNotNull null
|
||||
val title = descDiv.selectFirst("h3")?.selectFirst("a")?.text()
|
||||
?: return@mapNotNull null
|
||||
Manga(
|
||||
id = href.longHashCode(),
|
||||
url = href,
|
||||
localizedTitle = title,
|
||||
title = descDiv.selectFirst("h4")?.text() ?: title,
|
||||
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(),
|
||||
summary = "",
|
||||
rating = safe {
|
||||
node.selectFirst("div.rating")
|
||||
?.attr("title")
|
||||
?.substringBefore(' ')
|
||||
?.toFloatOrNull()
|
||||
?.div(10f)
|
||||
} ?: -1f,
|
||||
tags = safe {
|
||||
descDiv.selectFirst("div.tile-info")
|
||||
?.select("a.element-link")
|
||||
?.map {
|
||||
MangaTag(
|
||||
title = it.text(),
|
||||
key = it.attr("href").substringAfterLast('/')
|
||||
)
|
||||
}?.toSet()
|
||||
}.orEmpty(),
|
||||
state = when {
|
||||
node.selectFirst("div.tags")
|
||||
?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
source = MangaSource.READMANGA_RU
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
|
||||
}
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val doc = loaderContext.get(manga.url).parseHtml()
|
||||
val root = doc.body().getElementById("mangaBox")
|
||||
return manga.copy(
|
||||
description = root.selectFirst("div.manga-description").firstChild()?.html()?.parseAsHtml(),
|
||||
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
|
||||
"data-full"
|
||||
),
|
||||
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
|
||||
?.select("a")?.asReversed()?.mapIndexedNotNull { i, a ->
|
||||
val href =
|
||||
a.attr("href")?.withDomain("readmanga.me") ?: return@mapIndexedNotNull null
|
||||
MangaChapter(
|
||||
id = href.longHashCode(),
|
||||
name = a.ownText(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
source = MangaSource.READMANGA_RU
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
|
||||
}
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.shape.CornerFamily
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
|
||||
class ChipsFactory(private val context: Context) {
|
||||
|
||||
fun create(convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0, tag: Any? = null): Chip {
|
||||
val chip = convertView ?: Chip(context).apply {
|
||||
setTextColor(context.getThemeColor(android.R.attr.textColorPrimary))
|
||||
isCloseIconVisible = false
|
||||
}
|
||||
chip.text = text
|
||||
if (iconRes == 0) {
|
||||
chip.isChipIconVisible = false
|
||||
} else {
|
||||
chip.isCheckedIconVisible = true
|
||||
chip.setChipIconResource(iconRes)
|
||||
}
|
||||
chip.tag = tag
|
||||
return chip
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class RectFrameLayout @JvmOverloads constructor(
|
||||
class SquareLayout @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.ui.main.details
|
||||
|
||||
import android.view.ViewGroup
|
||||
import kotlinx.android.synthetic.main.item_chapter.*
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
|
||||
|
||||
class ChapterHolder(parent: ViewGroup) : BaseViewHolder<MangaChapter>(parent, R.layout.item_chapter) {
|
||||
|
||||
override fun onBind(data: MangaChapter) {
|
||||
textView_title.text = data.name
|
||||
textView_number.text = data.number.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.ui.main.details
|
||||
|
||||
import android.view.ViewGroup
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
|
||||
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
|
||||
|
||||
class ChaptersAdapter(onItemClickListener: ((MangaChapter) -> Unit)?) :
|
||||
BaseRecyclerAdapter<MangaChapter>(onItemClickListener) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup) = ChapterHolder(parent)
|
||||
|
||||
override fun onGetItemId(item: MangaChapter) = item.id
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.ui.main.details
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.android.synthetic.main.fragment_chapters.*
|
||||
import moxy.ktx.moxyPresenter
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.ui.common.BaseFragment
|
||||
|
||||
class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView {
|
||||
|
||||
private val presenter by moxyPresenter { (activity as MangaDetailsActivity).presenter }
|
||||
|
||||
private lateinit var adapter: ChaptersAdapter
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
adapter = ChaptersAdapter {
|
||||
|
||||
}
|
||||
recyclerView_chapters.addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL))
|
||||
recyclerView_chapters.adapter = adapter
|
||||
}
|
||||
|
||||
override fun onMangaUpdated(manga: Manga) {
|
||||
adapter.replaceData(manga.chapters.orEmpty())
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.koitharu.kotatsu.ui.main.details
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import kotlinx.android.synthetic.main.activity_details.*
|
||||
import moxy.ktx.moxyPresenter
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.ui.common.BaseActivity
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
|
||||
|
||||
val presenter by moxyPresenter(factory = ::MangaDetailsPresenter)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_details)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
pager.adapter = MangaDetailsAdapter(resources, supportFragmentManager)
|
||||
tabs.setupWithViewPager(pager)
|
||||
intent?.getParcelableExtra<Manga>(EXTRA_MANGA)?.let {
|
||||
presenter.loadDetails(it)
|
||||
} ?: finish()
|
||||
}
|
||||
|
||||
override fun onMangaUpdated(manga: Manga) {
|
||||
title = manga.title
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
Snackbar.make(pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_MANGA = "manga"
|
||||
|
||||
fun newIntent(context: Context, manga: Manga) = Intent(context, MangaDetailsActivity::class.java)
|
||||
.putExtra(EXTRA_MANGA, manga)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.ui.main.details
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentPagerAdapter
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class MangaDetailsAdapter(private val resources: Resources, fm: FragmentManager) : FragmentPagerAdapter(fm, FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
|
||||
|
||||
override fun getCount() = 2
|
||||
|
||||
override fun getItem(position: Int): Fragment = when(position) {
|
||||
0 -> MangaDetailsFragment()
|
||||
1 -> ChaptersFragment()
|
||||
else -> throw IndexOutOfBoundsException("No fragment for position $position")
|
||||
}
|
||||
|
||||
override fun getPageTitle(position: Int): CharSequence? = when(position) {
|
||||
0 -> resources.getString(R.string.details)
|
||||
1 -> resources.getString(R.string.chapters)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.koitharu.kotatsu.ui.main.details
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.view.isVisible
|
||||
import coil.api.load
|
||||
import kotlinx.android.synthetic.main.fragment_details.*
|
||||
import moxy.ktx.moxyPresenter
|
||||
import org.koin.core.get
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.ui.common.BaseFragment
|
||||
import org.koitharu.kotatsu.utils.ext.setChips
|
||||
|
||||
class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView {
|
||||
|
||||
private val presenter by moxyPresenter { (activity as MangaDetailsActivity).presenter }
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onMangaUpdated(manga: Manga) {
|
||||
imageView_cover.load(manga.largeCoverUrl ?: manga.coverUrl)
|
||||
textView_title.text = manga.title
|
||||
textView_subtitle.text = manga.localizedTitle
|
||||
textView_description.text = manga.description
|
||||
chips_tags.setChips(manga.tags) {
|
||||
create(
|
||||
text = it.title,
|
||||
iconRes = R.drawable.ic_chip_tag,
|
||||
tag = it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.ui.main.details
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import moxy.InjectViewState
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.ui.common.BasePresenter
|
||||
|
||||
@InjectViewState
|
||||
class MangaDetailsPresenter : BasePresenter<MangaDetailsView>() {
|
||||
|
||||
private var isLoaded = false
|
||||
|
||||
fun loadDetails(manga: Manga) {
|
||||
if (isLoaded) {
|
||||
return
|
||||
}
|
||||
viewState.onMangaUpdated(manga)
|
||||
launch {
|
||||
try {
|
||||
viewState.onLoadingStateChanged(true)
|
||||
val details = withContext(Dispatchers.IO) {
|
||||
MangaProviderFactory.create(manga.source).getDetails(manga)
|
||||
}
|
||||
viewState.onMangaUpdated(details)
|
||||
isLoaded = true
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
viewState.onError(e)
|
||||
} finally {
|
||||
viewState.onLoadingStateChanged(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.ui.main.details
|
||||
|
||||
import moxy.MvpView
|
||||
import moxy.viewstate.strategy.AddToEndSingleStrategy
|
||||
import moxy.viewstate.strategy.OneExecutionStateStrategy
|
||||
import moxy.viewstate.strategy.StateStrategyType
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
|
||||
interface MangaDetailsView : MvpView {
|
||||
|
||||
@StateStrategyType(AddToEndSingleStrategy::class)
|
||||
fun onMangaUpdated(manga: Manga)
|
||||
|
||||
@StateStrategyType(AddToEndSingleStrategy::class)
|
||||
fun onLoadingStateChanged(isLoading: Boolean)
|
||||
|
||||
@StateStrategyType(OneExecutionStateStrategy::class)
|
||||
fun onError(e: Exception)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.ui.common.BaseFragment
|
||||
import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener
|
||||
import org.koitharu.kotatsu.ui.common.list.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.ui.main.details.MangaDetailsActivity
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.hasItems
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
@@ -30,7 +31,7 @@ class MangaListFragment : BaseFragment(R.layout.fragment_list), MangaListView,
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
adapter = MangaListAdapter {
|
||||
|
||||
startActivity(MangaDetailsActivity.newIntent(context ?: return@MangaListAdapter, it))
|
||||
}
|
||||
// recyclerView.addItemDecoration(SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)))
|
||||
recyclerView.addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL))
|
||||
|
||||
@@ -4,6 +4,7 @@ import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
fun Response.parseHtml(): Document {
|
||||
val stream = body?.byteStream() ?: throw NullPointerException("Response body is null")
|
||||
@@ -15,4 +16,6 @@ fun Response.parseHtml(): Document {
|
||||
)
|
||||
closeQuietly()
|
||||
return doc
|
||||
}
|
||||
}
|
||||
|
||||
fun Element.firstChild(): Element? = children().first()
|
||||
@@ -9,7 +9,15 @@ fun String.longHashCode(): Long {
|
||||
return h
|
||||
}
|
||||
|
||||
fun String.withDomain(domain: String) = when {
|
||||
this.startsWith("/") -> "http://$domain"
|
||||
fun String.withDomain(domain: String, ssl: Boolean = true) = when {
|
||||
this.startsWith("/") -> buildString {
|
||||
append("http")
|
||||
if (ssl) {
|
||||
append('s')
|
||||
}
|
||||
append("://")
|
||||
append(domain)
|
||||
append(this@withDomain)
|
||||
}
|
||||
else -> this
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import org.koitharu.kotatsu.ui.common.ChipsFactory
|
||||
|
||||
fun View.hideKeyboard() {
|
||||
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
@@ -61,4 +64,13 @@ var TextView.textAndVisible: CharSequence?
|
||||
set(value) {
|
||||
text = value
|
||||
isGone = value.isNullOrEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> ChipGroup.setChips(data: Iterable<T>, action: ChipsFactory.(T) -> Chip) {
|
||||
removeAllViews()
|
||||
val factory = ChipsFactory(context)
|
||||
data.forEach {
|
||||
val chip = factory.action(it)
|
||||
addView(chip)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user