Improve updated manga screen

This commit is contained in:
Koitharu
2024-04-13 08:52:53 +03:00
parent 32eba77639
commit ff4eac8269
16 changed files with 185 additions and 43 deletions

View File

@@ -172,6 +172,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) }
var isUpdatedGroupingEnabled: Boolean
get() = prefs.getBoolean(KEY_UPDATED_GROUPING, true)
set(value) = prefs.edit { putBoolean(KEY_UPDATED_GROUPING, value) }
var isFeedHeaderVisible: Boolean
get() = prefs.getBoolean(KEY_FEED_HEADER, true)
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
val isReadingIndicatorsEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
@@ -575,6 +583,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_UPDATED_GROUPING = "updated_grouping"
const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
@@ -652,5 +661,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_STATS_ENABLED = "stats_on"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val KEY_FEED_HEADER = "feed_header"
}
}

View File

@@ -65,6 +65,7 @@ import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.File
import kotlin.math.roundToLong
import com.google.android.material.R as materialR
val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
@@ -141,7 +142,7 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float,
} else {
// Set navbar scrim 70% of navigationBarColor
ElevationOverlayProvider(context).compositeOverlayIfNeeded(
context.getThemeColor(com.google.android.material.R.attr.colorSurfaceContainer, alphaFactor),
context.getThemeColor(materialR.attr.colorSurfaceContainer, alphaFactor),
elevation,
)
}

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesFragment
class MangaListMenuProvider(
private val fragment: Fragment,
@@ -26,6 +27,7 @@ class MangaListMenuProvider(
is HistoryListFragment -> ListConfigSection.History
is SuggestionsFragment -> ListConfigSection.Suggestions
is FavouritesListFragment -> ListConfigSection.Favorites(fragment.categoryId)
is UpdatesFragment -> ListConfigSection.Updated
else -> ListConfigSection.General
}
ListConfigBottomSheet.show(fragment.childFragmentManager, section)

View File

@@ -14,7 +14,6 @@ import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.setValueRounded
@@ -22,7 +21,6 @@ import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.databinding.SheetListModeBinding
import javax.inject.Inject
@AndroidEntryPoint
class ListConfigBottomSheet :
@@ -31,10 +29,6 @@ class ListConfigBottomSheet :
MaterialButtonToggleGroup.OnButtonCheckedListener, CompoundButton.OnCheckedChangeListener,
AdapterView.OnItemSelectedListener {
@Inject
@Deprecated("")
lateinit var settings: AppSettings
private val viewModel by viewModels<ListConfigViewModel>()
override fun onCreateViewBinding(
@@ -57,11 +51,11 @@ class ListConfigBottomSheet :
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.switchGrouping.isVisible = viewModel.isGroupingAvailable
if (viewModel.isGroupingAvailable) {
binding.switchGrouping.isEnabled = settings.historySortOrder.isGroupingSupported()
binding.switchGrouping.isVisible = viewModel.isGroupingSupported
if (viewModel.isGroupingSupported) {
binding.switchGrouping.isEnabled = viewModel.isGroupingAvailable
}
binding.switchGrouping.isChecked = settings.isHistoryGroupingEnabled
binding.switchGrouping.isChecked = viewModel.isGroupingEnabled
binding.switchGrouping.setOnCheckedChangeListener(this)
val sortOrders = viewModel.getSortOrders()
@@ -99,7 +93,7 @@ class ListConfigBottomSheet :
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView.id) {
R.id.switch_grouping -> settings.isHistoryGroupingEnabled = isChecked
R.id.switch_grouping -> viewModel.isGroupingEnabled = isChecked
}
}
@@ -113,7 +107,7 @@ class ListConfigBottomSheet :
when (parent.id) {
R.id.spinner_order -> {
viewModel.setSortOrder(position)
viewBinding?.switchGrouping?.isEnabled = settings.historySortOrder.isGroupingSupported()
viewBinding?.switchGrouping?.isEnabled = viewModel.isGroupingAvailable
}
}
}

View File

@@ -18,4 +18,7 @@ sealed interface ListConfigSection : Parcelable {
@Parcelize
data object Suggestions : ListConfigSection
@Parcelize
data object Updated : ListConfigSection
}

View File

@@ -26,16 +26,18 @@ class ListConfigViewModel @Inject constructor(
var listMode: ListMode
get() = when (section) {
is ListConfigSection.Favorites -> settings.favoritesListMode
ListConfigSection.General -> settings.listMode
ListConfigSection.History -> settings.historyListMode
ListConfigSection.Suggestions -> settings.suggestionsListMode
ListConfigSection.General,
ListConfigSection.Updated -> settings.listMode
}
set(value) {
when (section) {
is ListConfigSection.Favorites -> settings.favoritesListMode = value
ListConfigSection.General -> settings.listMode = value
ListConfigSection.History -> settings.historyListMode = value
ListConfigSection.Suggestions -> settings.suggestionsListMode = value
ListConfigSection.Updated,
ListConfigSection.General -> settings.listMode = value
}
}
@@ -45,19 +47,40 @@ class ListConfigViewModel @Inject constructor(
settings.gridSize = value
}
val isGroupingSupported: Boolean
get() = section == ListConfigSection.History || section == ListConfigSection.Updated
val isGroupingAvailable: Boolean
get() = section == ListConfigSection.History
get() = when (section) {
ListConfigSection.History -> settings.historySortOrder.isGroupingSupported()
ListConfigSection.Updated -> true
else -> false
}
var isGroupingEnabled: Boolean
get() = when (section) {
ListConfigSection.History -> settings.isHistoryGroupingEnabled
ListConfigSection.Updated -> settings.isUpdatedGroupingEnabled
else -> false
}
set(value) = when (section) {
ListConfigSection.History -> settings.isHistoryGroupingEnabled = value
ListConfigSection.Updated -> settings.isUpdatedGroupingEnabled = value
else -> Unit
}
fun getSortOrders(): List<ListSortOrder>? = when (section) {
is ListConfigSection.Favorites -> ListSortOrder.FAVORITES
ListConfigSection.General -> null
ListConfigSection.History -> ListSortOrder.HISTORY
ListConfigSection.Suggestions -> ListSortOrder.SUGGESTIONS
ListConfigSection.Updated -> null
}?.sortedByOrdinal()
fun getSelectedSortOrder(): ListSortOrder? = when (section) {
is ListConfigSection.Favorites -> getCategorySortOrder(section.categoryId)
ListConfigSection.General -> null
ListConfigSection.Updated -> null
ListConfigSection.History -> settings.historySortOrder
ListConfigSection.Suggestions -> ListSortOrder.RELEVANCE // TODO
}
@@ -77,6 +100,7 @@ class ListConfigViewModel @Inject constructor(
ListConfigSection.History -> settings.historySortOrder = value
ListConfigSection.Suggestions -> Unit
ListConfigSection.Updated -> Unit
}
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.tracker.data
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
class MangaWithTrack(
@Embedded val track: TrackEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id",
)
val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class),
)
val tags: List<TagEntity>,
)

