Remove shelf

This commit is contained in:
Koitharu
2023-07-20 16:11:03 +03:00
parent bc273bfb8f
commit 5785a2d5d1
26 changed files with 21 additions and 1608 deletions

View File

@@ -29,7 +29,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
import java.io.File
import java.net.Proxy
import java.util.Collections
@@ -54,22 +53,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val remoteMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
var shelfSections: List<ShelfSection>
get() {
val raw = prefs.getString(KEY_SHELF_SECTIONS, null)
val values = enumValues<ShelfSection>()
if (raw.isNullOrEmpty()) {
return values.toList()
}
return raw.split('|')
.mapNotNull { values.getOrNull(it.toIntOrNull() ?: -1) }
.distinct()
}
set(value) {
val raw = value.joinToString("|") { it.ordinal.toString() }
prefs.edit { putString(KEY_SHELF_SECTIONS, raw) }
}
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
@@ -498,7 +481,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_SHELF_SECTIONS = "shelf_sections_2"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"

View File

@@ -1,6 +1,10 @@
package org.koitharu.kotatsu.favourites.data
import androidx.room.*
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
@@ -15,6 +19,9 @@ abstract class FavouriteCategoriesDao {
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 AND show_in_lib = 1 ORDER BY sort_key")
abstract fun observeAllForLibrary(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity?>

View File

@@ -67,6 +67,12 @@ class FavouritesRepository @Inject constructor(
}.distinctUntilChanged()
}
fun observeCategoriesForLibrary(): Flow<List<FavouriteCategory>> {
return db.favouriteCategoriesDao.observeAllForLibrary().mapItems {
it.toFavouriteCategory()
}.distinctUntilChanged()
}
fun observeCategoriesWithCovers(): Flow<Map<FavouriteCategory, List<Cover>>> {
return db.favouriteCategoriesDao.observeAll()
.map {

View File

@@ -14,10 +14,10 @@ import javax.inject.Inject
@HiltViewModel
class FavouritesContainerViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val categories = favouritesRepository.observeCategories()
val categories = favouritesRepository.observeCategoriesForLibrary()
.mapItems { FavouriteTabModel(it.id, it.title) }
.distinctUntilChanged()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())

View File

@@ -1,83 +0,0 @@
package org.koitharu.kotatsu.shelf.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toMangaList
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.shelf.domain.model.ShelfContent
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@Suppress("SameParameterValue")
class ShelfContentObserveUseCase @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val historyRepository: HistoryRepository,
private val trackingRepository: TrackingRepository,
private val suggestionRepository: SuggestionRepository,
private val db: MangaDatabase,
private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) {
operator fun invoke(): Flow<ShelfContent> = combine(
historyRepository.observeAll(20),
observeLocalManga(SortOrder.UPDATED, 20),
observeFavourites(),
trackingRepository.observeUpdatedManga(),
suggestionRepository.observeAll(20),
) { history, local, favorites, updated, suggestions ->
ShelfContent(history, favorites, updated, local, suggestions)
}
private fun observeLocalManga(sortOrder: SortOrder, limit: Int): Flow<List<Manga>> {
return combine<LocalManga?, String, Any?>(
localStorageChanges,
settings.observe().filter { it == AppSettings.KEY_LOCAL_MANGA_DIRS }.onStart { emit("") }
) { a, b -> a to b }
.onStart { emit(null) }
.mapLatest {
localMangaRepository.getList(0, null, sortOrder).take(limit)
}.distinctUntilChanged()
}
private fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> {
return db.favouriteCategoriesDao.observeAll()
.flatMapLatest { categories ->
val cats = categories.filter { it.isVisibleInLibrary }
if (cats.isEmpty()) {
flowOf(emptyMap())
} else {
observeCategoriesContent(cats)
}
}
}
private fun observeCategoriesContent(
categories: List<FavouriteCategoryEntity>,
) = combine<Pair<FavouriteCategory, List<Manga>>, Map<FavouriteCategory, List<Manga>>>(
categories.map { cat ->
val category = cat.toFavouriteCategory()
db.favouritesDao.observeAll(category.id, category.order)
.map { category to it.toMangaList() }
},
) { array -> array.toMap() }
}

View File

@@ -1,35 +0,0 @@
package org.koitharu.kotatsu.shelf.domain.model
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga
class ShelfContent(
val history: List<Manga>,
val favourites: Map<FavouriteCategory, List<Manga>>,
val updated: List<Manga>,
val local: List<Manga>,
val suggestions: List<Manga>,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ShelfContent
if (history != other.history) return false
if (favourites != other.favourites) return false
if (updated != other.updated) return false
if (local != other.local) return false
return suggestions == other.suggestions
}
override fun hashCode(): Int {
var result = history.hashCode()
result = 31 * result + favourites.hashCode()
result = 31 * result + updated.hashCode()
result = 31 * result + local.hashCode()
result = 31 * result + suggestions.hashCode()
return result
}
}

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.shelf.domain.model
enum class ShelfSection {
HISTORY, LOCAL, UPDATED, FAVORITES, SUGGESTIONS;
}

