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

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

77
app/build.gradle Normal file
View File

@@ -0,0 +1,77 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
def gitCommits = 'git rev-list --all --count'.execute([], rootDir).text.trim().toInteger()
def gitBranch = 'git branch --show-current'.execute([], rootDir).text.trim()
android {
compileSdkVersion 29
defaultConfig {
applicationId "org.koitharu.kotatsu"
minSdkVersion 21
targetSdkVersion 29
versionCode gitCommits
versionName "0.1"
buildConfigField 'String', 'GIT_BRANCH', "\"${gitBranch}\""
}
archivesBaseName = "kotatsu_${gitCommits}"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
lintOptions {
disable 'MissingTranslation'
abortOnError false
}
}
androidExtensions {
experimental = true
}
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3'
implementation 'androidx.core:core-ktx:1.1.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-alpha03'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01'
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0-alpha01'
implementation 'androidx.preference:preference:1.1.0'
implementation 'com.google.android.material:material:1.2.0-alpha04'
implementation 'androidx.room:room-runtime:2.2.3'
implementation 'androidx.room:room-ktx:2.2.3'
kapt 'androidx.room:room-compiler:2.2.3'
implementation 'com.github.moxy-community:moxy:2.0.2'
implementation 'com.github.moxy-community:moxy-androidx:2.0.2'
implementation 'com.github.moxy-community:moxy-material:2.0.2'
implementation 'com.github.moxy-community:moxy-ktx:2.0.2'
kapt 'com.github.moxy-community:moxy-compiler:2.0.2'
implementation 'com.squareup.okhttp3:okhttp:4.3.1'
implementation 'com.squareup.okio:okio:2.4.3'
implementation 'org.jsoup:jsoup:1.12.1'
implementation 'org.koin:koin-android:2.0.1'
implementation 'io.coil-kt:coil:0.9.2'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
testImplementation 'junit:junit:4.13'
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="org.koitharu.kotatsu">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:name="org.koitharu.kotatsu.KotatsuApp"
android:theme="@style/AppTheme"
android:fullBackupContent="@xml/backup_descriptor">
<activity android:name="org.koitharu.kotatsu.ui.main.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

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])
}

View File

@@ -0,0 +1,31 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
</vector>

View File

@@ -0,0 +1,171 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path android:pathData="M0,0h24v24H0V0z M 0,0" />
<path
android:fillColor="@android:color/white"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4V6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
</group>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M18,4v16L6,20L6,8.83L10.83,4L18,4m0,-2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,4c0,-1.1 -0.9,-2 -2,-2zM9,7h2v4L9,11zM12,7h2v4h-2zM15,7h2v4h-2z" />
</vector>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout
android:id="@+id/drawer"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.MainActivity"
tools:openDrawer="start">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar">
<androidx.appcompat.widget.Toolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways"
app:popupTheme="@style/ThemeOverlay.MaterialComponents.Light" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:id="@id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.navigation.NavigationView
android:layout_width="260dp"
android:layout_height="match_parent"
android:layout_gravity="start"
app:menu="@menu/nav_drawer" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="3" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout
android:id="@+id/layout_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/textView_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
tools:text="@tools:sample/lorem" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<org.koitharu.kotatsu.ui.common.widgets.CoverImageView
android:id="@+id/imageView_cover"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical|start"
android:lines="2"
android:padding="6dp"
android:text="?android:textColorPrimary"
tools:text="@tools:sample/lorem" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group>
<item
android:id="@+id/nav_local_storage"
android:icon="@drawable/ic_storage"
android:title="@string/local_storage" />
<item
android:id="@+id/nav_favourites"
android:icon="@drawable/ic_star_half"
android:title="@string/favourites" />
<item
android:id="@+id/nav_history"
android:icon="@drawable/ic_history"
android:title="@string/history" />
</group>
<group android:id="@+id/group_menu">
<item
android:id="@+id/nav_test"
android:title="Test" />
</group>
<!--
<item android:title="Title 1">
<menu>
<item
android:id="@+id/nav_item_six"
android:icon="@drawable/ic_drafts_black_24dp"
android:title="Item 6" />
<item
android:id="@+id/nav_item_seven"
android:icon="@drawable/ic_drafts_black_24dp"
android:title="Item 7" />
</menu>
</item>-->
</menu>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#6200EE</color>
<color name="colorPrimaryDark">#3700B3</color>
<color name="colorAccent">#03DAC5</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="grid_spacing">5dp</dimen>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="toolbar" type="id" />
<item name="container" type="id" />
</resources>

View File

@@ -0,0 +1,8 @@
<resources>
<string name="app_name" translatable="false">Kotatsu</string>
<string name="close_menu">Close menu</string>
<string name="open_menu">Open menu</string>
<string name="local_storage">Local storage</string>
<string name="favourites">Favourites</string>
<string name="history">History</string>
</resources>

View File

@@ -0,0 +1,10 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- TODO: Exclude specific shared preferences that contain GCM registration Id -->
<!-- https://developer.android.com/guide/topics/data/autobackup -->
</full-backup-content>