Initial commit
This commit is contained in:
53
app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
Normal file
53
app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
Normal 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"
|
||||
)
|
||||
}
|
||||
12
app/src/main/java/org/koitharu/kotatsu/core/db/HistoryDao.kt
Normal file
12
app/src/main/java/org/koitharu/kotatsu/core/db/HistoryDao.kt
Normal 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>
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
21
app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
Normal file
21
app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
enum class MangaState {
|
||||
ONGOING, FINISHED
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
enum class SortOrder {
|
||||
ALPHABETICAL, POPULARITY, UPDATED, NEWEST
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.koitharu.kotatsu.domain.exceptions
|
||||
|
||||
class ParseException(message: String? = null, cause: Throwable? = null) : RuntimeException(message, cause)
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)!!
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
fun <T> MutableCollection<T>.replaceWith(subject: Iterable<T>) {
|
||||
clear()
|
||||
addAll(subject)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/org/koitharu/kotatsu/utils/ext/DateExt.kt
Normal file
13
app/src/main/java/org/koitharu/kotatsu/utils/ext/DateExt.kt
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
18
app/src/main/java/org/koitharu/kotatsu/utils/ext/JsoupExt.kt
Normal file
18
app/src/main/java/org/koitharu/kotatsu/utils/ext/JsoupExt.kt
Normal 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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
22
app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt
Normal file
22
app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt
Normal 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)
|
||||
}
|
||||
55
app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
Normal file
55
app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
Normal 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])
|
||||
}
|
||||
Reference in New Issue
Block a user