Show suggestions on the shelf

This commit is contained in:
Koitharu
2023-05-11 16:47:36 +03:00
parent 4d8da40885
commit 26a7a7a2e8
16 changed files with 122 additions and 44 deletions

View File

@@ -5,8 +5,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.* import org.junit.Assert.*
@@ -20,6 +18,8 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import java.io.File
import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@@ -52,6 +52,7 @@ class AppBackupAgentTest {
title = SampleData.favouriteCategory.title, title = SampleData.favouriteCategory.title,
sortOrder = SampleData.favouriteCategory.order, sortOrder = SampleData.favouriteCategory.order,
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled, isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
) )
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga)) favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
historyRepository.addOrUpdate( historyRepository.addOrUpdate(

View File

@@ -11,7 +11,6 @@ import android.text.format.DateUtils
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.work.WorkManager import androidx.work.WorkManager
import coil.ImageLoader import coil.ImageLoader
@@ -155,8 +154,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
null null
} }
if (state.error != null) { if (state.error != null) {
builder.setContentText(state.error) builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error))
builder.setSubText(percent)
} else { } else {
builder.setContentText(percent) builder.setContentText(percent)
} }
@@ -183,22 +181,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
else -> { else -> {
builder.setProgress(state.max, state.progress, false) builder.setProgress(state.max, state.progress, false)
val percent = if (state.percent >= 0f) { builder.setContentText(getProgressString(state.percent, state.eta))
context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
} else {
null
}
if (state.eta > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(
state.eta,
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
)
builder.setContentText(eta)
builder.setSubText(percent)
} else {
builder.setContentText(percent)
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true) builder.setOngoing(true)
@@ -209,6 +192,29 @@ class DownloadNotificationFactory @AssistedInject constructor(
return builder.build() return builder.build()
} }
private fun getProgressString(percent: Float, eta: Long): CharSequence? {
val percentString = if (percent >= 0f) {
context.getString(R.string.percent_string_pattern, (percent * 100).format())
} else {
null
}
val etaString = if (eta > 0L) {
DateUtils.getRelativeTimeSpanString(
eta,
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
)
} else {
null
}
return when {
percentString == null && etaString == null -> null
percentString != null && etaString == null -> percentString
percentString == null && etaString != null -> etaString
else -> context.getString(R.string.download_summary_pattern, percentString, etaString)
}
}
private fun createMangaIntent(context: Context, manga: Manga?) = PendingIntentCompat.getActivity( private fun createMangaIntent(context: Context, manga: Manga?) = PendingIntentCompat.getActivity(
context, context,
manga.hashCode(), manga.hashCode(),

View File

@@ -9,6 +9,7 @@ class ShelfContent(
val favourites: Map<FavouriteCategory, List<Manga>>, val favourites: Map<FavouriteCategory, List<Manga>>,
val updated: Map<Manga, Int>, val updated: Map<Manga, Int>,
val local: List<Manga>, val local: List<Manga>,
val suggestions: List<Manga>,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -21,8 +22,7 @@ class ShelfContent(
if (favourites != other.favourites) return false if (favourites != other.favourites) return false
if (updated != other.updated) return false if (updated != other.updated) return false
if (local != other.local) return false if (local != other.local) return false
return suggestions == other.suggestions
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@@ -30,6 +30,7 @@ class ShelfContent(
result = 31 * result + favourites.hashCode() result = 31 * result + favourites.hashCode()
result = 31 * result + updated.hashCode() result = 31 * result + updated.hashCode()
result = 31 * result + local.hashCode() result = 31 * result + local.hashCode()
result = 31 * result + suggestions.hashCode()
return result return result
} }
} }

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -34,6 +35,7 @@ class ShelfRepository @Inject constructor(
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val suggestionRepository: SuggestionRepository,
private val db: MangaDatabase, private val db: MangaDatabase,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) { ) {
@@ -43,8 +45,9 @@ class ShelfRepository @Inject constructor(
observeLocalManga(SortOrder.UPDATED), observeLocalManga(SortOrder.UPDATED),
observeFavourites(), observeFavourites(),
trackingRepository.observeUpdatedManga(), trackingRepository.observeUpdatedManga(),
) { history, local, favorites, updated -> suggestionRepository.observeAll(16),
ShelfContent(history, favorites, updated, local) ) { history, local, favorites, updated, suggestions ->
ShelfContent(history, favorites, updated, local, suggestions)
} }
private fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> { private fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> {

View File

@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.shelf.domain
enum class ShelfSection { enum class ShelfSection {
HISTORY, LOCAL, UPDATED, FAVORITES; HISTORY, LOCAL, UPDATED, FAVORITES, SUGGESTIONS;
} }

View File

@@ -33,6 +33,7 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfAdapter import org.koitharu.kotatsu.shelf.ui.adapter.ShelfAdapter
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfListEventListener import org.koitharu.kotatsu.shelf.ui.adapter.ShelfListEventListener
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
import org.koitharu.kotatsu.utils.ext.addMenuProvider import org.koitharu.kotatsu.utils.ext.addMenuProvider
import javax.inject.Inject import javax.inject.Inject
@@ -118,6 +119,7 @@ class ShelfFragment :
is ShelfSectionModel.Favourites -> FavouritesActivity.newIntent(view.context, section.category) is ShelfSectionModel.Favourites -> FavouritesActivity.newIntent(view.context, section.category)
is ShelfSectionModel.Updated -> UpdatesActivity.newIntent(view.context) is ShelfSectionModel.Updated -> UpdatesActivity.newIntent(view.context)
is ShelfSectionModel.Local -> MangaListActivity.newIntent(view.context, MangaSource.LOCAL) is ShelfSectionModel.Local -> MangaListActivity.newIntent(view.context, MangaSource.LOCAL)
is ShelfSectionModel.Suggestions -> SuggestionsActivity.newIntent(view.context)
} }
startActivity(intent) startActivity(intent)
} }