View File

@@ -1,156 +0,0 @@
package org.koitharu.kotatsu.shelf.ui
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.NestedScrollStateHandle
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentShelfBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
import org.koitharu.kotatsu.history.ui.HistoryActivity
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
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 javax.inject.Inject
@AndroidEntryPoint
class ShelfFragment :
BaseFragment<FragmentShelfBinding>(),
RecyclerViewOwner,
ShelfListEventListener {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private var nestedScrollStateHandle: NestedScrollStateHandle? = null
private val viewModel by viewModels<ShelfViewModel>()
private var adapter: ShelfAdapter? = null
private var selectionController: SectionedSelectionController<ShelfSectionModel>? = null
override val recyclerView: RecyclerView
get() = requireViewBinding().recyclerView
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentShelfBinding {
return FragmentShelfBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: FragmentShelfBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
nestedScrollStateHandle = NestedScrollStateHandle(savedInstanceState, KEY_NESTED_SCROLL)
val sizeResolver = DynamicItemSizeResolver(resources, settings)
selectionController = SectionedSelectionController(
activity = requireActivity(),
owner = this,
callback = ShelfSelectionCallback(binding.recyclerView, childFragmentManager, viewModel),
)
adapter = ShelfAdapter(
lifecycleOwner = viewLifecycleOwner,
coil = coil,
listener = this,
sizeResolver = sizeResolver,
selectionController = checkNotNull(selectionController),
nestedScrollStateHandle = checkNotNull(nestedScrollStateHandle),
)
binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true)
addMenuProvider(ShelfMenuProvider(binding.root.context, childFragmentManager, viewModel))
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView))
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
nestedScrollStateHandle?.onSaveInstanceState(outState)
}
override fun onDestroyView() {
super.onDestroyView()
adapter = null
selectionController = null
nestedScrollStateHandle = null
}
override fun onItemClick(item: Manga, section: ShelfSectionModel, view: View) {
if (selectionController?.onItemClick(section, item.id) != true) {
val intent = DetailsActivity.newIntent(view.context, item)
startActivity(intent)
}
}
override fun onItemLongClick(item: Manga, section: ShelfSectionModel, view: View): Boolean {
return selectionController?.onItemLongClick(section, item.id) ?: false
}
override fun onSectionClick(section: ShelfSectionModel, view: View) {
selectionController?.clear()
val intent = when (section) {
is ShelfSectionModel.History -> HistoryActivity.newIntent(view.context)
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)
}
override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() {
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Settings.Panel.ACTION_INTERNET_CONNECTIVITY
} else {
Settings.ACTION_WIRELESS_SETTINGS
}
startActivity(Intent(action))
}
override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().recyclerView.updatePadding(
bottom = insets.bottom,
)
}
private fun onListChanged(list: List<ListModel>) {
adapter?.items = list
}
companion object {
private const val KEY_NESTED_SCROLL = "nested_scroll"
fun newInstance() = ShelfFragment()
}
}

View File

@@ -1,81 +0,0 @@
package org.koitharu.kotatsu.shelf.ui
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.util.ext.startOfDay
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
import org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity
import org.koitharu.kotatsu.shelf.ui.config.size.ShelfSizeBottomSheet
import java.util.Date
import java.util.concurrent.TimeUnit
import com.google.android.material.R as materialR
class ShelfMenuProvider(
private val context: Context,
private val fragmentManager: FragmentManager,
private val viewModel: ShelfViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_shelf, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_clear_history -> {
showClearHistoryDialog()
true
}
R.id.action_grid_size -> {
ShelfSizeBottomSheet.show(fragmentManager)
true
}
R.id.action_import -> {
ImportDialogFragment.show(fragmentManager)
true
}
R.id.action_categories -> {
context.startActivity(ShelfSettingsActivity.newIntent(context))
true
}
else -> false
}
}
private fun showClearHistoryDialog() {
val selectionListener = RememberSelectionDialogListener(2)
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_history)
.setSingleChoiceItems(
arrayOf(
context.getString(R.string.last_2_hours),
context.getString(R.string.today),
context.getString(R.string.clear_all_history),
),
selectionListener.selection,
selectionListener,
)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
val minDate = when (selectionListener.selection) {
0 -> System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2)
1 -> Date().startOfDay()
2 -> 0L
else -> return@setPositiveButton
}
viewModel.clearHistory(minDate)
}.show()
}
}