View File

@@ -6,7 +6,6 @@ import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao
abstract class TracksDao {
@@ -47,12 +46,12 @@ abstract class TracksDao {
abstract fun observeNewChapters(mangaId: Long): Flow<Int?>
@Transaction
@Query("SELECT manga.* FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY last_chapter_date DESC")
abstract fun observeUpdatedManga(): Flow<List<MangaWithTags>>
@Query("SELECT * FROM tracks WHERE chapters_new > 0 ORDER BY last_chapter_date DESC")
abstract fun observeUpdatedManga(): Flow<List<MangaWithTrack>>
@Transaction
@Query("SELECT manga.* FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY last_chapter_date DESC LIMIT :limit")
abstract fun observeUpdatedManga(limit: Int): Flow<List<MangaWithTags>>
@Query("SELECT * FROM tracks WHERE chapters_new > 0 ORDER BY last_chapter_date DESC LIMIT :limit")
abstract fun observeUpdatedManga(limit: Int): Flow<List<MangaWithTrack>>
@Query("DELETE FROM tracks")
abstract suspend fun clear()

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.ifZero
@@ -59,13 +60,20 @@ class TrackingRepository @Inject constructor(
.onStart { gcIfNotCalled() }
}
fun observeUpdatedManga(limit: Int = 0): Flow<List<Manga>> {
fun observeUpdatedManga(limit: Int = 0): Flow<List<MangaTracking>> {
return if (limit == 0) {
db.getTracksDao().observeUpdatedManga()
} else {
db.getTracksDao().observeUpdatedManga(limit)
}.mapItems { it.toManga() }
.distinctUntilChanged()
}.mapItems {
MangaTracking(
manga = it.manga.toManga(it.tags.toMangaTags()),
lastChapterId = it.track.lastChapterId,
lastCheck = it.track.lastCheckTime.toInstantOrNull(),
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
newChapters = it.track.newChapters,
)
}.distinctUntilChanged()
.onStart { gcIfNotCalled() }
}
@@ -79,6 +87,8 @@ class TrackingRepository @Inject constructor(
manga = it.manga.toManga(emptySet()),
lastChapterId = it.track.lastChapterId,
lastCheck = it.track.lastCheckTime.toInstantOrNull(),
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
newChapters = it.track.newChapters,
)
}
}
@@ -90,6 +100,8 @@ class TrackingRepository @Inject constructor(
manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheckTime?.toInstantOrNull(),
lastChapterDate = track?.lastChapterDate?.toInstantOrNull(),
newChapters = track?.newChapters ?: 0,
)
}

