This commit is contained in:
Admin
2020-02-02 12:42:22 +02:00
parent 82fda9394d
commit 6eea278b4d
37 changed files with 569 additions and 133 deletions

View File

@@ -4,6 +4,7 @@
<w>koin</w> <w>koin</w>
<w>kotatsu</w> <w>kotatsu</w>
<w>manga</w> <w>manga</w>
<w>upsert</w>
</words> </words>
</dictionary> </dictionary>
</component> </component>

View File

@@ -54,5 +54,5 @@ class KotatsuApp : Application() {
applicationContext, applicationContext,
MangaDatabase::class.java, MangaDatabase::class.java,
"kotatsu-db" "kotatsu-db"
) ).fallbackToDestructiveMigration() //TODO remove
} }

View File

@@ -1,12 +1,41 @@
package org.koitharu.kotatsu.core.db package org.koitharu.kotatsu.core.db
import androidx.room.Dao import androidx.room.*
import androidx.room.Query
import org.koitharu.kotatsu.core.db.entity.HistoryEntity import org.koitharu.kotatsu.core.db.entity.HistoryEntity
import org.koitharu.kotatsu.core.db.entity.HistoryWithManga
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Dao @Dao
interface HistoryDao { abstract class HistoryDao {
/**
* @hide
*/
@Transaction
@Query("SELECT * FROM history ORDER BY :orderBy LIMIT :limit OFFSET :offset")
abstract suspend fun getAll(offset: Int, limit: Int, orderBy: String): List<HistoryWithManga>
@Query("DELETE FROM history")
abstract suspend fun clear()
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: HistoryEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insertManga(manga: MangaEntity): Long
@Query("UPDATE history SET page = :page, chapter_id = :chapterId, updated_at = :updatedAt WHERE manga_id = :mangaId")
abstract suspend fun update(mangaId: Long, page: Int, chapterId: Long, updatedAt: Long): Int
suspend fun update(entity: HistoryWithManga) = update(entity.manga.id, entity.history.page, entity.history.chapterId, entity.history.updatedAt)
@Transaction
suspend open fun upsert(entity: HistoryWithManga) {
if (update(entity) == 0) {
insertManga(entity.manga)
insert(entity.history)
}
}
@Query("SELECT * FROM history")
suspend fun getAll(): List<HistoryEntity>
} }

View File