View File

@@ -1,141 +0,0 @@
package org.koitharu.kotatsu.shelf.ui
import android.content.Context
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.flattenTo
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
class ShelfSelectionCallback(
private val recyclerView: RecyclerView,
private val fragmentManager: FragmentManager,
private val viewModel: ShelfViewModel,
) : SectionedSelectionController.Callback<ShelfSectionModel> {
private val context: Context
get() = recyclerView.context
override fun onCreateActionMode(
controller: SectionedSelectionController<ShelfSectionModel>,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_shelf, menu)
return true
}
override fun onPrepareActionMode(
controller: SectionedSelectionController<ShelfSectionModel>,
mode: ActionMode,
menu: Menu,
): 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 &&
singleKey !is ShelfSectionModel.Suggestions
menu.findItem(R.id.action_save)?.isVisible = singleKey !is ShelfSectionModel.Local
return super.onPrepareActionMode(controller, mode, menu)
}
override fun onActionItemClicked(
controller: SectionedSelectionController<ShelfSectionModel>,
mode: ActionMode,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_share -> {
ShareHelper(context).shareMangaLinks(collectSelectedItems(controller))
mode.finish()
true
}
R.id.action_favourite -> {
FavouriteCategoriesSheet.show(fragmentManager, collectSelectedItems(controller))
mode.finish()
true
}
R.id.action_save -> {
viewModel.download(collectSelectedItems(controller))
mode.finish()
true
}
R.id.action_remove -> {
val (group, ids) = controller.snapshot().entries.singleOrNull { it.value.isNotEmpty() } ?: return false
when (group) {
is ShelfSectionModel.Favourites -> viewModel.removeFromFavourites(group.category, ids)
is ShelfSectionModel.History -> viewModel.removeFromHistory(ids)
is ShelfSectionModel.Updated -> return false
is ShelfSectionModel.Local -> {
showDeletionConfirm(ids, mode)
return true
}
is ShelfSectionModel.Suggestions -> return false
}
mode.finish()
true
}
else -> false
}
}
override fun onSelectionChanged(controller: SectionedSelectionController<ShelfSectionModel>, count: Int) {
recyclerView.invalidateNestedItemDecorations()
}
override fun onCreateItemDecoration(
controller: SectionedSelectionController<ShelfSectionModel>,
section: ShelfSectionModel,
): AbstractSelectionItemDecoration = MangaSelectionDecoration(context)
private fun collectSelectedItemsMap(
controller: SectionedSelectionController<ShelfSectionModel>,
): Map<ShelfSectionModel, Set<Manga>> {
val snapshot = controller.peekCheckedIds()
if (snapshot.isEmpty()) {
return emptyMap()
}
return snapshot.mapValues { (_, ids) -> viewModel.getManga(ids) }
}
private fun collectSelectedItems(
controller: SectionedSelectionController<ShelfSectionModel>,
): Set<Manga> {
val snapshot = controller.peekCheckedIds()
if (snapshot.isEmpty()) {
return emptySet()
}
return viewModel.getManga(snapshot.values.flattenTo(HashSet()))
}
private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
if (ids.isEmpty()) {
return
}
MaterialAlertDialogBuilder(context)
.setTitle(R.string.delete_manga)
.setMessage(context.getString(R.string.text_delete_local_manga_batch))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal(ids)
mode.finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}

View File