View File

@@ -7,7 +7,10 @@ data class MangaTracking(
val manga: Manga,
val lastChapterId: Long,
val lastCheck: Instant?,
val lastChapterDate: Instant?,
val newChapters: Int,
) {
fun isEmpty(): Boolean {
return lastChapterId == 0L
}

View File

@@ -11,11 +11,15 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.drop
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
@@ -60,20 +64,15 @@ class FeedFragment :
setHasFixedSize(true)
addOnScrollListener(PaginationScrollListener(4, this@FeedFragment))
addItemDecoration(TypedListSpacingDecoration(context, true))
RecyclerScrollKeeper(this).attach()
}
binding.swipeRefreshLayout.setOnRefreshListener(this)
addMenuProvider(
FeedMenuProvider(
binding.recyclerView,
viewModel,
),
)
addMenuProvider(FeedMenuProvider(binding.recyclerView, viewModel))
viewModel.isHeaderEnabled.drop(1).observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
viewModel.content.observe(viewLifecycleOwner, this::onListChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onFeedCleared.observeEvent(viewLifecycleOwner) {
onFeedCleared()
}
viewModel.onFeedCleared.observeEvent(viewLifecycleOwner) { onFeedCleared() }
viewModel.isRunning.observe(viewLifecycleOwner, this::onIsTrackerRunningChanged)
}

View File

