Bookmarks feature
parent
161bc5f69d
commit
1cbb825892
@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.View.OnClickListener
|
||||||
|
import android.view.View.OnLongClickListener
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
||||||
|
|
||||||
|
class AdapterDelegateClickListenerAdapter<I>(
|
||||||
|
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<I, *>,
|
||||||
|
private val clickListener: OnListItemClickListener<I>,
|
||||||
|
) : OnClickListener, OnLongClickListener {
|
||||||
|
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
clickListener.onItemClick(adapterDelegate.item, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(v: View): Boolean {
|
||||||
|
return clickListener.onItemLongClick(adapterDelegate.item, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks
|
||||||
|
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||||
|
|
||||||
|
val bookmarksModule
|
||||||
|
get() = module {
|
||||||
|
|
||||||
|
factory { BookmarksRepository(get()) }
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.data
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "bookmarks",
|
||||||
|
primaryKeys = ["manga_id", "page_id"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = MangaEntity::class,
|
||||||
|
parentColumns = ["manga_id"],
|
||||||
|
childColumns = ["manga_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class BookmarkEntity(
|
||||||
|
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||||
|
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
|
||||||
|
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||||
|
@ColumnInfo(name = "page") val page: Int,
|
||||||
|
@ColumnInfo(name = "scroll") val scroll: Int,
|
||||||
|
@ColumnInfo(name = "image") val imageUrl: String,
|
||||||
|
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||||
|
)
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.data
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Junction
|
||||||
|
import androidx.room.Relation
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
|
||||||
|
class BookmarkWithManga(
|
||||||
|
@Embedded val bookmark: BookmarkEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "manga_id",
|
||||||
|
entityColumn = "manga_id"
|
||||||
|
)
|
||||||
|
val manga: MangaEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "manga_id",
|
||||||
|
entityColumn = "tag_id",
|
||||||
|
associateBy = Junction(MangaTagsEntity::class)
|
||||||
|
)
|
||||||
|
val tags: List<TagEntity>,
|
||||||
|
)
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.data
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class BookmarksDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
|
||||||
|
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC")
|
||||||
|
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
abstract suspend fun insert(entity: BookmarkEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
abstract suspend fun delete(entity: BookmarkEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||||
|
abstract suspend fun delete(mangaId: Long, pageId: Long)
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.data
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
fun BookmarkWithManga.toBookmark() = bookmark.toBookmark(
|
||||||
|
manga.toManga(tags.toMangaTags())
|
||||||
|
)
|
||||||
|
|
||||||
|
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
||||||
|
manga = manga,
|
||||||
|
pageId = pageId,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
createdAt = Date(createdAt),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Bookmark.toEntity() = BookmarkEntity(
|
||||||
|
mangaId = manga.id,
|
||||||
|
pageId = pageId,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
createdAt = createdAt.time,
|
||||||
|
)
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.domain
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class Bookmark(
|
||||||
|
val manga: Manga,
|
||||||
|
val pageId: Long,
|
||||||
|
val chapterId: Long,
|
||||||
|
val page: Int,
|
||||||
|
val scroll: Int,
|
||||||
|
val imageUrl: String,
|
||||||
|
val createdAt: Date,
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as Bookmark
|
||||||
|
|
||||||
|
if (manga != other.manga) return false
|
||||||
|
if (pageId != other.pageId) return false
|
||||||
|
if (chapterId != other.chapterId) return false
|
||||||
|
if (page != other.page) return false
|
||||||
|
if (scroll != other.scroll) return false
|
||||||
|
if (imageUrl != other.imageUrl) return false
|
||||||
|
if (createdAt != other.createdAt) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = manga.hashCode()
|
||||||
|
result = 31 * result + pageId.hashCode()
|
||||||
|
result = 31 * result + chapterId.hashCode()
|
||||||
|
result = 31 * result + page
|
||||||
|
result = 31 * result + scroll
|
||||||
|
result = 31 * result + imageUrl.hashCode()
|
||||||
|
result = 31 * result + createdAt.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.domain
|
||||||
|
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.koitharu.kotatsu.bookmarks.data.toBookmark
|
||||||
|
import org.koitharu.kotatsu.bookmarks.data.toEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toEntities
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||||
|
|
||||||
|
class BookmarksRepository(
|
||||||
|
private val db: MangaDatabase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
|
||||||
|
return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
|
||||||
|
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addBookmark(bookmark: Bookmark) {
|
||||||
|
db.withTransaction {
|
||||||
|
val tags = bookmark.manga.tags.toEntities()
|
||||||
|
db.tagsDao.upsert(tags)
|
||||||
|
db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
|
||||||
|
db.bookmarksDao.insert(bookmark.toEntity())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeBookmark(mangaId: Long, pageId: Long) {
|
||||||
|
db.bookmarksDao.delete(mangaId, pageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.Disposable
|
||||||
|
import coil.size.Scale
|
||||||
|
import coil.util.CoilUtils
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
|
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
||||||
|
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.utils.ext.newImageRequest
|
||||||
|
import org.koitharu.kotatsu.utils.ext.referer
|
||||||
|
|
||||||
|
fun bookmarkListAD(
|
||||||
|
coil: ImageLoader,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
|
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||||
|
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) }
|
||||||
|
) {
|
||||||
|
|
||||||
|
var imageRequest: Disposable? = null
|
||||||
|
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||||
|
|
||||||
|
binding.root.setOnClickListener(listener)
|
||||||
|
binding.root.setOnLongClickListener(listener)
|
||||||
|
|
||||||
|
bind {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = binding.imageViewThumb.newImageRequest(item.imageUrl)
|
||||||
|
.referer(item.manga.publicUrl)
|
||||||
|
.placeholder(R.drawable.ic_placeholder)
|
||||||
|
.fallback(R.drawable.ic_placeholder)
|
||||||
|
.error(R.drawable.ic_placeholder)
|
||||||
|
.allowRgb565(true)
|
||||||
|
.scale(Scale.FILL)
|
||||||
|
.lifecycle(lifecycleOwner)
|
||||||
|
.enqueueWith(coil)
|
||||||
|
}
|
||||||
|
|
||||||
|
onViewRecycled {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = null
|
||||||
|
CoilUtils.dispose(binding.imageViewThumb)
|
||||||
|
binding.imageViewThumb.setImageDrawable(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import coil.ImageLoader
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||||
|
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
|
||||||
|
class BookmarksAdapter(
|
||||||
|
coil: ImageLoader,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
|
) : AsyncListDifferDelegationAdapter<Bookmark>(
|
||||||
|
DiffCallback(),
|
||||||
|
bookmarkListAD(coil, lifecycleOwner, clickListener)
|
||||||
|
) {
|
||||||
|
|
||||||
|
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
|
||||||
|
|
||||||
|
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
|
||||||
|
return oldItem.manga.id == newItem.manga.id && oldItem.chapterId == newItem.chapterId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
|
||||||
|
return oldItem.imageUrl == newItem.imageUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration10To11 : Migration(10, 11) {
|
||||||
|
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `bookmarks` (
|
||||||
|
`manga_id` INTEGER NOT NULL,
|
||||||
|
`page_id` INTEGER NOT NULL,
|
||||||
|
`chapter_id` INTEGER NOT NULL,
|
||||||
|
`page` INTEGER NOT NULL,
|
||||||
|
`scroll` INTEGER NOT NULL,
|
||||||
|
`image` TEXT NOT NULL,
|
||||||
|
`created_at` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`manga_id`, `page_id`),
|
||||||
|
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
|
||||||
|
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M17,18L12,15.82L7,18V5H17M17,3H7A2,2 0 0,0 5,5V21L12,18L19,21V5C19,3.89 18.1,3 17,3Z" />
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M9.47 9.65L8.06 11.07L11 14L16.19 8.82L14.78 7.4L11 11.18M17 3H7C5.9 3 5 3.9 5 5L5 21L12 18L19 21V5C19 3.9 18.1 3 17 3M17 18L12 15.82L7 18V5H17Z" />
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<?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"
|
||||||
|
style="@style/Widget.Material3.CardView.Outlined"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="@dimen/bookmark_item_height"
|
||||||
|
app:cardCornerRadius="12dp">
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.base.ui.widgets.CoverImageView
|
||||||
|
android:id="@+id/imageView_thumb"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
tools:src="@drawable/ic_placeholder" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_remove"
|
||||||
|
android:title="@string/remove" />
|
||||||
|
|
||||||
|
</menu>
|
||||||
Loading…
Reference in New Issue