View File

@@ -43,7 +43,9 @@ class ShelfSelectionCallback(
): Boolean { ): Boolean {
val checkedIds = controller.peekCheckedIds().entries val checkedIds = controller.peekCheckedIds().entries
val singleKey = checkedIds.singleOrNull { (_, ids) -> ids.isNotEmpty() }?.key val singleKey = checkedIds.singleOrNull { (_, ids) -> ids.isNotEmpty() }?.key
menu.findItem(R.id.action_remove)?.isVisible = singleKey != null && singleKey !is ShelfSectionModel.Updated menu.findItem(R.id.action_remove)?.isVisible = singleKey != null &&
singleKey !is ShelfSectionModel.Updated &&
singleKey !is ShelfSectionModel.Suggestions
menu.findItem(R.id.action_save)?.isVisible = singleKey !is ShelfSectionModel.Local menu.findItem(R.id.action_save)?.isVisible = singleKey !is ShelfSectionModel.Local
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }
@@ -82,6 +84,8 @@ class ShelfSelectionCallback(
showDeletionConfirm(ids, mode) showDeletionConfirm(ids, mode)
return true return true
} }
is ShelfSectionModel.Suggestions -> return false
} }
mode.finish() mode.finish()
true true

View File

@@ -58,10 +58,11 @@ class ShelfViewModel @Inject constructor(
val content: LiveData<List<ListModel>> = combine( val content: LiveData<List<ListModel>> = combine(
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections }, settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }, settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled },
settings.observeAsFlow(AppSettings.KEY_SUGGESTIONS) { isSuggestionsEnabled },
networkState, networkState,
repository.observeShelfContent(), repository.observeShelfContent(),
) { sections, isTrackerEnabled, isConnected, content -> ) { sections, isTrackerEnabled, isSuggestionsEnabled, isConnected, content ->
mapList(content, isTrackerEnabled, sections, isConnected) mapList(content, isTrackerEnabled, isSuggestionsEnabled, sections, isConnected)
}.catch { e -> }.catch { e ->
emit(listOf(e.toErrorState(canRetry = false))) emit(listOf(e.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
@@ -157,6 +158,7 @@ class ShelfViewModel @Inject constructor(
private suspend fun mapList( private suspend fun mapList(
content: ShelfContent, content: ShelfContent,
isTrackerEnabled: Boolean, isTrackerEnabled: Boolean,
isSuggestionsEnabled: Boolean,
sections: List<ShelfSection>, sections: List<ShelfSection>,
isNetworkAvailable: Boolean, isNetworkAvailable: Boolean,
): List<ListModel> { ): List<ListModel> {
@@ -171,6 +173,9 @@ class ShelfViewModel @Inject constructor(
} }
ShelfSection.FAVORITES -> mapFavourites(result, content.favourites) ShelfSection.FAVORITES -> mapFavourites(result, content.favourites)
ShelfSection.SUGGESTIONS -> if (isSuggestionsEnabled) {
mapSuggestions(result, content.suggestions)
}
} }
} }
} else { } else {
@@ -190,6 +195,7 @@ class ShelfViewModel @Inject constructor(
ShelfSection.LOCAL -> mapLocal(result, content.local) ShelfSection.LOCAL -> mapLocal(result, content.local)
ShelfSection.UPDATED -> Unit ShelfSection.UPDATED -> Unit
ShelfSection.FAVORITES -> Unit ShelfSection.FAVORITES -> Unit
ShelfSection.SUGGESTIONS -> Unit
} }
} }
} }
@@ -257,6 +263,19 @@ class ShelfViewModel @Inject constructor(
) )
} }
private suspend fun mapSuggestions(
destination: MutableList<in ShelfSectionModel.Suggestions>,
suggestions: List<Manga>,
) {
if (suggestions.isEmpty()) {
return
}
destination += ShelfSectionModel.Suggestions(
items = suggestions.toUi(ListMode.GRID, this, null),
showAllButtonText = R.string.show_all,
)
}
private suspend fun mapFavourites( private suspend fun mapFavourites(
destination: MutableList<in ShelfSectionModel.Favourites>, destination: MutableList<in ShelfSectionModel.Favourites>,
favourites: Map<FavouriteCategory, List<Manga>>, favourites: Map<FavouriteCategory, List<Manga>>,

View File

@@ -73,4 +73,5 @@ private val ShelfSection.titleResId: Int
ShelfSection.LOCAL -> R.string.local_storage ShelfSection.LOCAL -> R.string.local_storage
ShelfSection.UPDATED -> R.string.updated ShelfSection.UPDATED -> R.string.updated
ShelfSection.FAVORITES -> R.string.favourites ShelfSection.FAVORITES -> R.string.favourites
ShelfSection.SUGGESTIONS -> R.string.suggestions
} }

