Initial commit

This commit is contained in:
Koitharu
2020-01-30 18:45:45 +02:00
commit 9eda96c0d8
96 changed files with 2881 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
package org.koitharu.kotatsu
import android.app.Application
import androidx.room.Room
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.domain.MangaLoaderContext
import java.util.concurrent.TimeUnit
class KotatsuApp : Application() {
override fun onCreate() {
super.onCreate()
initKoin()
}
private fun initKoin() {
startKoin {
androidLogger()
androidContext(applicationContext)
modules(listOf(
module {
factory {
okHttp().build()
}
}, module {
single {
MangaLoaderContext(applicationContext)
}
}, module {
single {
mangaDb().build()
}
}
))
}
}
private fun okHttp() = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
private fun mangaDb() = Room.databaseBuilder(
applicationContext,
MangaDatabase::class.java,
"kotatsu-db"
)
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db
import androidx.room.Dao
import androidx.room.Query
import org.koitharu.kotatsu.core.db.entity.HistoryEntity
@Dao
interface HistoryDao {
@Query("SELECT * FROM history")
suspend fun getAll(): List<HistoryEntity>
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.db
import androidx.room.Database
import androidx.room.RoomDatabase
import org.koitharu.kotatsu.core.db.entity.HistoryEntity
@Database(entities = [HistoryEntity::class], version = 1)
abstract class MangaDatabase : RoomDatabase()

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "history")
data class HistoryEntity(
@PrimaryKey val id: Long
)

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
data class Manga(
val id: Long,
val title: String,
val localizedTitle: String? = null,
val url: String,
val rating: Float = -1f, //normalized value [0..1] or -1
val coverUrl: String,
val largeCoverUrl: String? = null,
val summary: String,
val description: CharSequence? = null,
val tags: Set<MangaTag> = emptySet(),
val state: MangaState? = null,
val chapters: List<MangaChapter>? = null,
val source: MangaSource
) : Parcelable

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
data class MangaChapter(
val id: Long,
val name: String,
val number: Int,
val url: String,
val source: MangaSource
) : Parcelable

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
data class MangaPage(
val id: Long,
val url: String,
val preview: String? = null,
val source: MangaSource
) : Parcelable

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import org.koitharu.kotatsu.domain.MangaRepository
import org.koitharu.kotatsu.domain.repository.ReadmangaRepository
import kotlin.reflect.KClass
@Parcelize
enum class MangaSource(val title: String, val cls: Class<out MangaRepository>): Parcelable {
READMANGA_RU("ReadManga", ReadmangaRepository::class.java)
}

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.core.model
enum class MangaState {
ONGOING, FINISHED
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
data class MangaTag(
val title: String,
val key: String
) : Parcelable

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.core.model
enum class SortOrder {
ALPHABETICAL, POPULARITY, UPDATED, NEWEST
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.domain
import android.content.Context
import okhttp3.*
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.utils.ext.await
class MangaLoaderContext(context: Context) : KoinComponent {
private val okHttp by inject<OkHttpClient>()
private val preferences = context.getSharedPreferences("sources", Context.MODE_PRIVATE)
suspend fun get(url: String, block: (Request.Builder.() -> Unit)? = null): Response {
val request = Request.Builder()
.get()
.url(url)
if (block != null) {
request.block()
}
return okHttp.newCall(request.build()).await()
}
suspend fun post(
url: String,
form: Map<String, String>,
block: (Request.Builder.() -> Unit)? = null
): Response {
val body = FormBody.Builder()
form.forEach { (k, v) ->
body.addEncoded(k, v)
}
val request = Request.Builder()
.post(body.build())
.url(url)
if (block != null) {
request.block()
}
return okHttp.newCall(request.build()).await()
}
fun getStringOption(name: String, default: String? = null) =
preferences.getString(name, default)
fun getIntOption(name: String, default: Int) = preferences.getInt(name, default)
fun getBooleanOption(name: String, default: Boolean) = preferences.getBoolean(name, default)
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.domain
import org.koin.core.KoinComponent
import org.koin.core.get
import org.koitharu.kotatsu.core.model.MangaSource
object MangaProviderFactory : KoinComponent {
private val loaderContext get() = get<MangaLoaderContext>()
fun create(source: MangaSource): MangaRepository {
val constructor = source.cls.getConstructor(MangaLoaderContext::class.java)
return constructor.newInstance(loaderContext)
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.domain
import org.koitharu.kotatsu.core.model.*
abstract class MangaRepository(protected val loaderContext: MangaLoaderContext) {
open val sortOrders: Set<SortOrder> get() = emptySet()
open val isSearchAvailable get() = true
abstract suspend fun getList(offset: Int, query: String? = null, sortOrder: SortOrder? = null, tags: Set<String>? = null): List<Manga>
abstract suspend fun getDetails(manga: Manga) : Manga
abstract suspend fun getPages(chapter: MangaChapter) : List<MangaPage>
open suspend fun getPageFullUrl(page: MangaPage) : String = page.url
}

View File

@@ -0,0 +1,3 @@
package org.koitharu.kotatsu.domain.exceptions
class ParseException(message: String? = null, cause: Throwable? = null) : RuntimeException(message, cause)

View File

@@ -0,0 +1,70 @@
package org.koitharu.kotatsu.domain.repository
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
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") ?: 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 getPages(chapter: MangaChapter): List<MangaPage> {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.ui.common
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.Toolbar
import moxy.MvpAppCompatActivity
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.R
abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
override fun setContentView(layoutResID: Int) {
super.setContentView(layoutResID)
setupToolbar()
}
override fun setContentView(view: View?) {
super.setContentView(view)
setupToolbar()
}
private fun setupToolbar() {
(findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
onBackPressed()
true
} else super.onOptionsItemSelected(item)
}

View File

@@ -0,0 +1,17 @@
package org.koitharu.kotatsu.ui.common
import android.os.Parcelable
import androidx.annotation.IdRes
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.RecyclerView
import moxy.MvpAppCompatFragment
import org.koitharu.kotatsu.utils.delegates.ParcelableArgumentDelegate
import org.koitharu.kotatsu.utils.delegates.StringArgumentDelegate
abstract class BaseFragment(@LayoutRes contentLayoutId: Int) :
MvpAppCompatFragment(contentLayoutId) {
fun stringArg(name: String) = StringArgumentDelegate(name)
fun <T : Parcelable> arg(name: String) = ParcelableArgumentDelegate<T>(name)
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.ui.common
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import moxy.MvpPresenter
import moxy.MvpView
import org.koin.core.KoinComponent
import kotlin.coroutines.CoroutineContext
abstract class BasePresenter<V : MvpView> : MvpPresenter<V>(), KoinComponent, CoroutineScope {
private val job = SupervisorJob()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
override fun onDestroy() {
coroutineContext.cancelChildren()
super.onDestroy()
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.ui.common.list
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import java.util.*
class AdapterUpdater<T>(oldList: List<T>, newList: List<T>, getId: (T) -> Long) {
private val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
getId(oldList[oldItemPosition]) == getId(newList[newItemPosition])
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
Objects.equals(oldList[oldItemPosition], newList[newItemPosition])
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
})
operator fun invoke(adapter: RecyclerView.Adapter<*>) {
diff.dispatchUpdatesTo(adapter)
}
}

View File

@@ -0,0 +1,82 @@
package org.koitharu.kotatsu.ui.common.list
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.utils.ext.replaceWith
abstract class BaseRecyclerAdapter<T>(private val onItemClickListener: ((T) -> Unit)? = null) :
RecyclerView.Adapter<BaseViewHolder<T>>(),
KoinComponent {
private val dataSet = ArrayList<T>()
init {
@Suppress("LeakingThis")
setHasStableIds(true)
}
override fun onBindViewHolder(holder: BaseViewHolder<T>, position: Int) {
holder.bind(dataSet[position])
}
fun getItem(position: Int) = dataSet[position]
override fun getItemId(position: Int) = onGetItemId(dataSet[position])
protected fun findItemById(id: Long) = dataSet.find { x -> onGetItemId(x) == id }
protected fun findItemPositionById(id: Long) =
dataSet.indexOfFirst { x -> onGetItemId(x) == id }
fun replaceData(newData: List<T>) {
val updater = AdapterUpdater(dataSet, newData, this::onGetItemId)
dataSet.replaceWith(newData)
updater(this)
}
fun appendData(newData: List<T>) {
val pos = dataSet.size
dataSet.addAll(newData)
notifyItemRangeInserted(pos, newData.size)
}
fun appendItem(newItem: T) {
dataSet.add(newItem)
notifyItemInserted(dataSet.lastIndex)
}
fun removeItem(item: T) {
removeItemAt(dataSet.indexOf(item))
}
fun removeItemAt(position: Int) {
if (position in dataSet.indices) {
dataSet.removeAt(position)
notifyItemRemoved(position)
}
}
fun clearData() {
dataSet.clear()
notifyDataSetChanged()
}
final override fun getItemCount() = dataSet.size
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T> {
return onCreateViewHolder(parent).also { holder ->
if (onItemClickListener != null) {
holder.itemView.setOnClickListener {
onItemClickListener.invoke(holder.requireData())
}
}
}.also(this::onViewHolderCreated)
}
protected open fun onViewHolderCreated(holder: BaseViewHolder<T>) = Unit
protected abstract fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<T>
protected abstract fun onGetItemId(item: T): Long
}

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.ui.common.list
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.extensions.LayoutContainer
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.utils.ext.inflate
abstract class BaseViewHolder<T> protected constructor(view: View) :
RecyclerView.ViewHolder(view), LayoutContainer, KoinComponent {
constructor(parent: ViewGroup, @LayoutRes resId: Int) : this(parent.inflate(resId))
override val containerView: View?
get() = itemView
protected var boundData: T? = null
private set
val context get() = itemView.context!!
fun bind(data: T) {
boundData = data
onBind(data)
}
fun requireData() = boundData ?: throw IllegalStateException("Calling requireData() before bind()")
abstract fun onBind(data: T)
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.ui.common.list
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) :
RecyclerView.OnScrollListener() {
constructor(offset: Int = 0) : this(offset, offset)
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToTop(recyclerView)
return
}
val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom && firstVisibleItemPosition >= 0) {
onScrolledToEnd(recyclerView)
}
}
abstract fun onScrolledToTop(recyclerView: RecyclerView)
abstract fun onScrolledToEnd(recyclerView: RecyclerView)
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.ui.common.list
import androidx.recyclerview.widget.RecyclerView
class PaginationScrollListener(offset: Int, private val callback: Callback) : BoundsScrollListener(0, offset) {
private var lastTotalCount = 0
override fun onScrolledToTop(recyclerView: RecyclerView) = Unit
override fun onScrolledToEnd(recyclerView: RecyclerView) {
val total = recyclerView.adapter?.itemCount ?: 0
if (total > lastTotalCount) {
callback.onRequestMoreItems(total)
lastTotalCount = total
} else if (total < lastTotalCount) {
lastTotalCount = total
}
}
interface Callback {
fun onRequestMoreItems(offset: Int)
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.ui.common.list
import android.graphics.Rect
import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDecoration() {
private val halfSpacing = spacing / 2
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val spans = (parent.layoutManager as? GridLayoutManager)?.spanCount ?: 1
val position = parent.getChildAdapterPosition(view)
outRect.set(
if (position % spans == 0) spacing else halfSpacing,
if (position < spans) spacing else halfSpacing,
if ((position + 1) % spans == 0) spacing else halfSpacing,
spacing //TODO check bottom
)
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.ui.common.widgets
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
class CoverImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val originalWidth = MeasureSpec.getSize(widthMeasureSpec)
val calculatedHeight: Int = (originalWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).toInt()
super.onMeasure(
MeasureSpec.makeMeasureSpec(originalWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(calculatedHeight, MeasureSpec.EXACTLY)
)
}
private companion object {
const val ASPECT_RATIO_HEIGHT = 18f
const val ASPECT_RATIO_WIDTH = 13f
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.ui.common.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
class RectFrameLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
public override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, widthMeasureSpec)
}
}

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.ui.main
import android.content.res.Configuration
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.ActionBarDrawerToggle
import kotlinx.android.synthetic.main.activity_main.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.main.list.MangaListFragment
class MainActivity : BaseActivity() {
private lateinit var drawerToggle: ActionBarDrawerToggle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
drawerToggle = ActionBarDrawerToggle(this, drawer, toolbar, R.string.open_menu, R.string.close_menu)
drawer.addDrawerListener(drawerToggle)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setHomeButtonEnabled(true)
if (!supportFragmentManager.isStateSaved) {
supportFragmentManager.beginTransaction()
.replace(R.id.container, MangaListFragment.newInstance(MangaSource.READMANGA_RU))
.commit()
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
drawerToggle.syncState()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
drawerToggle.onConfigurationChanged(newConfig)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return drawerToggle.onOptionsItemSelected(item) || super.onOptionsItemSelected(item)
}
}

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.ui.main.list
import android.view.ViewGroup
import coil.api.load
import coil.request.RequestDisposable
import kotlinx.android.synthetic.main.item_manga_grid.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
class MangaGridHolder(parent: ViewGroup) : BaseViewHolder<Manga>(parent, R.layout.item_manga_grid) {
private var coverRequest: RequestDisposable? = null
override fun onBind(data: Manga) {
coverRequest?.dispose()
textView_title.text = data.title
coverRequest = imageView_cover.load(data.coverUrl) {
crossfade(true)
}
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.ui.main.list
import android.view.ViewGroup
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
class MangaListAdapter(onItemClickListener: ((Manga) -> Unit)?) :
BaseRecyclerAdapter<Manga>(onItemClickListener) {
override fun onCreateViewHolder(parent: ViewGroup) = MangaGridHolder(parent)
override fun onGetItemId(item: Manga) = item.id
}

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.ui.main.list
import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
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.utils.ext.hasItems
import org.koitharu.kotatsu.utils.ext.withArgs
class MangaListFragment : BaseFragment(R.layout.fragment_list), MangaListView,
PaginationScrollListener.Callback {
private val presenter by moxyPresenter(factory = ::MangaListPresenter)
private val source by arg<MangaSource>(ARG_SOURCE)
private lateinit var adapter: MangaListAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = MangaListAdapter {
}
recyclerView.addItemDecoration(SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)))
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(PaginationScrollListener(4, this))
swipeRefreshLayout.setOnRefreshListener {
presenter.loadList(source, 0)
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
presenter.loadList(source, 0)
}
override fun onRequestMoreItems(offset: Int) {
presenter.loadList(source, offset)
}
override fun onListChanged(list: List<Manga>) {
adapter.replaceData(list)
}
override fun onListAppended(list: List<Manga>) {
adapter.appendData(list)
}
override fun onLoadingChanged(isLoading: Boolean) {
val hasItems = recyclerView.hasItems
progressBar.isVisible = isLoading && !hasItems
swipeRefreshLayout.isRefreshing = isLoading && hasItems
swipeRefreshLayout.isEnabled = !progressBar.isVisible
}
companion object {
private const val ARG_SOURCE = "provider"
fun newInstance(provider: MangaSource) = MangaListFragment().withArgs(1) {
putParcelable(ARG_SOURCE, provider)
}
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.ui.main.list
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.InjectViewState
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BasePresenter
@InjectViewState
class MangaListPresenter : BasePresenter<MangaListView>() {
fun loadList(source: MangaSource, offset: Int) {
launch {
viewState.onLoadingChanged(true)
try {
val list = withContext(Dispatchers.IO) {
MangaProviderFactory.create(source)
.getList(offset)
}
if (offset == 0) {
viewState.onListChanged(list)
} else {
viewState.onListAppended(list)
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
viewState.onLoadingChanged(false)
}
}
}
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.ui.main.list
import moxy.MvpView
import moxy.viewstate.strategy.AddToEndSingleStrategy
import moxy.viewstate.strategy.AddToEndSingleTagStrategy
import moxy.viewstate.strategy.AddToEndStrategy
import moxy.viewstate.strategy.StateStrategyType
import org.koitharu.kotatsu.core.model.Manga
interface MangaListView : MvpView {
@StateStrategyType(AddToEndSingleTagStrategy::class, tag = "content")
fun onListChanged(list: List<Manga>)
@StateStrategyType(AddToEndStrategy::class, tag = "content")
fun onListAppended(list: List<Manga>)
@StateStrategyType(AddToEndSingleStrategy::class)
fun onLoadingChanged(isLoading: Boolean)
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.utils.delegates
import android.os.Parcelable
import androidx.fragment.app.Fragment
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
class ParcelableArgumentDelegate<T : Parcelable>(private val name: String) : ReadOnlyProperty<Fragment, T> {
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
return thisRef.requireArguments().getParcelable(name)!!
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.utils.delegates
import androidx.fragment.app.Fragment
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
class StringArgumentDelegate(private val name: String) : ReadOnlyProperty<Fragment, String?> {
override fun getValue(thisRef: Fragment, property: KProperty<*>): String? {
return thisRef.arguments?.getString(name)
}
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.utils.ext
fun <T> MutableCollection<T>.replaceWith(subject: Iterable<T>) {
clear()
addAll(subject)
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.utils.ext
import org.koitharu.kotatsu.BuildConfig
inline fun <T, R> T.safe(action: T.() -> R?) = try {
this.action()
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
null
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.utils.ext
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
suspend fun Call.await() = suspendCancellableCoroutine<Response> { cont ->
this.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (!cont.isCancelled) {
cont.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
cont.invokeOnCancellation {
safe {
this.cancel()
}
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.utils.ext
import android.annotation.SuppressLint
import org.intellij.lang.annotations.PrintFormat
import java.text.SimpleDateFormat
import java.util.*
@SuppressLint("SimpleDateFormat")
fun Date.format(pattern: String): String = SimpleDateFormat(pattern).format(this)
fun Date.calendar(): Calendar = Calendar.getInstance().also {
it.time = this
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.utils.ext
import android.os.Bundle
import androidx.fragment.app.Fragment
fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val b = Bundle(size)
b.block()
this.arguments = b
return this
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.utils.ext
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
fun Response.parseHtml(): Document {
val stream = body?.byteStream() ?: throw NullPointerException("Response body is null")
val charset = body!!.contentType()?.charset()?.name()
val doc = Jsoup.parse(
stream,
charset,
this.request.url.toString()
)
closeQuietly()
return doc
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.utils.ext
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.*
fun Number?.asBoolean() = (this?.toInt() ?: 0) > 0
fun Number.format(decimals: Int = 0, decPoint: Char = '.', thousandsSep: Char? = ' '): String {
val formatter = NumberFormat.getInstance(Locale.US) as DecimalFormat
val symbols = formatter.decimalFormatSymbols
if (thousandsSep != null) {
symbols.groupingSeparator = thousandsSep
formatter.isGroupingUsed = true
} else {
formatter.isGroupingUsed = false
}
symbols.decimalSeparator = decPoint
formatter.decimalFormatSymbols = symbols
formatter.minimumFractionDigits = decimals
formatter.maximumFractionDigits = decimals
return when (this) {
is Float,
is Double -> formatter.format(this.toDouble())
else -> formatter.format(this.toLong())
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.utils.ext
import android.content.res.Resources
import androidx.annotation.Px
import kotlin.math.roundToInt
@Px
fun Resources.resolveDp(dp: Int) = (dp * displayMetrics.density).roundToInt()
@Px
fun Resources.resolveDp(dp: Float) = dp * displayMetrics.density

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.utils.ext
fun String.longHashCode(): Long {
var h = 1125899906842597L
val len: Int = this.length
for (i in 0 until len) {
h = 31 * h + this[i].toLong()
}
return h
}

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
import android.graphics.Color
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.core.content.res.use
@Px
fun Context.getThemeDimen(@AttrRes resId: Int) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimension(0, 0f)
}
fun Context.getThemeDrawable(@AttrRes resId: Int) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDrawable(0)
}
@ColorInt
fun Context.getThemeColor(@AttrRes resId: Int, @ColorInt default: Int = Color.TRANSPARENT) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getColor(0, default)
}

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.utils.ext
import android.app.Activity
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
fun View.hideKeyboard() {
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(this.windowToken, 0)
}
fun View.showKeyboard() {
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(this, 0)
}
val EditText.plainText
get() = text?.toString().orEmpty()
inline fun <reified T : View> ViewGroup.inflate(@LayoutRes resId: Int) =
LayoutInflater.from(context).inflate(resId, this, false) as T
val TextView.hasText get() = !text.isNullOrEmpty()
fun RecyclerView.lookupSpanSize(callback: (Int) -> Int) {
(layoutManager as? GridLayoutManager)?.spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = callback(position)
}
}
val RecyclerView.hasItems: Boolean
get() = (adapter?.itemCount ?: 0) > 0
var TextView.drawableStart: Drawable?
get() = compoundDrawablesRelative[0]
set(value) {
val old = compoundDrawablesRelative
setCompoundDrawablesRelativeWithIntrinsicBounds(value, old[1], old[2], old[3])
}
var TextView.drawableEnd: Drawable?
get() = compoundDrawablesRelative[2]
set(value) {
val old = compoundDrawablesRelative
setCompoundDrawablesRelativeWithIntrinsicBounds(old[0], old[1], value, old[3])
}