@@ -1,278 +0,0 @@
package org.koitharu.kotatsu.shelf.ui
import androidx.collection.ArraySet
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.os.NetworkState
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.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.EmptyState
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.toGridModel
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.shelf.domain.ShelfContentObserveUseCase
import org.koitharu.kotatsu.shelf.domain.model.ShelfContent
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.sync.domain.SyncController
import javax.inject.Inject
@HiltViewModel
class ShelfViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val settings: AppSettings,
private val downloadScheduler: DownloadWorker.Scheduler,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val listExtraProvider: ListExtraProvider,
shelfContentObserveUseCase: ShelfContentObserveUseCase,
syncController: SyncController,
networkState: NetworkState,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
val onDownloadStarted = MutableEventFlow<Unit>()
val content: StateFlow<List<ListModel>> = combine(
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled },
settings.observeAsFlow(AppSettings.KEY_SUGGESTIONS) { isSuggestionsEnabled },
networkState,
shelfContentObserveUseCase(),
) { sections, isTrackerEnabled, isSuggestionsEnabled, isConnected, content ->
mapList(content, isTrackerEnabled, isSuggestionsEnabled, sections, isConnected)
}.catch { e ->
emit(listOf(e.toErrorState(canRetry = false)))
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init {
launchJob(Dispatchers.Default) {
syncController.requestFullSync()
}
}
fun removeFromFavourites(category: FavouriteCategory, ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob(Dispatchers.Default) {
val handle = favouritesRepository.removeFromCategory(category.id, ids)
onActionDone.call(ReversibleAction(R.string.removed_from_favourites, handle))
}
}
fun removeFromHistory(ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob(Dispatchers.Default) {
val handle = historyRepository.delete(ids)
onActionDone.call(ReversibleAction(R.string.removed_from_history, handle))
}
}
fun deleteLocal(ids: Set<Long>) {
launchLoadingJob(Dispatchers.Default) {
deleteLocalMangaUseCase(ids)
onActionDone.call(ReversibleAction(R.string.removal_completed, null))
}
}
fun clearHistory(minDate: Long) {
launchJob(Dispatchers.Default) {
val stringRes = if (minDate <= 0) {
historyRepository.clear()
R.string.history_cleared
} else {
historyRepository.deleteAfter(minDate)
R.string.removed_from_history
}
onActionDone.call(ReversibleAction(stringRes, null))
}
}
fun getManga(ids: Set<Long>): Set<Manga> {
val snapshot = content.value
val result = ArraySet<Manga>(ids.size)
for (section in snapshot) {
if (section !is ShelfSectionModel) {
continue
}
for (item in section.items) {
if (item.id in ids) {
result.add(item.manga)
if (result.size == ids.size) {
return result
}
}
}
}
return result
}
fun download(items: Set<Manga>) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(items)
onDownloadStarted.call(Unit)
}
}
private suspend fun mapList(
content: ShelfContent,
isTrackerEnabled: Boolean,
isSuggestionsEnabled: Boolean,
sections: List<ShelfSection>,
isNetworkAvailable: Boolean,
): List<ListModel> {
val result = ArrayList<ListModel>(content.favourites.keys.size + sections.size)
if (isNetworkAvailable) {
for (section in sections) {
when (section) {
ShelfSection.HISTORY -> mapHistory(result, content.history)
ShelfSection.LOCAL -> mapLocal(result, content.local)
ShelfSection.UPDATED -> if (isTrackerEnabled) {
mapUpdated(result, content.updated)
}
ShelfSection.FAVORITES -> mapFavourites(result, content.favourites)
ShelfSection.SUGGESTIONS -> if (isSuggestionsEnabled) {
mapSuggestions(result, content.suggestions)
}
}
}
} else {
result += EmptyHint(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.network_unavailable,
textSecondary = R.string.network_unavailable_hint,
actionStringRes = R.string.manage,
)
for (section in sections) {
when (section) {
ShelfSection.HISTORY -> mapHistory(
result,
content.history.filter { it.source == MangaSource.LOCAL },
)
ShelfSection.LOCAL -> mapLocal(result, content.local)
ShelfSection.UPDATED -> Unit
ShelfSection.FAVORITES -> Unit
ShelfSection.SUGGESTIONS -> Unit
}
}
}
if (result.isEmpty()) {
result += EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_shelf_holder_primary,
textSecondary = R.string.text_shelf_holder_secondary,
actionStringRes = 0,
)
} else {
val one = result.singleOrNull()
if (one is EmptyHint) {
result[0] = one.toState()
}
}
return result
}
private suspend fun mapHistory(
destination: MutableList<in ShelfSectionModel.History>,
list: List<Manga>,
) {
if (list.isEmpty()) {
return
}
destination += ShelfSectionModel.History(
items = list.map { manga ->
manga.toGridModel(listExtraProvider)
},
showAllButtonText = R.string.show_all,
)
}
private suspend fun mapUpdated(
destination: MutableList<in ShelfSectionModel.Updated>,
updated: List<Manga>,
) {
if (updated.isEmpty()) {
return
}
settings.isReadingIndicatorsEnabled
destination += ShelfSectionModel.Updated(
items = updated.map { manga ->
manga.toGridModel(listExtraProvider)
},
showAllButtonText = R.string.show_all,
)
}
private suspend fun mapLocal(
destination: MutableList<in ShelfSectionModel.Local>,
local: List<Manga>,
) {
if (local.isEmpty()) {
return
}
destination += ShelfSectionModel.Local(
items = local.toUi(ListMode.GRID, listExtraProvider),
showAllButtonText = R.string.show_all,
)
}
private suspend fun mapSuggestions(
destination: MutableList<in ShelfSectionModel.Suggestions>,
suggestions: List<Manga>,
) {
if (suggestions.isEmpty()) {
return
}
destination += ShelfSectionModel.Suggestions(
items = suggestions.toUi(ListMode.GRID, listExtraProvider),
showAllButtonText = R.string.show_all,
)
}
private suspend fun mapFavourites(
destination: MutableList<in ShelfSectionModel.Favourites>,
favourites: Map<FavouriteCategory, List<Manga>>,
) {
if (favourites.isEmpty()) {
return
}
for ((category, list) in favourites) {
if (list.isNotEmpty()) {
destination += ShelfSectionModel.Favourites(
items = list.toUi(ListMode.GRID, listExtraProvider),
category = category,
showAllButtonText = R.string.show_all,
)
}
}
}
}