View File

@@ -35,9 +35,7 @@ sealed interface ShelfSectionModel : ListModel {
other as History other as History
if (showAllButtonText != other.showAllButtonText) return false if (showAllButtonText != other.showAllButtonText) return false
if (items != other.items) return false return items == other.items
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@@ -67,9 +65,7 @@ sealed interface ShelfSectionModel : ListModel {
if (category != other.category) return false if (category != other.category) return false
if (showAllButtonText != other.showAllButtonText) return false if (showAllButtonText != other.showAllButtonText) return false
if (items != other.items) return false return items == other.items
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@@ -98,9 +94,7 @@ sealed interface ShelfSectionModel : ListModel {
other as Updated other as Updated
if (items != other.items) return false if (items != other.items) return false
if (showAllButtonText != other.showAllButtonText) return false return showAllButtonText == other.showAllButtonText
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@@ -128,9 +122,35 @@ sealed interface ShelfSectionModel : ListModel {
other as Local other as Local
if (items != other.items) return false if (items != other.items) return false
if (showAllButtonText != other.showAllButtonText) return false return showAllButtonText == other.showAllButtonText
}
return true override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + showAllButtonText
return result
}
override fun toString(): String = key
}
class Suggestions(
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "suggestions"
override fun getTitle(resources: Resources) = resources.getString(R.string.suggestions)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Suggestions
if (items != other.items) return false
return showAllButtonText == other.showAllButtonText
} }
override fun hashCode(): Int { override fun hashCode(): Int {

View File

@@ -1,6 +1,11 @@
package org.koitharu.kotatsu.suggestions.data package org.koitharu.kotatsu.suggestions.data
import androidx.room.* import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@@ -10,6 +15,10 @@ abstract class SuggestionDao {
@Query("SELECT * FROM suggestions ORDER BY relevance DESC") @Query("SELECT * FROM suggestions ORDER BY relevance DESC")
abstract fun observeAll(): Flow<List<SuggestionWithManga>> abstract fun observeAll(): Flow<List<SuggestionWithManga>>
@Transaction
@Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit")
abstract fun observeAll(limit: Int): Flow<List<SuggestionWithManga>>
@Query("SELECT COUNT(*) FROM suggestions") @Query("SELECT COUNT(*) FROM suggestions")
abstract suspend fun count(): Int abstract suspend fun count(): Int
@@ -28,4 +37,4 @@ abstract class SuggestionDao {
insert(entity) insert(entity)
} }
} }
} }

View File

@@ -22,6 +22,12 @@ class SuggestionRepository @Inject constructor(
} }
} }
fun observeAll(limit: Int): Flow<List<Manga>> {
return db.suggestionDao.observeAll(limit).mapItems {
it.manga.toManga(it.tags.toMangaTags())
}
}
suspend fun clear() { suspend fun clear() {
db.suggestionDao.deleteAll() db.suggestionDao.deleteAll()
} }

View File

@@ -10,6 +10,8 @@ import androidx.work.impl.foreground.SystemForegroundService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
@@ -27,8 +29,10 @@ class WorkServiceStopHelper(
WorkManager.getInstance(context) WorkManager.getInstance(context)
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING)) .getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.asFlow() .asFlow()
.map { it.isEmpty() }
.distinctUntilChanged()
.collectLatest { .collectLatest {
if (it.isEmpty()) { if (it) {
delay(1_000) delay(1_000)
stopWorkerService() stopWorkerService()
} }

View File

@@ -3,6 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View File

@@ -3,6 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View File

@@ -361,7 +361,7 @@
<string name="options">Options</string> <string name="options">Options</string>
<string name="not_found_404">Content not found or removed</string> <string name="not_found_404">Content not found or removed</string>
<string name="downloading_manga">Downloading manga</string> <string name="downloading_manga">Downloading manga</string>
<string name="download_summary_pattern" translatable="false">&lt;b&gt;%1$s&lt;/b&gt; %2$s</string> <string name="download_summary_pattern" translatable="false">%1$s · %2$s</string>
<string name="incognito_mode">Incognito mode</string> <string name="incognito_mode">Incognito mode</string>
<string name="app_update_available_s">Application update available: %s</string> <string name="app_update_available_s">Application update available: %s</string>
<string name="no_chapters">No chapters</string> <string name="no_chapters">No chapters</string>