@@ -22,12 +22,22 @@ class FeedMenuProvider(
menuInflater.inflate(R.menu.opt_feed, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_show_updated)?.isChecked = viewModel.isHeaderEnabled.value
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_update -> {
viewModel.update()
true
}
R.id.action_show_updated -> {
viewModel.setHeaderEnabled(!menuItem.isChecked)
true
}
R.id.action_clear_feed -> {
CheckBoxAlertDialog.Builder(context)
.setTitle(R.string.clear_updates_feed)

View File

@@ -6,22 +6,25 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.feed.model.UpdatedMangaHeader
@@ -34,6 +37,7 @@ private const val PAGE_SIZE = 20
@HiltViewModel
class FeedViewModel @Inject constructor(
private val settings: AppSettings,
private val repository: TrackingRepository,
private val scheduler: TrackWorker.Scheduler,
private val listExtraProvider: ListExtraProvider,
@@ -45,6 +49,12 @@ class FeedViewModel @Inject constructor(
val isRunning = scheduler.observeIsRunning()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
val isHeaderEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_FEED_HEADER,
valueProducer = { isFeedHeaderVisible },
)
val onFeedCleared = MutableEventFlow<Unit>()
val content = combine(
observeHeader(),
@@ -94,6 +104,10 @@ class FeedViewModel @Inject constructor(
scheduler.startNow()
}
fun setHeaderEnabled(value: Boolean) {
settings.isFeedHeaderVisible = value
}
private fun List<TrackingLogItem>.mapListTo(destination: MutableList<ListModel>) {
var prevDate: DateTimeAgo? = null
for (item in this) {
@@ -106,11 +120,19 @@ class FeedViewModel @Inject constructor(
}
}
private fun observeHeader() = repository.observeUpdatedManga(10).map { mangaList ->
if (mangaList.isEmpty()) {
null
private fun observeHeader() = isHeaderEnabled.flatMapLatest { hasHeader ->
if (hasHeader) {
repository.observeUpdatedManga(10).map { mangaList ->
if (mangaList.isEmpty()) {
null
} else {
UpdatedMangaHeader(
mangaList.map { it.manga.toGridModel(listExtraProvider) },
)
}
}
} else {
UpdatedMangaHeader(mangaList.toUi(ListMode.GRID, listExtraProvider))
flowOf(null)
}
}
}

View File

@@ -11,15 +11,24 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import javax.inject.Inject
@HiltViewModel
@@ -32,8 +41,9 @@ class UpdatesViewModel @Inject constructor(
override val content = combine(
repository.observeUpdatedManga(),
settings.observeAsFlow(AppSettings.KEY_UPDATED_GROUPING) { isUpdatedGroupingEnabled },
listMode,
) { mangaList, mode ->
) { mangaList, grouping, mode ->
when {
mangaList.isEmpty() -> listOf(
EmptyState(
@@ -44,7 +54,7 @@ class UpdatesViewModel @Inject constructor(
),
)
else -> mangaList.toUi(mode, extraProvider)
else -> mangaList.toUi(mode, grouping)
}
}.onStart {
loadingCounter.increment()
@@ -69,4 +79,26 @@ class UpdatesViewModel @Inject constructor(
repository.clearUpdates(ids)
}
}
private suspend fun List<MangaTracking>.toUi(mode: ListMode, grouped: Boolean): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (size * 1.4).toInt() else size)
var prevHeader: DateTimeAgo? = null
for (item in this) {
if (grouped) {
val header = item.lastChapterDate?.let { calculateTimeAgo(it) }
if (header != prevHeader) {
if (header != null) {
result += ListHeader(header)
}
prevHeader = header
}
}
result += when (mode) {
ListMode.LIST -> item.manga.toListModel(extraProvider)
ListMode.DETAILED_LIST -> item.manga.toListDetailedModel(extraProvider)
ListMode.GRID -> item.manga.toGridModel(extraProvider)
}
}
return result
}
}

View File

@@ -15,6 +15,13 @@
android:title="@string/update"
app:showAsAction="never" />
<item
android:id="@+id/action_show_updated"
android:checkable="true"
android:orderInCategory="50"
android:title="@string/show_updated"
app:showAsAction="never" />
<item
android:id="@+id/action_clear_feed"
android:orderInCategory="50"

View File

@@ -652,4 +652,5 @@
<string name="fix">Fix</string>
<string name="missing_storage_permission">There is no permission to access manga on external storage</string>
<string name="last_used">Last used</string>
<string name="show_updated">Show updated</string>
</resources>