View File

@@ -1,33 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class ScrollKeepObserver(
private val recyclerView: RecyclerView,
) : RecyclerView.AdapterDataObserver() {
private val layoutManager: LinearLayoutManager
get() = recyclerView.layoutManager as LinearLayoutManager
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
val position = minOf(toPosition, fromPosition) // if items are swapping positions may be swapped too
if (firstVisiblePosition != RecyclerView.NO_POSITION && (position == 0 || position < firstVisiblePosition)) {
postScroll(position)
}
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePosition != RecyclerView.NO_POSITION && (positionStart == 0 || positionStart < firstVisiblePosition)) {
postScroll(positionStart)
}
}
private fun postScroll(targetPosition: Int) {
recyclerView.post {
layoutManager.scrollToPositionWithOffset(targetPosition, 0)
}
}
}

View File

@@ -1,50 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.NestedScrollStateHandle
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
class ShelfAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
listener: ShelfListEventListener,
sizeResolver: ItemSizeResolver,
selectionController: SectionedSelectionController<ShelfSectionModel>,
nestedScrollStateHandle: NestedScrollStateHandle,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
val pool = RecyclerView.RecycledViewPool()
delegatesManager.addDelegate(
shelfGroupAD(
sharedPool = pool,
lifecycleOwner = lifecycleOwner,
coil = coil,
sizeResolver = sizeResolver,
selectionController = selectionController,
listener = listener,
nestedScrollStateHandle = nestedScrollStateHandle,
),
).addDelegate(loadingStateAD()).addDelegate(loadingFooterAD())
.addDelegate(emptyHintAD(coil, lifecycleOwner, listener))
.addDelegate(emptyStateListAD(coil, lifecycleOwner, listener)).addDelegate(errorStateListAD(listener))
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val item = items.getOrNull(position) as? ShelfSectionModel ?: return null
return item.getTitle(context.resources)
}
}

View File