@@ -3,6 +3,13 @@ package org.koitharu.kotatsu.core.db
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import org.koitharu.kotatsu.core.db.entity.HistoryEntity import org.koitharu.kotatsu.core.db.entity.HistoryEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Database(entities = [HistoryEntity::class], version = 1) @Database(entities = [MangaEntity::class, TagEntity::class, HistoryEntity::class], version = 1)
abstract class MangaDatabase : RoomDatabase() abstract class MangaDatabase : RoomDatabase() {
abstract fun historyDao(): HistoryDao
abstract fun tagsDao(): TagsDao
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.core.db
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
interface TagsDao {
@Transaction
@Query("SELECT * FROM tags")
fun getAllTags(): List<TagEntity>
}

View File

@@ -1,9 +1,25 @@
package org.koitharu.kotatsu.core.db.entity package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.*
@Entity(tableName = "history") @Entity(tableName = "history")
data class HistoryEntity( data class HistoryEntity(
@PrimaryKey val id: Long @PrimaryKey(autoGenerate = false)
) @ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int
) {
fun toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page
)
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded
import androidx.room.Relation
data class HistoryWithManga(
@Embedded val history: HistoryEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id"
)
val manga: MangaEntity
)

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaState
@Entity(tableName = "manga")
data class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "localized_title") val localizedTitle: String? = null,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "rating") val rating: Float = Manga.NO_RATING, //normalized value [0..1] or -1
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String? = null,
@ColumnInfo(name = "summary") val summary: String,
@ColumnInfo(name = "state") val state: String? = null,
@ColumnInfo(name = "source") val source: String
) {
fun toManga() = Manga(
id = this.id,
title = this.title,
localizedTitle = this.localizedTitle,
summary = this.summary,
state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
url = this.url,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
source = MangaSource.valueOf(this.source)
// tags = this.tags.map(TagEntity::toMangaTag).toSet()
)
companion object {
fun from(manga: Manga) = MangaEntity(
id = manga.id,
url = manga.url,
source = manga.source.name,
largeCoverUrl = manga.largeCoverUrl,
coverUrl = manga.coverUrl,
localizedTitle = manga.localizedTitle,
rating = manga.rating,
state = manga.state?.name,
summary = manga.summary,
// tags = manga.tags.map(TagEntity.Companion::fromMangaTag),
title = manga.title
)
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
@Entity(tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"])
data class MangaTagsEntity(
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "tag_id") val tagId: Long
)

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.longHashCode
@Entity(tableName = "tags")
data class TagEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "tag_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "source") val source: String
) {
fun toMangaTag() = MangaTag(
key = this.key,
title = this.title,
source = MangaSource.valueOf(this.source)
)
companion object {
fun fromMangaTag(tag: MangaTag) = TagEntity(
title = tag.title,
key = tag.key,
source = tag.source.name,
id = "${tag.key}_${tag.source.name}".longHashCode()
)
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import java.util.*
@Parcelize
data class MangaHistory(
val createdAt: Date,
val updatedAt: Date,
val chapterId: Long,
val page: Int
) : Parcelable

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.model
data class MangaInfo <E>(
val manga: Manga,
val extra: E
)

View File

@@ -4,7 +4,6 @@ import android.os.Parcelable
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import org.koitharu.kotatsu.domain.MangaRepository import org.koitharu.kotatsu.domain.MangaRepository
import org.koitharu.kotatsu.domain.repository.ReadmangaRepository import org.koitharu.kotatsu.domain.repository.ReadmangaRepository
import kotlin.reflect.KClass
@Parcelize @Parcelize
enum class MangaSource(val title: String, val cls: Class<out MangaRepository>): Parcelable { enum class MangaSource(val title: String, val cls: Class<out MangaRepository>): Parcelable {

View File

@@ -6,5 +6,6 @@ import kotlinx.android.parcel.Parcelize
@Parcelize @Parcelize
data class MangaTag( data class MangaTag(
val title: String, val title: String,
val key: String val key: String,
val source: MangaSource
) : Parcelable ) : Parcelable

View File

@@ -3,8 +3,6 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Resources import android.content.res.Resources
import androidx.core.content.edit
import androidx.lifecycle.LifecycleOwner
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.delegates.prefs.EnumPreferenceDelegate import org.koitharu.kotatsu.utils.delegates.prefs.EnumPreferenceDelegate
@@ -13,7 +11,7 @@ class AppSettings private constructor(private val resources: Resources, private
constructor(context: Context) : this(context.resources, PreferenceManager.getDefaultSharedPreferences(context)) constructor(context: Context) : this(context.resources, PreferenceManager.getDefaultSharedPreferences(context))
var listMode by EnumPreferenceDelegate(ListMode::class.java, resources.getString(R.string.key_list_mode), ListMode.LIST) var listMode by EnumPreferenceDelegate(ListMode::class.java, resources.getString(R.string.key_list_mode), ListMode.DETAILED_LIST)
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener) prefs.registerOnSharedPreferenceChangeListener(listener)

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.domain
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.SortOrder
abstract class BaseMangaRepository(protected val loaderContext: MangaLoaderContext) : MangaRepository {
override val sortOrders: Set<SortOrder> get() = emptySet()
override val isSearchAvailable get() = true
override suspend fun getPageFullUrl(page: MangaPage) : String = page.url
}

View File

@@ -0,0 +1,71 @@
package org.koitharu.kotatsu.domain
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.HistoryEntity
import org.koitharu.kotatsu.core.db.entity.HistoryWithManga
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.model.*
import java.io.Closeable
class HistoryRepository() : KoinComponent, MangaRepository, Closeable {
private val db: MangaDatabase by inject()
override val sortOrders: Set<SortOrder> = setOf(SortOrder.NEWEST, SortOrder.POPULARITY)
override val isSearchAvailable = false
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tags: Set<String>?
): List<Manga> = getHistory(offset, query, sortOrder, tags).map { x -> x.manga }
suspend fun getHistory(
offset: Int,
query: String? = null,
sortOrder: SortOrder? = null,
tags: Set<String>? = null
): List<MangaInfo<MangaHistory>> {
val entities = db.historyDao().getAll(offset, 20, "updated_by")
return entities.map { x -> MangaInfo(x.manga.toManga(), x.history.toMangaHistory()) }
}
override suspend fun getDetails(manga: Manga): Manga {
throw UnsupportedOperationException("History repository does not support getDetails() method")
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
throw UnsupportedOperationException("History repository does not support getPages() method")
}
override suspend fun getPageFullUrl(page: MangaPage) = page.url
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int) {
val dao = db.historyDao()
val entity = HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page
)
dao.upsert(
HistoryWithManga(
history = entity,
manga = MangaEntity.from(manga)
)
)
}
suspend fun clear() {
db.historyDao().clear()
}
override fun close() {
db.close()
}
}

View File

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

View File

@@ -1,14 +1,13 @@
package org.koitharu.kotatsu.domain.repository package org.koitharu.kotatsu.domain.repository
import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.domain.BaseMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.domain.MangaRepository
import org.koitharu.kotatsu.domain.exceptions.ParseException import org.koitharu.kotatsu.domain.exceptions.ParseException
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
class ReadmangaRepository(loaderContext: MangaLoaderContext) : MangaRepository(loaderContext) { class ReadmangaRepository(loaderContext: MangaLoaderContext) : BaseMangaRepository(loaderContext) {
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
@@ -47,7 +46,8 @@ class ReadmangaRepository(loaderContext: MangaLoaderContext) : MangaRepository(l
?.map { ?.map {
MangaTag( MangaTag(
title = it.text(), title = it.text(),
key = it.attr("href").substringAfterLast('/') key = it.attr("href").substringAfterLast('/'),
source = MangaSource.READMANGA_RU
) )
}?.toSet() }?.toSet()
}.orEmpty(), }.orEmpty(),

View File

@@ -10,7 +10,8 @@ import kotlinx.android.synthetic.main.activity_main.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.ui.common.BaseActivity import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.main.list.MangaListFragment import org.koitharu.kotatsu.ui.main.list.history.HistoryListFragment
import org.koitharu.kotatsu.ui.main.list.remote.RemoteListFragment
class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener { class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener {
@@ -29,7 +30,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
navigationView.setNavigationItemSelectedListener(this) navigationView.setNavigationItemSelectedListener(this)
if (!supportFragmentManager.isStateSaved) { if (!supportFragmentManager.isStateSaved) {
setPrimaryFragment(MangaListFragment.newInstance(MangaSource.READMANGA_RU)) setPrimaryFragment(RemoteListFragment.newInstance(MangaSource.READMANGA_RU))
} }
} }
@@ -51,9 +52,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onNavigationItemSelected(item: MenuItem): Boolean { override fun onNavigationItemSelected(item: MenuItem): Boolean {
if (item.groupId == R.id.group_remote_sources) { if (item.groupId == R.id.group_remote_sources) {
val source = MangaSource.values().getOrNull(item.itemId) ?: return false val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(MangaListFragment.newInstance(source)) setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) { } else when (item.itemId) {
R.id.nav_history -> Unit R.id.nav_history -> setPrimaryFragment(HistoryListFragment.newInstance())
R.id.nav_favourites -> Unit R.id.nav_favourites -> Unit
R.id.nav_local_storage -> Unit R.id.nav_local_storage -> Unit
else -> return false else -> return false

View File

@@ -46,6 +46,8 @@ class ListModeSelectDialog : AlertDialogFragment(R.layout.dialog_list_mode), Vie
private const val TAG = "ListModeSelectDialog" private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG) fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm,
TAG
)
} }
} }

View File

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

View File

@@ -1,21 +1,21 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.main.list
import android.view.ViewGroup import android.view.ViewGroup
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaInfo
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
class MangaListAdapter(onItemClickListener: OnRecyclerItemClickListener<Manga>) : class MangaListAdapter<E>(onItemClickListener: OnRecyclerItemClickListener<MangaInfo<E>>) :
BaseRecyclerAdapter<Manga>(onItemClickListener) { BaseRecyclerAdapter<MangaInfo<E>>(onItemClickListener) {
var listMode: ListMode = ListMode.LIST var listMode: ListMode = ListMode.LIST
override fun onCreateViewHolder(parent: ViewGroup) = when(listMode) { override fun onCreateViewHolder(parent: ViewGroup) = when(listMode) {
ListMode.LIST -> MangaListHolder(parent) ListMode.LIST -> MangaListHolder<E>(parent)
ListMode.DETAILED_LIST -> MangaListDetailsHolder(parent) ListMode.DETAILED_LIST -> MangaListDetailsHolder<E>(parent)
ListMode.GRID -> MangaGridHolder(parent) ListMode.GRID -> MangaGridHolder(parent)
} }
override fun onGetItemId(item: Manga) = item.id override fun onGetItemId(item: MangaInfo<E>) = item.manga.id
} }

View File

@@ -8,29 +8,30 @@ import coil.request.RequestDisposable
import kotlinx.android.synthetic.main.item_manga_list_details.* import kotlinx.android.synthetic.main.item_manga_list_details.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaInfo
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import kotlin.math.roundToInt import kotlin.math.roundToInt
class MangaListDetailsHolder(parent: ViewGroup) : BaseViewHolder<Manga>(parent, R.layout.item_manga_list_details) { class MangaListDetailsHolder<E>(parent: ViewGroup) : BaseViewHolder<MangaInfo<E>>(parent, R.layout.item_manga_list_details) {
private var coverRequest: RequestDisposable? = null private var coverRequest: RequestDisposable? = null
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onBind(data: Manga) { override fun onBind(data: MangaInfo<E>) {
coverRequest?.dispose() coverRequest?.dispose()
textView_title.text = data.title textView_title.text = data.manga.title
textView_subtitle.textAndVisible = data.localizedTitle textView_subtitle.textAndVisible = data.manga.localizedTitle
coverRequest = imageView_cover.load(data.coverUrl) { coverRequest = imageView_cover.load(data.manga.coverUrl) {
crossfade(true) crossfade(true)
} }
if(data.rating == Manga.NO_RATING) { if(data.manga.rating == Manga.NO_RATING) {
textView_rating.isVisible = false textView_rating.isVisible = false
} else { } else {
textView_rating.text = "${(data.rating * 10).roundToInt()}/10" textView_rating.text = "${(data.manga.rating * 10).roundToInt()}/10"
textView_rating.isVisible = true textView_rating.isVisible = true
} }
textView_tags.text = data.tags.joinToString(", ") { textView_tags.text = data.manga.tags.joinToString(", ") {
it.title it.title
} }
} }

View File

@@ -13,26 +13,23 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_list.* import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaInfo
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BaseFragment import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener
import org.koitharu.kotatsu.ui.common.list.SpacingItemDecoration import org.koitharu.kotatsu.ui.common.list.SpacingItemDecoration
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.firstItem
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hasItems
class MangaListFragment : BaseFragment(R.layout.fragment_list), MangaListView, abstract class MangaListFragment <E> : BaseFragment(R.layout.fragment_list), MangaListView<E>,
PaginationScrollListener.Callback, OnRecyclerItemClickListener<Manga> { PaginationScrollListener.Callback, OnRecyclerItemClickListener<MangaInfo<E>> {
private val presenter by moxyPresenter(factory = ::MangaListPresenter) private lateinit var adapter: MangaListAdapter<E>
private val source by arg<MangaSource>(ARG_SOURCE)
private lateinit var adapter: MangaListAdapter
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -46,7 +43,7 @@ class MangaListFragment : BaseFragment(R.layout.fragment_list), MangaListView,
recyclerView.adapter = adapter recyclerView.adapter = adapter
recyclerView.addOnScrollListener(PaginationScrollListener(4, this)) recyclerView.addOnScrollListener(PaginationScrollListener(4, this))
swipeRefreshLayout.setOnRefreshListener { swipeRefreshLayout.setOnRefreshListener {
presenter.loadList(source, 0) onRequestMoreItems(0)
} }
settings.subscribe(this) settings.subscribe(this)
} }
@@ -58,7 +55,9 @@ class MangaListFragment : BaseFragment(R.layout.fragment_list), MangaListView,
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState) super.onActivityCreated(savedInstanceState)
presenter.loadList(source, 0) if (!recyclerView.hasItems) {
onRequestMoreItems(0)
}
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -74,19 +73,16 @@ class MangaListFragment : BaseFragment(R.layout.fragment_list), MangaListView,
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun onItemClick(item: Manga, position: Int, view: View) { override fun onItemClick(item: MangaInfo<E>, position: Int, view: View) {
startActivity(MangaDetailsActivity.newIntent(context ?: return, item)) startActivity(MangaDetailsActivity.newIntent(context ?: return, item.manga))
} }
override fun onRequestMoreItems(offset: Int) { override fun onListChanged(list: List<MangaInfo<E>>) {
presenter.loadList(source, offset)
}
override fun onListChanged(list: List<Manga>) {
adapter.replaceData(list) adapter.replaceData(list)
layout_holder.isVisible = list.isEmpty()
} }
override fun onListAppended(list: List<Manga>) { override fun onListAppended(list: List<MangaInfo<E>>) {
adapter.appendData(list) adapter.appendData(list)
} }
@@ -101,10 +97,9 @@ class MangaListFragment : BaseFragment(R.layout.fragment_list), MangaListView,
progressBar.isVisible = isLoading && !hasItems progressBar.isVisible = isLoading && !hasItems
swipeRefreshLayout.isRefreshing = isLoading && hasItems swipeRefreshLayout.isRefreshing = isLoading && hasItems
swipeRefreshLayout.isEnabled = !progressBar.isVisible swipeRefreshLayout.isEnabled = !progressBar.isVisible
} if (isLoading) {
layout_holder.isVisible = false
override fun getTitle(): CharSequence? { }
return source.title
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
@@ -133,13 +128,4 @@ class MangaListFragment : BaseFragment(R.layout.fragment_list), MangaListView,
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
recyclerView.firstItem = position recyclerView.firstItem = position
} }
companion object {
private const val ARG_SOURCE = "provider"
fun newInstance(provider: MangaSource) = MangaListFragment().withArgs(1) {
putParcelable(ARG_SOURCE, provider)
}
}
} }

