Highlight new records in feed
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.db.dao
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
||||||
|
|
||||||
@@ -8,8 +9,8 @@ import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
|||||||
interface TrackLogsDao {
|
interface TrackLogsDao {
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
|
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
||||||
suspend fun findAll(offset: Int, limit: Int): List<TrackLogWithManga>
|
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs")
|
@Query("DELETE FROM track_logs")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
@@ -25,4 +26,4 @@ interface TrackLogsDao {
|
|||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM track_logs")
|
@Query("SELECT COUNT(*) FROM track_logs")
|
||||||
suspend fun count(): Int
|
suspend fun count(): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,26 @@ import org.koitharu.kotatsu.core.db.entity.toManga
|
|||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
|
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
|
||||||
|
|
||||||
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
|
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
|
||||||
id = trackLog.id,
|
val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
|
||||||
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
|
return TrackingLogItem(
|
||||||
manga = manga.toManga(tags.toMangaTags()),
|
id = trackLog.id,
|
||||||
createdAt = Date(trackLog.createdAt)
|
chapters = chaptersList,
|
||||||
)
|
manga = manga.toManga(tags.toMangaTags()),
|
||||||
|
createdAt = Date(trackLog.createdAt),
|
||||||
|
isNew = counters.decrement(trackLog.mangaId, chaptersList.size),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableMap<Long, Int>.decrement(key: Long, count: Int): Boolean {
|
||||||
|
val counter = get(key)
|
||||||
|
if (counter == null || counter <= 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (counter < count) {
|
||||||
|
remove(key)
|
||||||
|
} else {
|
||||||
|
put(key, counter - count)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ abstract class TracksDao {
|
|||||||
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
|
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
|
||||||
abstract suspend fun findNewChapters(mangaId: Long): Int?
|
abstract suspend fun findNewChapters(mangaId: Long): Int?
|
||||||
|
|
||||||
|
@MapInfo(keyColumn = "manga_id", valueColumn = "chapters_new")
|
||||||
|
@Query("SELECT manga_id, chapters_new FROM tracks")
|
||||||
|
abstract fun observeNewChaptersMap(): Flow<Map<Long, Int>>
|
||||||
|
|
||||||
@Query("SELECT chapters_new FROM tracks")
|
@Query("SELECT chapters_new FROM tracks")
|
||||||
abstract fun observeNewChapters(): Flow<List<Int>>
|
abstract fun observeNewChapters(): Flow<List<Int>>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.room.withTransaction
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
@@ -73,9 +75,15 @@ class TrackingRepository @Inject constructor(
|
|||||||
db.tracksDao.delete(mangaId)
|
db.tracksDao.delete(mangaId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTrackingLog(offset: Int, limit: Int): List<TrackingLogItem> {
|
fun observeTrackingLog(limit: Flow<Int>): Flow<List<TrackingLogItem>> {
|
||||||
return db.trackLogsDao.findAll(offset, limit).map { x ->
|
return limit.flatMapLatest { limitValue ->
|
||||||
x.toTrackingLogItem()
|
combine(
|
||||||
|
db.tracksDao.observeNewChaptersMap(),
|
||||||
|
db.trackLogsDao.observeAll(limitValue),
|
||||||
|
) { counters, entities ->
|
||||||
|
val countersMap = counters.toMutableMap()
|
||||||
|
entities.map { x -> x.toTrackingLogItem(countersMap) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ data class TrackingLogItem(
|
|||||||
val id: Long,
|
val id: Long,
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val chapters: List<String>,
|
val chapters: List<String>,
|
||||||
val createdAt: Date
|
val createdAt: Date,
|
||||||
)
|
val isNew: Boolean,
|
||||||
|
)
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class FeedFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onScrolledToEnd() {
|
override fun onScrolledToEnd() {
|
||||||
viewModel.loadList(append = true)
|
viewModel.requestMoreItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: Manga, view: View) {
|
override fun onItemClick(item: Manga, view: View) {
|
||||||
|
|||||||
@@ -4,44 +4,39 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.ui.DateTimeAgo
|
import org.koitharu.kotatsu.core.ui.DateTimeAgo
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
|
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
|
||||||
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
|
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.asFlowLiveData
|
||||||
import org.koitharu.kotatsu.utils.ext.daysDiff
|
import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 20
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class FeedViewModel @Inject constructor(
|
class FeedViewModel @Inject constructor(
|
||||||
private val repository: TrackingRepository,
|
private val repository: TrackingRepository,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null)
|
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||||
private val hasNextPage = MutableStateFlow(false)
|
private val isReady = AtomicBoolean(false)
|
||||||
private var loadingJob: Job? = null
|
|
||||||
|
|
||||||
val onFeedCleared = SingleLiveEvent<Unit>()
|
val onFeedCleared = SingleLiveEvent<Unit>()
|
||||||
val content = combine(
|
val content = repository.observeTrackingLog(limit)
|
||||||
logList.filterNotNull(),
|
.map { list ->
|
||||||
hasNextPage,
|
|
||||||
) { list, isHasNextPage ->
|
|
||||||
buildList(list.size + 2) {
|
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
add(
|
listOf(
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = R.drawable.ic_empty_feed,
|
icon = R.drawable.ic_empty_feed,
|
||||||
textPrimary = R.string.text_empty_holder_primary,
|
textPrimary = R.string.text_empty_holder_primary,
|
||||||
@@ -50,48 +45,26 @@ class FeedViewModel @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
list.mapListTo(this)
|
isReady.set(true)
|
||||||
if (isHasNextPage) {
|
list.mapList()
|
||||||
add(LoadingFooter)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
||||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadList(append = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadList(append: Boolean) {
|
|
||||||
if (loadingJob?.isActive == true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (append && !hasNextPage.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
|
||||||
val offset = if (append) logList.value?.size ?: 0 else 0
|
|
||||||
val list = repository.getTrackingLog(offset, 20)
|
|
||||||
if (!append) {
|
|
||||||
logList.value = list
|
|
||||||
} else if (list.isNotEmpty()) {
|
|
||||||
logList.value = logList.value?.plus(list) ?: list
|
|
||||||
}
|
|
||||||
hasNextPage.value = list.isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearFeed() {
|
fun clearFeed() {
|
||||||
val lastJob = loadingJob
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
|
||||||
lastJob?.cancelAndJoin()
|
|
||||||
repository.clearLogs()
|
repository.clearLogs()
|
||||||
logList.value = emptyList()
|
|
||||||
onFeedCleared.postCall(Unit)
|
onFeedCleared.postCall(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<TrackingLogItem>.mapListTo(destination: MutableList<ListModel>) {
|
fun requestMoreItems() {
|
||||||
|
if (isReady.compareAndSet(true, false)) {
|
||||||
|
limit.value += PAGE_SIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<TrackingLogItem>.mapList(): List<ListModel> {
|
||||||
|
val destination = ArrayList<ListModel>((size * 1.4).toInt())
|
||||||
var prevDate: DateTimeAgo? = null
|
var prevDate: DateTimeAgo? = null
|
||||||
for (item in this) {
|
for (item in this) {
|
||||||
val date = timeAgo(item.createdAt)
|
val date = timeAgo(item.createdAt)
|
||||||
@@ -101,6 +74,7 @@ class FeedViewModel @Inject constructor(
|
|||||||
prevDate = date
|
prevDate = date
|
||||||
destination += item.toFeedItem()
|
destination += item.toFeedItem()
|
||||||
}
|
}
|
||||||
|
return destination
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun timeAgo(date: Date): DateTimeAgo {
|
private fun timeAgo(date: Date): DateTimeAgo {
|
||||||
|
|||||||
@@ -11,22 +11,23 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
|
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
|
||||||
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
|
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
|
||||||
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
|
import org.koitharu.kotatsu.utils.ext.isBold
|
||||||
import org.koitharu.kotatsu.utils.ext.newImageRequest
|
import org.koitharu.kotatsu.utils.ext.newImageRequest
|
||||||
|
|
||||||
fun feedItemAD(
|
fun feedItemAD(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
clickListener: OnListItemClickListener<Manga>
|
clickListener: OnListItemClickListener<Manga>,
|
||||||
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>(
|
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>(
|
||||||
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) }
|
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
itemView.setOnClickListener {
|
itemView.setOnClickListener {
|
||||||
clickListener.onItemClick(item.manga, it)
|
clickListener.onItemClick(item.manga, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
|
binding.textViewTitle.isBold = item.isNew
|
||||||
|
binding.textViewSummary.isBold = item.isNew
|
||||||
binding.imageViewCover.newImageRequest(item.imageUrl)?.run {
|
binding.imageViewCover.newImageRequest(item.imageUrl)?.run {
|
||||||
placeholder(R.drawable.ic_placeholder)
|
placeholder(R.drawable.ic_placeholder)
|
||||||
fallback(R.drawable.ic_placeholder)
|
fallback(R.drawable.ic_placeholder)
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ data class FeedItem(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val count: Int,
|
val count: Int,
|
||||||
) : ListModel
|
val isNew: Boolean,
|
||||||
|
) : ListModel
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ fun TrackingLogItem.toFeedItem() = FeedItem(
|
|||||||
title = manga.title,
|
title = manga.title,
|
||||||
count = chapters.size,
|
count = chapters.size,
|
||||||
manga = manga,
|
manga = manga,
|
||||||
)
|
isNew = isNew,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.utils.ext
|
package org.koitharu.kotatsu.utils.ext
|
||||||
|
|
||||||
|
import android.graphics.Typeface
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
@@ -48,3 +49,15 @@ fun TextView.setTextAndVisible(@StringRes textResId: Int) {
|
|||||||
fun TextView.setTextColorAttr(@AttrRes attrResId: Int) {
|
fun TextView.setTextColorAttr(@AttrRes attrResId: Int) {
|
||||||
setTextColor(context.getThemeColorStateList(attrResId))
|
setTextColor(context.getThemeColorStateList(attrResId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var TextView.isBold: Boolean
|
||||||
|
get() = typeface.isBold
|
||||||
|
set(value) {
|
||||||
|
var style = typeface.style
|
||||||
|
style = if (value) {
|
||||||
|
style or Typeface.BOLD
|
||||||
|
} else {
|
||||||
|
style and Typeface.BOLD.inv()
|
||||||
|
}
|
||||||
|
setTypeface(typeface, style)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package org.koitharu.kotatsu.utils.ext
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.annotation.FloatRange
|
import androidx.annotation.FloatRange
|
||||||
|
import androidx.annotation.StyleRes
|
||||||
import androidx.core.content.res.use
|
import androidx.core.content.res.use
|
||||||
import androidx.core.graphics.ColorUtils
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.core.widget.TextViewCompat
|
||||||
|
|
||||||
fun Context.getThemeDrawable(
|
fun Context.getThemeDrawable(
|
||||||
@AttrRes resId: Int,
|
@AttrRes resId: Int,
|
||||||
@@ -43,3 +46,9 @@ fun Context.getThemeColorStateList(
|
|||||||
) = obtainStyledAttributes(intArrayOf(resId)).use {
|
) = obtainStyledAttributes(intArrayOf(resId)).use {
|
||||||
it.getColorStateList(0)
|
it.getColorStateList(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun TextView.setThemeTextAppearance(@AttrRes resId: Int, @StyleRes fallback: Int) {
|
||||||
|
context.obtainStyledAttributes(intArrayOf(resId)).use {
|
||||||
|
TextViewCompat.setTextAppearance(this, it.getResourceId(0, fallback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginHorizontal="16dp"
|
||||||
android:layout_marginVertical="8dp"
|
android:layout_marginVertical="8dp"
|
||||||
android:paddingBottom="8dp"
|
android:paddingBottom="8dp"
|
||||||
app:cardBackgroundColor="?colorOnPrimary"
|
app:cardBackgroundColor="?colorPrimaryContainer"
|
||||||
app:cardCornerRadius="24dp">
|
app:cardCornerRadius="24dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|||||||
Reference in New Issue
Block a user