@@ -1,76 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.NestedScrollStateHandle
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.util.ext.removeItemDecoration
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.ItemListGroupBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
fun shelfGroupAD(
sharedPool: RecyclerView.RecycledViewPool,
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
sizeResolver: ItemSizeResolver,
selectionController: SectionedSelectionController<ShelfSectionModel>,
listener: ShelfListEventListener,
nestedScrollStateHandle: NestedScrollStateHandle,
) = adapterDelegateViewBinding<ShelfSectionModel, ListModel, ItemListGroupBinding>(
{ layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) },
) {
val listenerAdapter = object : OnListItemClickListener<Manga>, View.OnClickListener {
override fun onItemClick(item: Manga, view: View) {
listener.onItemClick(item, this@adapterDelegateViewBinding.item, view)
}
override fun onItemLongClick(item: Manga, view: View): Boolean {
return listener.onItemLongClick(item, this@adapterDelegateViewBinding.item, view)
}
override fun onClick(v: View?) {
listener.onSectionClick(item, itemView)
}
}
val adapter = AsyncListDifferDelegationAdapter(
ListModelDiffCallback,
mangaGridItemAD(coil, lifecycleOwner, sizeResolver, listenerAdapter),
)
adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
adapter.registerAdapterDataObserver(ScrollKeepObserver(binding.recyclerView))
binding.recyclerView.setRecycledViewPool(sharedPool)
binding.recyclerView.adapter = adapter
val spacingDecoration = SpacingItemDecoration(context.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
binding.recyclerView.addItemDecoration(spacingDecoration)
binding.buttonMore.setOnClickListener(listenerAdapter)
val stateController = nestedScrollStateHandle.attach(binding.recyclerView)
bind {
selectionController.attachToRecyclerView(item, binding.recyclerView)
binding.textViewTitle.text = item.getTitle(context.resources)
binding.buttonMore.setTextAndVisible(item.showAllButtonText)
adapter.items = item.items
stateController.onBind(bindingAdapterPosition)
}
onViewRecycled {
stateController.onRecycled()
adapter.items = emptyList()
binding.recyclerView.removeItemDecoration(AbstractSelectionItemDecoration::class.java)
}
}

View File

@@ -1,15 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import android.view.View
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.parsers.model.Manga
interface ShelfListEventListener : ListStateHolderListener {
fun onItemClick(item: Manga, section: ShelfSectionModel, view: View)
fun onItemLongClick(item: Manga, section: ShelfSectionModel, view: View): Boolean
fun onSectionClick(section: ShelfSectionModel, view: View)
}

View File

@@ -1,101 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.ActivityShelfSettingsBinding
import com.google.android.material.R as materialR
@AndroidEntryPoint
class ShelfSettingsActivity :
BaseActivity<ActivityShelfSettingsBinding>(),
View.OnClickListener, ShelfSettingsListener {
private val viewModel by viewModels<ShelfSettingsViewModel>()
private lateinit var reorderHelper: ItemTouchHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityShelfSettingsBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
viewBinding.buttonDone.setOnClickListener(this)
val settingsAdapter = ShelfSettingsAdapter(this)
with(viewBinding.recyclerView) {
setHasFixedSize(true)
adapter = settingsAdapter
reorderHelper = ItemTouchHelper(SectionsReorderCallback()).also {
it.attachToRecyclerView(this)
}
}
viewModel.content.observe(this) { settingsAdapter.items = it }
}
override fun onItemCheckedChanged(item: ShelfSettingsItemModel, isChecked: Boolean) {
viewModel.setItemChecked(item, isChecked)
}
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) {
reorderHelper.startDrag(holder)
}
override fun onClick(v: View?) {
finishAfterTransition()
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
viewBinding.recyclerView.updatePadding(
bottom = insets.bottom,
)
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
private inner class SectionsReorderCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
0,
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSections(
viewHolder.bindingAdapterPosition,
target.bindingAdapterPosition,
)
override fun canDropOver(
recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
}
companion object {
fun newIntent(context: Context) = Intent(context, ShelfSettingsActivity::class.java)
}
}

View File

@@ -1,14 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.list.ui.model.ListModel
class ShelfSettingsAdapter(
listener: ShelfSettingsListener,
) : BaseListAdapter<ListModel>() {
init {
delegatesManager.addDelegate(shelfCategoryAD(listener))
.addDelegate(shelfSectionAD(listener))
}
}

View File