View File

@@ -5,19 +5,19 @@ import coil.api.load
import coil.request.RequestDisposable import coil.request.RequestDisposable
import kotlinx.android.synthetic.main.item_manga_list.* import kotlinx.android.synthetic.main.item_manga_list.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaInfo
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
class MangaListHolder(parent: ViewGroup) : BaseViewHolder<Manga>(parent, R.layout.item_manga_list) { class MangaListHolder<E>(parent: ViewGroup) : BaseViewHolder<MangaInfo<E>>(parent, R.layout.item_manga_list) {
private var coverRequest: RequestDisposable? = null private var coverRequest: RequestDisposable? = null
override fun onBind(data: Manga) { override fun onBind(data: MangaInfo<E>) {
coverRequest?.dispose() coverRequest?.dispose()
textView_title.text = data.title textView_title.text = data.manga.title
textView_subtitle.textAndVisible = data.localizedTitle textView_subtitle.textAndVisible = data.manga.localizedTitle
coverRequest = imageView_cover.load(data.coverUrl) { coverRequest = imageView_cover.load(data.manga.coverUrl) {
crossfade(true) crossfade(true)
} }
} }

View File

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

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.ui.main.list.history
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.main.list.MangaListFragment
import org.koitharu.kotatsu.ui.main.list.MangaListView
class HistoryListFragment : MangaListFragment<MangaHistory>(), MangaListView<MangaHistory>{
private val presenter by moxyPresenter(factory = ::HistoryListPresenter)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
textView_holder.setText(R.string.history_is_empty)
}
override fun onRequestMoreItems(offset: Int) {
presenter.loadList(offset)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_history, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem) = when(item.itemId) {
R.id.action_clear_history -> {
presenter.clearHistory()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun getTitle(): CharSequence? {
return getString(R.string.history)
}
companion object {
fun newInstance() = HistoryListFragment()
}
}

View File

@@ -0,0 +1,70 @@
package org.koitharu.kotatsu.ui.main.list.history
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.InjectViewState
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.domain.HistoryRepository
import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.ui.main.list.MangaListView
@InjectViewState
class HistoryListPresenter : BasePresenter<MangaListView<MangaHistory>>() {
private lateinit var repository: HistoryRepository
override fun onFirstViewAttach() {
repository = HistoryRepository()
super.onFirstViewAttach()
}
fun loadList(offset: Int) {
launch {
viewState.onLoadingChanged(true)
try {
val list = withContext(Dispatchers.IO) {
repository.getHistory(offset = offset)
}
if (offset == 0) {
viewState.onListChanged(list)
} else {
viewState.onListAppended(list)
}
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
viewState.onError(e)
} finally {
viewState.onLoadingChanged(false)
}
}
}
fun clearHistory() {
launch {
viewState.onLoadingChanged(true)
try {
withContext(Dispatchers.IO) {
repository.clear()
}
viewState.onListChanged(emptyList())
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
viewState.onError(e)
} finally {
viewState.onLoadingChanged(false)
}
}
}
override fun onDestroy() {
repository.closeQuietly()
super.onDestroy()
}
}

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.ui.main.list.remote
import android.os.Bundle
import android.view.View
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.ui.main.list.MangaListFragment
import org.koitharu.kotatsu.utils.ext.withArgs
class RemoteListFragment : MangaListFragment<Unit>() {
private val presenter by moxyPresenter(factory = ::RemoteListPresenter)
private val source by arg<MangaSource>(ARG_SOURCE)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
textView_holder.setText(R.string.nothing_found)
}
override fun onRequestMoreItems(offset: Int) {
presenter.loadList(source, offset)
}
override fun getTitle(): CharSequence? {
return source.title
}
companion object {
private const val ARG_SOURCE = "provider"
fun newInstance(provider: MangaSource) = RemoteListFragment().withArgs(1) {
putParcelable(ARG_SOURCE, provider)
}
}
}

View File

@@ -1,16 +1,18 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.main.list.remote
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import moxy.InjectViewState import moxy.InjectViewState
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaInfo
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaProviderFactory import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BasePresenter import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.ui.main.list.MangaListView
@InjectViewState @InjectViewState
class MangaListPresenter : BasePresenter<MangaListView>() { class RemoteListPresenter : BasePresenter<MangaListView<Unit>>() {
fun loadList(source: MangaSource, offset: Int) { fun loadList(source: MangaSource, offset: Int) {
launch { launch {
@@ -19,6 +21,7 @@ class MangaListPresenter : BasePresenter<MangaListView>() {
val list = withContext(Dispatchers.IO) { val list = withContext(Dispatchers.IO) {
MangaProviderFactory.create(source) MangaProviderFactory.create(source)
.getList(offset) .getList(offset)
.map { MangaInfo(it, Unit) }
} }
if (offset == 0) { if (offset == 0) {
viewState.onListChanged(list) viewState.onListChanged(list)

View File

@@ -6,12 +6,10 @@ import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_reader.* import kotlinx.android.synthetic.main.activity_reader.*
import moxy.ktx.moxyPresenter import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.ui.common.BaseActivity import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.utils.ext.showDialog import org.koitharu.kotatsu.utils.ext.showDialog
@@ -58,6 +56,11 @@ class ReaderActivity : BaseActivity(), ReaderView {
super.onDestroy() super.onDestroy()
} }
override fun onPause() {
presenter.addToHistory(manga, chapterId, pager.currentItem)
super.onPause()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_reader_top, menu) menuInflater.inflate(R.menu.opt_reader_top, menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)

View File

@@ -5,12 +5,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import moxy.InjectViewState import moxy.InjectViewState
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.domain.HistoryRepository
import org.koitharu.kotatsu.domain.MangaProviderFactory import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BasePresenter import org.koitharu.kotatsu.ui.common.BasePresenter
@InjectViewState @InjectViewState
class ReaderPresenter() : BasePresenter<ReaderView>() { class ReaderPresenter : BasePresenter<ReaderView>() {
fun loadChapter(chapter: MangaChapter) { fun loadChapter(chapter: MangaChapter) {
launch { launch {
@@ -31,4 +33,12 @@ class ReaderPresenter() : BasePresenter<ReaderView>() {
} }
} }
fun addToHistory(manga: Manga, chapterId: Long, page: Int) {
launch(Dispatchers.IO) {
HistoryRepository().use {
it.addOrUpdate(manga, chapterId, page)
}
}
}
} }

View File

@@ -1,31 +0,0 @@
<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

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -26,6 +27,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:orientation="vertical"> android:orientation="vertical">
<TextView <TextView
@@ -34,7 +37,9 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:gravity="center" android:gravity="center"
tools:text="@tools:sample/lorem" /> android:text="?android:textColorSecondary"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
tools:text="@tools:sample/lorem[3]" />
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_clear_history"
android:orderInCategory="50"
android:title="@string/clear_history"
app:showAsAction="never" />
</menu>

View File

@@ -19,4 +19,7 @@
<string name="chapter_d_of_d">Chapter %d of %d</string> <string name="chapter_d_of_d">Chapter %d of %d</string>
<string name="close">Close</string> <string name="close">Close</string>
<string name="try_again">Try again</string> <string name="try_again">Try again</string>
<string name="clear_history">Clear history</string>
<string name="nothing_found">Nothing found</string>
<string name="history_is_empty">History is empty</string>
</resources> </resources>