diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt index 8f1fbb50d..495e42f45 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt @@ -69,6 +69,7 @@ class MigrateUseCase @Inject constructor( lastCheckTime = System.currentTimeMillis(), lastChapterDate = lastChapter?.uploadDate ?: 0L, lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION, + lastError = null, ) tracksDao.delete(oldDetails.id) tracksDao.upsert(newTrack) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt index 53e26189d..02229b7de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt @@ -84,6 +84,7 @@ class JsonDeserializer(private val json: JSONObject) { source = json.getString("source"), isEnabled = json.getBoolean("enabled"), sortKey = json.getInt("sort_key"), + addedIn = json.getIntOrDefault("added_in", 0), ) fun toMap(): Map { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt index c0b3d6efa..bda4584aa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -33,6 +33,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration17To18 import org.koitharu.kotatsu.core.db.migrations.Migration18To19 import org.koitharu.kotatsu.core.db.migrations.Migration19To20 import org.koitharu.kotatsu.core.db.migrations.Migration1To2 +import org.koitharu.kotatsu.core.db.migrations.Migration20To21 import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration4To5 @@ -58,7 +59,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TracksDao -const val DATABASE_VERSION = 20 +const val DATABASE_VERSION = 21 @Database( entities = [ @@ -118,6 +119,7 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf( Migration17To18(), Migration18To19(), Migration19To20(), + Migration20To21(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt index a9a9e1f02..5e0255111 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt @@ -11,6 +11,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.flow.Flow import org.intellij.lang.annotations.Language +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.explore.data.SourcesSortOrder @@ -68,6 +69,7 @@ abstract class MangaSourcesDao { source = source, isEnabled = isEnabled, sortKey = getMaxSortKey() + 1, + addedIn = BuildConfig.VERSION_CODE, ) upsert(entity) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt index 00243e8df..8c8784a46 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt @@ -14,4 +14,5 @@ data class MangaSourceEntity( val source: String, @ColumnInfo(name = "enabled") val isEnabled: Boolean, @ColumnInfo(name = "sort_key", index = true) val sortKey: Int, + @ColumnInfo(name = "added_in") val addedIn: Int, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt new file mode 100644 index 000000000..462b77261 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration20To21 : Migration(20, 21) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt index 48e01376b..8936dd8a0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt @@ -156,6 +156,7 @@ class MangaSourcesRepository @Inject constructor( } } + @Deprecated("") suspend fun assimilateNewSources(): Set { val new = getNewSources() if (new.isEmpty()) { @@ -167,6 +168,7 @@ class MangaSourcesRepository @Inject constructor( source = x.name, isEnabled = false, sortKey = ++maxSortKey, + addedIn = BuildConfig.VERSION_CODE, ) } dao.insertIfAbsent(entities) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt index a8b30a06a..865c4544f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt @@ -6,30 +6,32 @@ import android.view.View import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets -import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar -import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver +import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding +import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.main.ui.owners.AppBarOwner +import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment import javax.inject.Inject @AndroidEntryPoint class SourcesCatalogActivity : BaseActivity(), OnListItemClickListener, - AppBarOwner, MenuItem.OnActionExpandListener { + AppBarOwner, MenuItem.OnActionExpandListener, ChipsView.OnChipClickListener { @Inject lateinit var coil: ImageLoader @@ -45,18 +47,24 @@ class SourcesCatalogActivity : BaseActivity(), super.onCreate(savedInstanceState) setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - val pagerAdapter = SourcesCatalogPagerAdapter(this, coil, this) - viewBinding.pager.adapter = pagerAdapter - val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter) - tabMediator.attach() - viewModel.content.observe(this, pagerAdapter) + val sourcesAdapter = SourcesCatalogAdapter(this, coil, this) + with(viewBinding.recyclerView) { + setHasFixedSize(true) + addItemDecoration(TypedListSpacingDecoration(context, false)) + adapter = sourcesAdapter + } + viewBinding.chipsFilter.onChipClickListener = this + viewModel.content.observe(this, sourcesAdapter) viewModel.hasNewSources.observe(this, ::onHasNewSourcesChanged) viewModel.onActionDone.observeEvent( this, - ReversibleActionObserver(viewBinding.pager), + ReversibleActionObserver(viewBinding.recyclerView), ) - viewModel.locale.observe(this) { - supportActionBar?.subtitle = it?.toLocale().getDisplayName(this) + viewModel.appliedFilter.observe(this) { + supportActionBar?.subtitle = it.locale?.toLocale().getDisplayName(this) + } + viewModel.filter.observe(this) { + viewBinding.chipsFilter.setChips(it) } addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) } @@ -68,21 +76,23 @@ class SourcesCatalogActivity : BaseActivity(), ) } + override fun onChipClick(chip: Chip, data: Any?) { + when (data) { + is ContentType -> viewModel.setContentType(data, chip.isChecked) + } + } + override fun onItemClick(item: SourceCatalogItem.Source, view: View) { viewModel.addSource(item.source) } override fun onMenuItemActionExpand(item: MenuItem): Boolean { - viewBinding.tabs.isVisible = false - viewBinding.pager.isUserInputEnabled = false val sq = (item.actionView as? SearchView)?.query?.trim()?.toString().orEmpty() viewModel.performSearch(sq) return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - viewBinding.tabs.isVisible = true - viewBinding.pager.isUserInputEnabled = true viewModel.performSearch(null) return true } @@ -92,7 +102,7 @@ class SourcesCatalogActivity : BaseActivity(), if (newSourcesSnackbar?.isShownOrQueued == true) { return } - val snackbar = Snackbar.make(viewBinding.pager, R.string.new_sources_text, Snackbar.LENGTH_INDEFINITE) + val snackbar = Snackbar.make(viewBinding.recyclerView, R.string.new_sources_text, Snackbar.LENGTH_INDEFINITE) snackbar.setAction(R.string.explore) { NewSourcesDialogFragment.show(supportFragmentManager) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogFilter.kt new file mode 100644 index 000000000..979ed677a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogFilter.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.settings.sources.catalog + +import org.koitharu.kotatsu.parsers.model.ContentType +import java.util.Locale + +data class SourcesCatalogFilter( + val types: Set, + val locale: String?, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogListProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogListProducer.kt index 40c42c3e2..3d11ff033 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogListProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogListProducer.kt @@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.ContentType +@Deprecated("") class SourcesCatalogListProducer @AssistedInject constructor( @Assisted private val locale: String?, @Assisted private val contentType: ContentType, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt index 6d573ac34..c8f4ffe99 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -1,29 +1,25 @@ package org.koitharu.kotatsu.settings.sources.catalog -import androidx.annotation.MainThread import androidx.lifecycle.viewModelScope -import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest 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.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction +import org.koitharu.kotatsu.core.ui.widgets.ChipsView.ChipModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet -import java.util.EnumMap import java.util.EnumSet import java.util.Locale import javax.inject.Inject @@ -31,41 +27,40 @@ import javax.inject.Inject @HiltViewModel class SourcesCatalogViewModel @Inject constructor( private val repository: MangaSourcesRepository, - private val listProducerFactory: SourcesCatalogListProducer.Factory, - private val settings: AppSettings, ) : BaseViewModel() { - private val lifecycle = RetainedLifecycleImpl() - private var searchQuery: String? = null val onActionDone = MutableEventFlow() val locales = repository.allMangaSources.mapToSet { it.locale } - val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales }) + + private val searchQuery = MutableStateFlow(null) + val appliedFilter = MutableStateFlow( + SourcesCatalogFilter( + types = emptySet(), + locale = Locale.getDefault().language.takeIf { it in locales }, + ), + ) val hasNewSources = repository.observeNewSources() .map { it.isNotEmpty() } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - private val listProducers = locale.map { lc -> - createListProducers(lc) - }.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value)) + val filter: StateFlow> = appliedFilter.map { + buildFilter(it) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, buildFilter(appliedFilter.value)) - val content: StateFlow> = listProducers.flatMapLatest { - val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } } - combine>(flows, Array::toList) + val content: StateFlow> = combine( + searchQuery, + appliedFilter, + ) { q, f -> + buildSourcesList(f, q) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - override fun onCleared() { - super.onCleared() - lifecycle.dispatchOnCleared() - } - fun performSearch(query: String?) { - searchQuery = query - listProducers.value.forEach { (_, v) -> v.setQuery(query) } + searchQuery.value = query?.trim() } fun setLocale(value: String?) { - locale.value = value + appliedFilter.value = appliedFilter.value.copy(locale = value) } fun addSource(source: MangaSource) { @@ -81,15 +76,64 @@ class SourcesCatalogViewModel @Inject constructor( } } - @MainThread - private fun createListProducers(lc: String?): Map { - val types = EnumSet.allOf(ContentType::class.java) - if (settings.isNsfwContentDisabled) { - types.remove(ContentType.HENTAI) + fun setContentType(value: ContentType, isAdd: Boolean) { + val filter = appliedFilter.value + val types = EnumSet.noneOf(ContentType::class.java) + types.addAll(filter.types) + if (isAdd) { + types.add(value) + } else { + types.remove(value) } - return types.associateWithTo(EnumMap(ContentType::class.java)) { type -> - listProducerFactory.create(lc, type, lifecycle).also { - it.setQuery(searchQuery) + appliedFilter.value = filter.copy(types = types) + } + + private fun buildFilter(applied: SourcesCatalogFilter): List = buildList(ContentType.entries.size) { + for (ct in ContentType.entries) { + add( + ChipModel( + tint = 0, + title = ct.name, + icon = 0, + isCheckable = true, + isChecked = ct in applied.types, + data = ct, + ), + ) + } + } + + private suspend fun buildSourcesList(filter: SourcesCatalogFilter, query: String?): List { + val sources = repository.getDisabledSources().toMutableList() + sources.retainAll { + (filter.types.isEmpty() || it.contentType in filter.types) && it.locale == filter.locale + } + if (!query.isNullOrEmpty()) { + sources.retainAll { it.title.contains(query, ignoreCase = true) } + } + return if (sources.isEmpty()) { + listOf( + if (query == null) { + SourceCatalogItem.Hint( + icon = R.drawable.ic_empty_feed, + title = R.string.no_manga_sources, + text = R.string.no_manga_sources_catalog_text, + ) + } else { + SourceCatalogItem.Hint( + icon = R.drawable.ic_empty_feed, + title = R.string.nothing_found, + text = R.string.no_manga_sources_found, + ) + }, + ) + } else { + sources.sortBy { it.title } + sources.map { + SourceCatalogItem.Source( + source = it, + showSummary = query != null, + ) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt index c152253ac..127a60b4c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt @@ -25,6 +25,7 @@ class TrackEntity( @ColumnInfo(name = "last_check_time") val lastCheckTime: Long, @ColumnInfo(name = "last_chapter_date") val lastChapterDate: Long, @ColumnInfo(name = "last_result") val lastResult: Int, + @ColumnInfo(name = "last_error") val lastError: String?, ) { companion object { @@ -42,6 +43,7 @@ class TrackEntity( lastCheckTime = 0L, lastChapterDate = 0, lastResult = RESULT_NONE, + lastError = null, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index 628b5ea29..011d50a0e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -174,6 +174,7 @@ class TrackingRepository @Inject constructor( lastCheckTime = tracking.lastCheck?.toEpochMilli() ?: 0L, lastChapterDate = tracking.lastChapterDate?.toEpochMilli() ?: 0L, lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION, + lastError = null, ) db.getTracksDao().upsert(entity) } @@ -230,6 +231,7 @@ class TrackingRepository @Inject constructor( lastCheckTime = System.currentTimeMillis(), lastChapterDate = lastChapterDate, lastResult = TrackEntity.RESULT_FAILED, + lastError = updates.error?.toString(), ) is MangaUpdates.Success -> TrackEntity( @@ -239,6 +241,7 @@ class TrackingRepository @Inject constructor( lastCheckTime = System.currentTimeMillis(), lastChapterDate = updates.lastChapterDate().ifZero { lastChapterDate }, lastResult = if (updates.isNotEmpty()) TrackEntity.RESULT_HAS_UPDATE else TrackEntity.RESULT_NO_UPDATE, + lastError = null, ) } } diff --git a/app/src/main/res/layout/activity_sources_catalog.xml b/app/src/main/res/layout/activity_sources_catalog.xml index 7b844cac0..528ad1107 100644 --- a/app/src/main/res/layout/activity_sources_catalog.xml +++ b/app/src/main/res/layout/activity_sources_catalog.xml @@ -19,19 +19,36 @@ android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" /> - + android:clipToPadding="false" + android:paddingHorizontal="@dimen/list_spacing_large" + android:scrollbars="none"> + + + + -