Add data listener to updates

This commit is contained in:
Syer10
2023-02-02 23:44:23 -05:00
parent a23d9e85c3
commit a943bf638d
5 changed files with 319 additions and 118 deletions

View File

@@ -33,13 +33,15 @@ class ServerListeners @Inject constructor() {
)
val mangaListener = _mangaListener.asSharedFlow()
private val chapterIndexesListener = MutableSharedFlow<Pair<Long, List<Int>?>>(
private val _chapterIndexesListener = MutableSharedFlow<Pair<Long, List<Int>?>>(
extraBufferCapacity = Channel.UNLIMITED
)
val chapterIndexesListener = _chapterIndexesListener.asSharedFlow()
private val chapterIdsListener = MutableSharedFlow<Pair<Long?, List<Long>>>(
private val _chapterIdsListener = MutableSharedFlow<Pair<Long?, List<Long>>>(
extraBufferCapacity = Channel.UNLIMITED
)
val chapterIdsListener = _chapterIdsListener.asSharedFlow()
private val categoryMangaListener = MutableSharedFlow<Long>(
extraBufferCapacity = Channel.UNLIMITED
@@ -87,14 +89,14 @@ class ServerListeners @Inject constructor() {
idPredate: (suspend (Long?, List<Long>) -> Boolean)? = null
): Flow<T> {
val indexListener = if (indexPredate != null) {
chapterIndexesListener.filter { indexPredate(it.first, it.second) }.startWith(Unit)
_chapterIndexesListener.filter { indexPredate(it.first, it.second) }.startWith(Unit)
} else {
chapterIndexesListener.startWith(Unit)
_chapterIndexesListener.startWith(Unit)
}
val idsListener = if (idPredate != null) {
chapterIdsListener.filter { idPredate(it.first, it.second) }.startWith(Unit)
_chapterIdsListener.filter { idPredate(it.first, it.second) }.startWith(Unit)
} else {
chapterIdsListener.startWith(Unit)
_chapterIdsListener.startWith(Unit)
}
return combine(indexListener, idsListener) { _, _ -> }
@@ -104,25 +106,25 @@ class ServerListeners @Inject constructor() {
fun updateChapters(mangaId: Long, chapterIndexes: List<Int>) {
scope.launch {
chapterIndexesListener.emit(mangaId to chapterIndexes.ifEmpty { null })
_chapterIndexesListener.emit(mangaId to chapterIndexes.ifEmpty { null })
}
}
fun updateChapters(mangaId: Long, vararg chapterIndexes: Int) {
scope.launch {
chapterIndexesListener.emit(mangaId to chapterIndexes.toList().ifEmpty { null })
_chapterIndexesListener.emit(mangaId to chapterIndexes.toList().ifEmpty { null })
}
}
fun updateChapters(mangaId: Long?, chapterIds: List<Long>) {
scope.launch {
chapterIdsListener.emit(mangaId to chapterIds)
_chapterIdsListener.emit(mangaId to chapterIds)
}
}
fun updateChapters(mangaId: Long?, vararg chapterIds: Long) {
scope.launch {
chapterIdsListener.emit(mangaId to chapterIds.toList())
_chapterIdsListener.emit(mangaId to chapterIds.toList())
}
}

View File

@@ -0,0 +1,186 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.updates.interactor
import androidx.compose.runtime.Immutable
import ca.gosyer.jui.domain.ServerListeners
import ca.gosyer.jui.domain.chapter.interactor.GetChapter
import ca.gosyer.jui.domain.chapter.model.Chapter
import ca.gosyer.jui.domain.manga.interactor.GetManga
import ca.gosyer.jui.domain.manga.model.Manga
import ca.gosyer.jui.domain.updates.model.MangaAndChapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import me.tatarka.inject.annotations.Inject
class UpdatesPager @Inject constructor(
private val getRecentUpdates: GetRecentUpdates,
private val getManga: GetManga,
private val getChapter: GetChapter,
private val serverListeners: ServerListeners,
) : CoroutineScope by CoroutineScope(Dispatchers.Default + SupervisorJob()){
private val updatesMutex = Mutex()
private val fetchedUpdates = MutableSharedFlow<List<MangaAndChapter>>()
private val foldedUpdates = fetchedUpdates.runningFold(emptyList<Updates>()) { updates, newUpdates ->
updates.ifEmpty {
val first = newUpdates.firstOrNull()?.chapter ?: return@runningFold updates
listOf(
Updates.Date(
Instant.fromEpochSeconds(first.fetchedAt)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
)
)
} + newUpdates.fold(emptyList()) { list, (manga, chapter) ->
val date = (list.lastOrNull() as? Updates.Update)?.let {
val lastUpdateDate = Instant.fromEpochSeconds(it.chapter.fetchedAt)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
val chapterDate = Instant.fromEpochSeconds(chapter.fetchedAt)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
chapterDate.takeUnless { it == lastUpdateDate }
}
if (date == null) {
list + Updates.Update(manga, chapter)
} else {
list + Updates.Date(date) + Updates.Update(manga, chapter)
}
}
}.stateIn(this, SharingStarted.Eagerly, emptyList())
private val mangaIds = fetchedUpdates.map { updates -> updates.map { it.manga.id } }
.stateIn(this, SharingStarted.Eagerly, emptyList())
private val chapterIds = fetchedUpdates.map { updates -> updates.map { Triple(it.manga.id, it.chapter.index, it.chapter.id) } }
.stateIn(this, SharingStarted.Eagerly, emptyList())
private val changedManga = serverListeners.mangaListener.runningFold(emptyMap<Long, Manga>()) { manga, updatedMangaIds ->
coroutineScope {
manga + updatedMangaIds.filter { it in mangaIds.value }.map {
async {
getManga.await(it)
}
}.awaitAll().filterNotNull().associateBy { it.id }
}
}.stateIn(this, SharingStarted.Eagerly, emptyMap())
private val changedChapters = MutableStateFlow(emptyMap<Long, Chapter>())
init {
serverListeners.chapterIndexesListener
.onEach {(mangaId, chapterIndexes) ->
if (chapterIndexes == null) {
val chapters = coroutineScope {
foldedUpdates.value.filterIsInstance<Updates.Update>().filter { it.manga.id == mangaId }.map {
async {
getChapter.await(it.manga.id, it.chapter.index)
}
}.awaitAll().filterNotNull().associateBy { it.id }
}
changedChapters.update { it + chapters }
} else {
val chapters = coroutineScope {
chapterIndexes.mapNotNull { index -> chapterIds.value.find { it.first == mangaId && it.second == index } }
.map {
async {
getChapter.await(it.first, it.second)
}
}.awaitAll().filterNotNull().associateBy { it.id }
}
changedChapters.update { it + chapters }
}
}
.launchIn(this)
serverListeners.chapterIdsListener
.onEach { (_, updatedChapterIds) ->
val chapters = coroutineScope {
updatedChapterIds.mapNotNull { id -> chapterIds.value.find { it.third == id } }.map {
async {
getChapter.await(it.first, it.second)
}
}.awaitAll().filterNotNull().associateBy { it.id }
}
changedChapters.update { it + chapters }
}
.launchIn(this)
}
val updates = combine(
foldedUpdates,
changedManga,
changedChapters
) { updates, changedManga, changedChapters ->
updates.map {
when (it) {
is Updates.Date -> it
is Updates.Update -> it.copy(
manga = changedManga[it.manga.id] ?: it.manga,
chapter = changedChapters[it.chapter.id] ?: it.chapter
)
}
}
}.stateIn(this, SharingStarted.Eagerly, emptyList())
private val currentPage = MutableStateFlow(0)
private val hasNextPage = MutableStateFlow(true)
@Immutable
sealed class Updates {
@Immutable
data class Update(val manga: Manga, val chapter: Chapter) : Updates()
@Immutable
data class Date(val date: String) : Updates() {
constructor(date: LocalDate) : this(date.toString())
}
}
fun loadNextPage(
onComplete: (() -> Unit)? = null,
onError: suspend (Throwable) -> Unit
) {
launch {
if (hasNextPage.value && updatesMutex.tryLock()) {
currentPage.value++
if (!getUpdates(currentPage.value, onError)) {
currentPage.value--
}
updatesMutex.unlock()
}
onComplete?.invoke()
}
}
private suspend fun getUpdates(page: Int, onError: suspend (Throwable) -> Unit): Boolean {
val updates = getRecentUpdates.await(page, onError) ?: return false
hasNextPage.value = updates.hasNextPage
fetchedUpdates.emit(updates.page)
return true
}
}

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.ui.updates
import ca.gosyer.jui.core.lang.launchDefault
import ca.gosyer.jui.domain.chapter.interactor.BatchUpdateChapter
import ca.gosyer.jui.domain.chapter.interactor.DeleteChapterDownload
import ca.gosyer.jui.domain.chapter.model.Chapter
@@ -15,6 +16,7 @@ import ca.gosyer.jui.domain.download.interactor.StopChapterDownload
import ca.gosyer.jui.domain.download.service.DownloadService
import ca.gosyer.jui.domain.updates.interactor.GetRecentUpdates
import ca.gosyer.jui.domain.updates.interactor.UpdateLibrary
import ca.gosyer.jui.domain.updates.interactor.UpdatesPager
import ca.gosyer.jui.ui.base.chapter.ChapterDownloadItem
import ca.gosyer.jui.uicore.vm.ContextWrapper
import ca.gosyer.jui.uicore.vm.ViewModel
@@ -22,24 +24,19 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
@@ -51,25 +48,27 @@ class UpdatesScreenViewModel @Inject constructor(
private val batchUpdateChapter: BatchUpdateChapter,
private val batchChapterDownload: BatchChapterDownload,
private val updateLibrary: UpdateLibrary,
private val updatesPager: UpdatesPager,
contextWrapper: ContextWrapper
) : ViewModel(contextWrapper) {
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
private val _updates = MutableStateFlow<ImmutableList<UpdatesUI>>(persistentListOf())
val updates = _updates.asStateFlow()
private val currentPage = MutableStateFlow(1)
private val hasNextPage = MutableStateFlow(false)
private val updatesMutex = Mutex()
private var downloadServiceJob: Job? = null
val updates = updatesPager.updates.map {
it.map {
when (it) {
is UpdatesPager.Updates.Date -> UpdatesUI.Header(it.date)
is UpdatesPager.Updates.Update -> UpdatesUI.Item(ChapterDownloadItem(it.manga, it.chapter))
}
}.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val _selectedIds = MutableStateFlow<ImmutableList<Long>>(persistentListOf())
val selectedItems = combine(updates, _selectedIds) { updates, selecteditems ->
updates.filterIsInstance<UpdatesUI.Item>()
.filter { it.chapterDownloadItem.isSelected(selecteditems) }
val selectedItems = combine(updates, _selectedIds) { updates, selectedItems ->
updates.asSequence()
.filterIsInstance<UpdatesUI.Item>()
.filter { it.chapterDownloadItem.isSelected(selectedItems) }
.map { it.chapterDownloadItem }
.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
@@ -78,78 +77,40 @@ class UpdatesScreenViewModel @Inject constructor(
.stateIn(scope, SharingStarted.Eagerly, false)
init {
scope.launch(Dispatchers.Default) {
getUpdates(currentPage.value)
updatesPager.loadNextPage(
onComplete = {
_isLoading.value = false
},
onError = {
toast(it.message.orEmpty())
}
}
fun loadNextPage() {
scope.launch(Dispatchers.Default) {
if (hasNextPage.value && updatesMutex.tryLock()) {
currentPage.value++
getUpdates(currentPage.value)
updatesMutex.unlock()
}
}
}
private suspend fun getUpdates(page: Int) {
getRecentUpdates.asFlow(page)
.onEach { updates ->
val lastUpdateDate = (_updates.value.lastOrNull() as? UpdatesUI.Item)
?.let {
Instant.fromEpochSeconds(it.chapterDownloadItem.chapter.fetchedAt)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
.toString()
}
val items = updates.page
.map {
ChapterDownloadItem(
it.manga,
it.chapter
)
}
.groupBy {
Instant.fromEpochSeconds(it.chapter.fetchedAt).toLocalDateTime(TimeZone.currentSystemDefault()).date
}
.entries
.sortedByDescending { it.key.toEpochDays() }
_updates.value = _updates.value.plus(
items
.flatMap { (date, updates) ->
listOf(UpdatesUI.Header(date.toString())).dropWhile { it.date == lastUpdateDate } +
updates
.sortedByDescending { it.chapter.fetchedAt }
.map { UpdatesUI.Item(it) }
}
).toImmutableList()
downloadServiceJob?.cancel()
val mangaIds = _updates.value.filterIsInstance<UpdatesUI.Item>().mapNotNull {
.map { updates ->
updates.filterIsInstance<UpdatesUI.Item>().mapNotNull {
it.chapterDownloadItem.manga?.id
}.toSet()
downloadServiceJob = DownloadService.registerWatches(mangaIds)
}
.combine(DownloadService.downloadQueue) { mangaIds, queue ->
mangaIds to queue
}
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.onEach { chapters ->
_updates.value.filterIsInstance<UpdatesUI.Item>().forEach {
.onEach { (mangaIds, queue) ->
val chapters = queue.filter { it.mangaId in mangaIds }
updates.value.filterIsInstance<UpdatesUI.Item>().forEach {
it.chapterDownloadItem.updateFrom(chapters)
}
}
.flowOn(Dispatchers.Default)
.launchIn(scope)
}
hasNextPage.value = updates.hasNextPage
_isLoading.value = false
}
.catch {
fun loadNextPage() {
updatesPager.loadNextPage(
onError = {
toast(it.message.orEmpty())
log.warn(it) { "Failed to get updates for page $page" }
if (page > 1) {
currentPage.value = page - 1
}
_isLoading.value = false
}
.collect()
)
}
private fun setRead(chapterIds: List<Long>, read: Boolean) {
@@ -183,7 +144,7 @@ class UpdatesScreenViewModel @Inject constructor(
}
fun deleteDownloadedChapter(chapter: Chapter?) {
scope.launch {
scope.launchDefault {
if (chapter == null) {
val selectedIds = _selectedIds.value
batchUpdateChapter.await(selectedIds, delete = true, onError = { toast(it.message.orEmpty()) })
@@ -191,9 +152,9 @@ class UpdatesScreenViewModel @Inject constructor(
it.setNotDownloaded()
}
_selectedIds.value = persistentListOf()
return@launch
return@launchDefault
}
_updates.value
updates.value
.filterIsInstance<UpdatesUI.Item>()
.find { (chapterDownloadItem) ->
chapterDownloadItem.chapter.mangaId == chapter.mangaId &&
@@ -205,8 +166,8 @@ class UpdatesScreenViewModel @Inject constructor(
}
fun stopDownloadingChapter(chapter: Chapter) {
scope.launch {
_updates.value
scope.launchDefault {
updates.value
.filterIsInstance<UpdatesUI.Item>()
.find { (chapterDownloadItem) ->
chapterDownloadItem.chapter.mangaId == chapter.mangaId &&
@@ -218,7 +179,7 @@ class UpdatesScreenViewModel @Inject constructor(
}
fun selectAll() {
scope.launch {
scope.launchDefault {
_selectedIds.value = updates.value.filterIsInstance<UpdatesUI.Item>()
.map { it.chapterDownloadItem.chapter.id }
.toImmutableList()
@@ -226,7 +187,7 @@ class UpdatesScreenViewModel @Inject constructor(
}
fun invertSelection() {
scope.launch {
scope.launchDefault {
_selectedIds.value = updates.value.filterIsInstance<UpdatesUI.Item>()
.map { it.chapterDownloadItem.chapter.id }
.minus(_selectedIds.value)
@@ -235,24 +196,29 @@ class UpdatesScreenViewModel @Inject constructor(
}
fun selectChapter(id: Long) {
scope.launch {
scope.launchDefault {
_selectedIds.value = _selectedIds.value.plus(id).toImmutableList()
}
}
fun unselectChapter(id: Long) {
scope.launch {
scope.launchDefault {
_selectedIds.value = _selectedIds.value.minus(id).toImmutableList()
}
}
fun clearSelection() {
scope.launch {
scope.launchDefault {
_selectedIds.value = persistentListOf()
}
}
fun updateLibrary() {
scope.launch { updateLibrary.await(onError = { toast(it.message.orEmpty()) }) }
scope.launchDefault { updateLibrary.await(onError = { toast(it.message.orEmpty()) }) }
}
override fun onDispose() {
super.onDispose()
updatesPager.cancel()
}
private companion object {

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ContentAlpha
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -88,6 +89,13 @@ fun UpdatesItem(
data = manga,
contentDescription = manga.title
)
val textColor = if (chapter.bookmarked && !chapter.read) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.onSurface.copy(
alpha = if (chapter.read) ContentAlpha.disabled else ContentAlpha.medium
)
}
MangaListItemColumn(
modifier = Modifier
.weight(1f)
@@ -96,10 +104,13 @@ fun UpdatesItem(
) {
MangaListItemTitle(
text = manga.title,
fontWeight = FontWeight.SemiBold
bookmarked = chapter.bookmarked,
fontWeight = FontWeight.SemiBold,
textColor = textColor
)
MangaListItemSubtitle(
text = chapter.name
text = chapter.name,
textColor = textColor
)
}

View File

@@ -10,16 +10,31 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.uicore.image.ImageLoaderImage
import ca.gosyer.jui.uicore.resources.stringResource
@Composable
fun MangaListItem(
@@ -65,27 +80,48 @@ fun MangaListItemColumn(
fun MangaListItemTitle(
modifier: Modifier = Modifier,
text: String,
bookmarked: Boolean = false,
maxLines: Int = 1,
fontWeight: FontWeight = FontWeight.Normal
fontWeight: FontWeight = FontWeight.Normal,
textColor: Color = Color.Unspecified
) {
Row(verticalAlignment = Alignment.CenterVertically) {
var textHeight by remember { mutableStateOf(0) }
if (bookmarked) {
Icon(
imageVector = Icons.Filled.Bookmark,
contentDescription = stringResource(MR.strings.action_filter_bookmarked),
modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
tint = MaterialTheme.colors.primary
)
Spacer(modifier = Modifier.width(2.dp))
}
Text(
modifier = modifier,
text = text,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h5,
fontWeight = fontWeight
fontWeight = fontWeight,
color = textColor,
onTextLayout = {
textHeight = it.size.height
}
)
}
}
@Composable
fun MangaListItemSubtitle(
modifier: Modifier = Modifier,
text: String
text: String,
textColor: Color = Color.Unspecified
) {
Text(
modifier = modifier,
text = text,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1