@@ -1,78 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.widget.CompoundButton
import androidx.core.view.updatePaddingRelative
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemShelfSectionDraggableBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
@SuppressLint("ClickableViewAccessibility")
fun shelfSectionAD(
listener: ShelfSettingsListener,
) =
adapterDelegateViewBinding<ShelfSettingsItemModel.Section, ListModel, ItemShelfSectionDraggableBinding>(
{ layoutInflater, parent -> ItemShelfSectionDraggableBinding.inflate(layoutInflater, parent, false) },
) {
val eventListener = object :
View.OnTouchListener,
CompoundButton.OnCheckedChangeListener {
override fun onTouch(v: View?, event: MotionEvent): Boolean {
return if (event.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onDragHandleTouch(this@adapterDelegateViewBinding)
true
} else {
false
}
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
listener.onItemCheckedChanged(item, isChecked)
}
}
binding.switchToggle.setOnCheckedChangeListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener)
bind { payloads ->
binding.textViewTitle.setText(item.section.titleResId)
binding.switchToggle.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun shelfCategoryAD(
listener: ShelfSettingsListener,
) =
adapterDelegateViewBinding<ShelfSettingsItemModel.FavouriteCategory, ListModel, ItemCategoryCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onItemCheckedChanged(item, !item.isChecked)
}
binding.root.updatePaddingRelative(
start = binding.root.paddingStart * 2,
end = binding.root.paddingStart,
)
bind { payloads ->
binding.root.text = item.title
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
private val ShelfSection.titleResId: Int
get() = when (this) {
ShelfSection.HISTORY -> R.string.history
ShelfSection.LOCAL -> R.string.local_storage
ShelfSection.UPDATED -> R.string.updated
ShelfSection.FAVORITES -> R.string.favourites
ShelfSection.SUGGESTIONS -> R.string.suggestions
}

View File

@@ -1,73 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
sealed interface ShelfSettingsItemModel : ListModel {
val isChecked: Boolean
class Section(
val section: ShelfSection,
override val isChecked: Boolean,
) : ShelfSettingsItemModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Section && section == other.section
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is Section && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Section
if (section != other.section) return false
return isChecked == other.isChecked
}
override fun hashCode(): Int {
var result = section.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
}
class FavouriteCategory(
val id: Long,
val title: String,
override val isChecked: Boolean,
) : ShelfSettingsItemModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is FavouriteCategory && other.id == id
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FavouriteCategory
if (id != other.id) return false
if (title != other.title) return false
return isChecked == other.isChecked
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
}
}

View File

@@ -1,10 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config
import androidx.recyclerview.widget.RecyclerView
interface ShelfSettingsListener {
fun onItemCheckedChanged(item: ShelfSettingsItemModel, isChecked: Boolean)
fun onDragHandleTouch(holder: RecyclerView.ViewHolder)
}

View File

@@ -1,109 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.parsers.util.move
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
import javax.inject.Inject
@HiltViewModel
class ShelfSettingsViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
private val settings: AppSettings,
) : BaseViewModel() {
val content = combine(
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled },
favouritesRepository.observeCategories(),
) { sections, isTrackerEnabled, categories ->
buildList(sections, isTrackerEnabled, categories)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
private var updateJob: Job? = null
fun setItemChecked(item: ShelfSettingsItemModel, isChecked: Boolean) {
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob?.join()
when (item) {
is ShelfSettingsItemModel.FavouriteCategory -> {
favouritesRepository.updateCategory(item.id, isChecked)
}
is ShelfSettingsItemModel.Section -> {
val sections = settings.shelfSections
settings.shelfSections = if (isChecked) {
sections + item.section
} else {
if (sections.size > 1) {
sections - item.section
} else {
return@launchJob
}
}
}
}
}
}
fun reorderSections(oldPos: Int, newPos: Int): Boolean {
val snapshot = content.value.toMutableList()
snapshot.move(oldPos, newPos)
settings.shelfSections = snapshot.sections()
return true
}
private fun buildList(
sections: List<ShelfSection>,
isTrackerEnabled: Boolean,
categories: List<FavouriteCategory>
): List<ShelfSettingsItemModel> {
val result = ArrayList<ShelfSettingsItemModel>()
val sectionsList = ShelfSection.values().toMutableList()
if (!isTrackerEnabled) {
sectionsList.remove(ShelfSection.UPDATED)
}
for (section in sections) {
if (sectionsList.remove(section)) {
result.addSection(section, true, categories)
}
}
for (section in sectionsList) {
result.addSection(section, false, categories)
}
return result
}
private fun MutableList<in ShelfSettingsItemModel>.addSection(
section: ShelfSection,
isEnabled: Boolean,
favouriteCategories: List<FavouriteCategory>,
) {
add(ShelfSettingsItemModel.Section(section, isEnabled))
if (isEnabled && section == ShelfSection.FAVORITES) {
favouriteCategories.mapTo(this) {
ShelfSettingsItemModel.FavouriteCategory(
id = it.id,
title = it.title,
isChecked = it.isVisibleInLibrary,
)
}
}
}
private fun List<ShelfSettingsItemModel>.sections(): List<ShelfSection> {
return mapNotNull { (it as? ShelfSettingsItemModel.Section)?.takeIf { x -> x.isChecked }?.section }
}
}

View File

