Show suggestions on the shelf
This commit is contained in:
@@ -5,8 +5,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -52,6 +52,7 @@ class AppBackupAgentTest {
|
||||
title = SampleData.favouriteCategory.title,
|
||||
sortOrder = SampleData.favouriteCategory.order,
|
||||
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
|
||||
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
|
||||
)
|
||||
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
|
||||
historyRepository.addOrUpdate(
|
||||
|
||||
@@ -11,7 +11,6 @@ import android.text.format.DateUtils
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.work.WorkManager
|
||||
import coil.ImageLoader
|
||||
@@ -155,8 +154,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
null
|
||||
}
|
||||
if (state.error != null) {
|
||||
builder.setContentText(state.error)
|
||||
builder.setSubText(percent)
|
||||
builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error))
|
||||
} else {
|
||||
builder.setContentText(percent)
|
||||
}
|
||||
@@ -183,22 +181,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
|
||||
else -> {
|
||||
builder.setProgress(state.max, state.progress, false)
|
||||
val percent = if (state.percent >= 0f) {
|
||||
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.setContentText(getProgressString(state.percent, state.eta))
|
||||
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
builder.setStyle(null)
|
||||
builder.setOngoing(true)
|
||||
@@ -209,6 +192,29 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
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(
|
||||
context,
|
||||
manga.hashCode(),
|
||||
|
||||
@@ -9,6 +9,7 @@ class ShelfContent(
|
||||
val favourites: Map<FavouriteCategory, List<Manga>>,
|
||||
val updated: Map<Manga, Int>,
|
||||
val local: List<Manga>,
|
||||
val suggestions: List<Manga>,
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@@ -21,8 +22,7 @@ class ShelfContent(
|
||||
if (favourites != other.favourites) return false
|
||||
if (updated != other.updated) return false
|
||||
if (local != other.local) return false
|
||||
|
||||
return true
|
||||
return suggestions == other.suggestions
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@@ -30,6 +30,7 @@ class ShelfContent(
|
||||
result = 31 * result + favourites.hashCode()
|
||||
result = 31 * result + updated.hashCode()
|
||||
result = 31 * result + local.hashCode()
|
||||
result = 31 * result + suggestions.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
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.utils.ext.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
@@ -34,6 +35,7 @@ class ShelfRepository @Inject constructor(
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val suggestionRepository: SuggestionRepository,
|
||||
private val db: MangaDatabase,
|
||||
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) {
|
||||
@@ -43,8 +45,9 @@ class ShelfRepository @Inject constructor(
|
||||
observeLocalManga(SortOrder.UPDATED),
|
||||
observeFavourites(),
|
||||
trackingRepository.observeUpdatedManga(),
|
||||
) { history, local, favorites, updated ->
|
||||
ShelfContent(history, favorites, updated, local)
|
||||
suggestionRepository.observeAll(16),
|
||||
) { history, local, favorites, updated, suggestions ->
|
||||
ShelfContent(history, favorites, updated, local, suggestions)
|
||||
}
|
||||
|
||||
private fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> {
|
||||
|
||||
@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.shelf.domain
|
||||
|
||||
enum class ShelfSection {
|
||||
|
||||
HISTORY, LOCAL, UPDATED, FAVORITES;
|
||||
HISTORY, LOCAL, UPDATED, FAVORITES, SUGGESTIONS;
|
||||
}
|
||||
|
||||
@@ -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.ShelfListEventListener
|
||||
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.utils.ext.addMenuProvider
|
||||
import javax.inject.Inject
|
||||
@@ -118,6 +119,7 @@ class ShelfFragment :
|
||||
is ShelfSectionModel.Favourites -> FavouritesActivity.newIntent(view.context, section.category)
|
||||
is ShelfSectionModel.Updated -> UpdatesActivity.newIntent(view.context)
|
||||
is ShelfSectionModel.Local -> MangaListActivity.newIntent(view.context, MangaSource.LOCAL)
|
||||
is ShelfSectionModel.Suggestions -> SuggestionsActivity.newIntent(view.context)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ class ShelfSelectionCallback(
|
||||
): Boolean {
|
||||
val checkedIds = controller.peekCheckedIds().entries
|
||||
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
|
||||
return super.onPrepareActionMode(controller, mode, menu)
|
||||
}
|
||||
@@ -82,6 +84,8 @@ class ShelfSelectionCallback(
|
||||
showDeletionConfirm(ids, mode)
|
||||
return true
|
||||
}
|
||||
|
||||
is ShelfSectionModel.Suggestions -> return false
|
||||
}
|
||||
mode.finish()
|
||||
true
|
||||
|
||||
@@ -58,10 +58,11 @@ class ShelfViewModel @Inject constructor(
|
||||
val content: LiveData<List<ListModel>> = combine(
|
||||
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
|
||||
settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled },
|
||||
settings.observeAsFlow(AppSettings.KEY_SUGGESTIONS) { isSuggestionsEnabled },
|
||||
networkState,
|
||||
repository.observeShelfContent(),
|
||||
) { sections, isTrackerEnabled, isConnected, content ->
|
||||
mapList(content, isTrackerEnabled, sections, isConnected)
|
||||
) { sections, isTrackerEnabled, isSuggestionsEnabled, isConnected, content ->
|
||||
mapList(content, isTrackerEnabled, isSuggestionsEnabled, sections, isConnected)
|
||||
}.catch { e ->
|
||||
emit(listOf(e.toErrorState(canRetry = false)))
|
||||
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
||||
@@ -157,6 +158,7 @@ class ShelfViewModel @Inject constructor(
|
||||
private suspend fun mapList(
|
||||
content: ShelfContent,
|
||||
isTrackerEnabled: Boolean,
|
||||
isSuggestionsEnabled: Boolean,
|
||||
sections: List<ShelfSection>,
|
||||
isNetworkAvailable: Boolean,
|
||||
): List<ListModel> {
|
||||
@@ -171,6 +173,9 @@ class ShelfViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
ShelfSection.FAVORITES -> mapFavourites(result, content.favourites)
|
||||
ShelfSection.SUGGESTIONS -> if (isSuggestionsEnabled) {
|
||||
mapSuggestions(result, content.suggestions)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -190,6 +195,7 @@ class ShelfViewModel @Inject constructor(
|
||||
ShelfSection.LOCAL -> mapLocal(result, content.local)
|
||||
ShelfSection.UPDATED -> 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(
|
||||
destination: MutableList<in ShelfSectionModel.Favourites>,
|
||||
favourites: Map<FavouriteCategory, List<Manga>>,
|
||||
|
||||
@@ -73,4 +73,5 @@ private val ShelfSection.titleResId: Int
|
||||
ShelfSection.LOCAL -> R.string.local_storage
|
||||
ShelfSection.UPDATED -> R.string.updated
|
||||
ShelfSection.FAVORITES -> R.string.favourites
|
||||
ShelfSection.SUGGESTIONS -> R.string.suggestions
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ sealed interface ShelfSectionModel : ListModel {
|
||||
other as History
|
||||
|
||||
if (showAllButtonText != other.showAllButtonText) return false
|
||||
if (items != other.items) return false
|
||||
|
||||
return true
|
||||
return items == other.items
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@@ -67,9 +65,7 @@ sealed interface ShelfSectionModel : ListModel {
|
||||
|
||||
if (category != other.category) return false
|
||||
if (showAllButtonText != other.showAllButtonText) return false
|
||||
if (items != other.items) return false
|
||||
|
||||
return true
|
||||
return items == other.items
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@@ -98,9 +94,7 @@ sealed interface ShelfSectionModel : ListModel {
|
||||
other as Updated
|
||||
|
||||
if (items != other.items) return false
|
||||
if (showAllButtonText != other.showAllButtonText) return false
|
||||
|
||||
return true
|
||||
return showAllButtonText == other.showAllButtonText
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@@ -128,9 +122,35 @@ sealed interface ShelfSectionModel : ListModel {
|
||||
other as Local
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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
|
||||
|
||||
@Dao
|
||||
@@ -10,6 +15,10 @@ abstract class SuggestionDao {
|
||||
@Query("SELECT * FROM suggestions ORDER BY relevance DESC")
|
||||
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")
|
||||
abstract suspend fun count(): Int
|
||||
|
||||
@@ -28,4 +37,4 @@ abstract class SuggestionDao {
|
||||
insert(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
db.suggestionDao.deleteAll()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.work.impl.foreground.SystemForegroundService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
|
||||
|
||||
@@ -27,8 +29,10 @@ class WorkServiceStopHelper(
|
||||
WorkManager.getInstance(context)
|
||||
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
||||
.asFlow()
|
||||
.map { it.isEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest {
|
||||
if (it.isEmpty()) {
|
||||
if (it) {
|
||||
delay(1_000)
|
||||
stopWorkerService()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
||||
@@ -361,7 +361,7 @@
|
||||
<string name="options">Options</string>
|
||||
<string name="not_found_404">Content not found or removed</string>
|
||||
<string name="downloading_manga">Downloading manga</string>
|
||||
<string name="download_summary_pattern" translatable="false"><b>%1$s</b> %2$s</string>
|
||||
<string name="download_summary_pattern" translatable="false">%1$s · %2$s</string>
|
||||
<string name="incognito_mode">Incognito mode</string>
|
||||
<string name="app_update_available_s">Application update available: %s</string>
|
||||
<string name="no_chapters">No chapters</string>
|
||||
|
||||
Reference in New Issue
Block a user