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