Highlight new records in feed

This commit is contained in:
Koitharu
2022-08-18 12:33:17 +03:00
parent f78ae4a818
commit b73fe0398f
13 changed files with 101 additions and 71 deletions

View File

@@ -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
} }

View File

@@ -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
}

View File

@@ -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>>

View File

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

View File

@@ -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,
)

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

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

View File

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

View File

@@ -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