@@ -1,67 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config.size
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import com.google.android.material.slider.LabelFormatter
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.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.databinding.SheetShelfSizeBinding
import javax.inject.Inject
@AndroidEntryPoint
class ShelfSizeBottomSheet :
BaseBottomSheet<SheetShelfSizeBinding>(),
Slider.OnChangeListener,
View.OnClickListener {
@Inject
lateinit var settings: AppSettings
private var labelFormatter: LabelFormatter? = null
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetShelfSizeBinding {
return SheetShelfSizeBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetShelfSizeBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
labelFormatter = IntPercentLabelFormatter(binding.root.context)
binding.sliderGrid.addOnChangeListener(this)
binding.buttonSmall.setOnClickListener(this)
binding.buttonLarge.setOnClickListener(this)
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
}
override fun onDestroyView() {
labelFormatter = null
super.onDestroyView()
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
settings.gridSize = value.toInt()
requireViewBinding().textViewLabel.text = labelFormatter?.getFormattedValue(value)
}
override fun onClick(v: View) {
val slider = requireViewBinding().sliderGrid
when (v.id) {
R.id.button_small -> slider.setValueRounded(slider.value - slider.stepSize)
R.id.button_large -> slider.setValueRounded(slider.value + slider.stepSize)
}
}
companion object {
private const val TAG = "ShelfSizeBottomSheet"
fun show(fm: FragmentManager) = ShelfSizeBottomSheet().show(fm, TAG)
}
}

View File

@@ -1,176 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.model
import android.content.res.Resources
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
sealed interface ShelfSectionModel : ListModel {
val items: List<MangaItemModel>
@get:StringRes
val showAllButtonText: Int
val key: String
fun getTitle(resources: Resources): CharSequence
override fun toString(): String
override fun areItemsTheSame(other: ListModel): Boolean {
return other is ShelfSectionModel && key == other.key
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is ShelfSectionModel) {
Unit
} else {
null
}
}
class History(
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "history"
override fun getTitle(resources: Resources) = resources.getString(R.string.history)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as History
if (showAllButtonText != other.showAllButtonText) return false
return items == other.items
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + showAllButtonText.hashCode()
return result
}
override fun toString(): String = key
}
class Favourites(
override val items: List<MangaItemModel>,
val category: FavouriteCategory,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "fav_${category.id}"
override fun getTitle(resources: Resources) = category.title
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Favourites
if (category != other.category) return false
if (showAllButtonText != other.showAllButtonText) return false
return items == other.items
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + category.hashCode()
result = 31 * result + showAllButtonText.hashCode()
return result
}
override fun toString(): String = key
}
class Updated(
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "upd"
override fun getTitle(resources: Resources) = resources.getString(R.string.updated)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Updated
if (items != other.items) return false
return showAllButtonText == other.showAllButtonText
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + showAllButtonText
return result
}
override fun toString(): String = key
}
class Local(
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "local"
override fun getTitle(resources: Resources) = resources.getString(R.string.local_storage)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Local
if (items != other.items) return false
return showAllButtonText == other.showAllButtonText
}
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 {
var result = items.hashCode()
result = 31 * result + showAllButtonText
return result
}
override fun toString(): String = key
}
}

View File

@@ -72,7 +72,7 @@
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge"
app:layout_constraintBottom_toTopOf="@id/textView_subtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/imageView_handle"
app:layout_constraintStart_toEndOf="@id/imageView_cover3"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
@@ -88,7 +88,7 @@
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/imageView_handle"
app:layout_constraintStart_toEndOf="@id/imageView_cover3"
app:layout_constraintTop_toBottomOf="@id/textView_title"
app:layout_constraintVertical_chainStyle="packed"
@@ -105,6 +105,7 @@
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -6,6 +6,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="4dp"
tools:ignore="RtlSymmetry">
<org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView

View File

@@ -74,13 +74,11 @@
</style>
<style name="Widget.Kotatsu.Tabs" parent="@style/Widget.Material3.TabLayout">
<item name="android:background">?colorOutline</item>
<item name="tabBackground">@drawable/tabs_background</item>
<item name="android:background">@drawable/tabs_background</item>
<item name="tabGravity">center</item>
<item name="tabInlineLabel">true</item>
<item name="tabMinWidth">75dp</item>
<item name="tabMode">scrollable</item>
<item name="tabRippleColor">@color/ripple_toolbar</item>
</style>
<style name="Widget.Kotatsu.SearchView" parent="@style/Widget.AppCompat.SearchView">