From 9c94a273eadc74fddf48d6be0c647095630f3a69 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 20 Jul 2022 14:08:58 +0300 Subject: [PATCH] Slider in reader #204 --- .../kotatsu/reader/domain/ChapterPages.kt | 70 ++++++++++++ .../kotatsu/reader/domain/ChaptersLoader.kt | 70 ++++++++++++ .../kotatsu/reader/ui/PageLabelFormatter.kt | 11 ++ .../kotatsu/reader/ui/ReaderActivity.kt | 44 ++------ .../kotatsu/reader/ui/ReaderSliderListener.kt | 39 +++++++ .../kotatsu/reader/ui/ReaderViewModel.kt | 102 +++++++----------- .../kotatsu/reader/ui/pager/ReaderUiState.kt | 6 +- .../org/koitharu/kotatsu/utils/ext/FlowExt.kt | 11 +- .../res/layout-w600dp/activity_reader.xml | 83 -------------- app/src/main/res/layout/activity_reader.xml | 16 ++- app/src/main/res/menu/opt_reader_bottom.xml | 25 +---- app/src/main/res/values/strings.xml | 1 + .../kotatsu/core/backup/JsonSerializerTest.kt | 5 +- .../kotatsu/reader/domain/ChapterPagesTest.kt | 79 ++++++++++++++ 14 files changed, 349 insertions(+), 213 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/reader/domain/ChapterPages.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/reader/ui/PageLabelFormatter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt delete mode 100644 app/src/main/res/layout-w600dp/activity_reader.xml create mode 100644 app/src/test/java/org/koitharu/kotatsu/reader/domain/ChapterPagesTest.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChapterPages.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChapterPages.kt new file mode 100644 index 000000000..56248f4ad --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChapterPages.kt @@ -0,0 +1,70 @@ +package org.koitharu.kotatsu.reader.domain + +import androidx.collection.LongSparseArray +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage + +class ChapterPages private constructor(private val pages: ArrayDeque) : List by pages { + + // map chapterId to index in pages deque + private val indices = LongSparseArray() + + constructor() : this(ArrayDeque()) + + val chaptersSize: Int + get() = indices.size() + + fun removeFirst() { + val chapterId = pages.first().chapterId + indices.remove(chapterId) + var delta = 0 + while (pages.first().chapterId == chapterId) { + pages.removeFirst() + delta-- + } + shiftIndices(delta) + } + + fun removeLast() { + val chapterId = pages.last().chapterId + indices.remove(chapterId) + while (pages.last().chapterId == chapterId) { + pages.removeLast() + } + } + + fun addLast(id: Long, newPages: List) { + indices.put(id, pages.size until (pages.size + newPages.size)) + pages.addAll(newPages) + } + + fun addFirst(id: Long, newPages: List) { + shiftIndices(newPages.size) + indices.put(id, newPages.indices) + pages.addAll(0, newPages) + } + + fun clear() { + indices.clear() + pages.clear() + } + + fun size(id: Long) = indices[id]?.run { + endInclusive - start + 1 + } ?: 0 + + fun subList(id: Long): List { + val range = indices[id] ?: return emptyList() + return pages.subList(range.first, range.last + 1) + } + + private fun shiftIndices(delta: Int) { + for (i in 0 until indices.size()) { + val range = indices.valueAt(i) + indices.setValueAt(i, range + delta) + } + } + + private operator fun IntRange.plus(delta: Int): IntRange { + return IntRange(start + delta, endInclusive + delta) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt new file mode 100644 index 000000000..75c096296 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt @@ -0,0 +1,70 @@ +package org.koitharu.kotatsu.reader.domain + +import android.util.LongSparseArray +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage + +private const val PAGES_TRIM_THRESHOLD = 120 + +class ChaptersLoader { + + val chapters = LongSparseArray() + private val chapterPages = ChapterPages() + private val mutex = Mutex() + + suspend fun loadPrevNextChapter(manga: Manga, currentId: Long, isNext: Boolean) { + val chapters = manga.chapters ?: return + val predicate: (MangaChapter) -> Boolean = { it.id == currentId } + val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate) + if (index == -1) return + val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return + val newPages = loadChapter(manga, newChapter.id) + mutex.withLock { + if (chapterPages.chaptersSize > 1) { + // trim pages + if (chapterPages.size > PAGES_TRIM_THRESHOLD) { + if (isNext) { + chapterPages.removeFirst() + } else { + chapterPages.removeLast() + } + } + } + if (isNext) { + chapterPages.addLast(newChapter.id, newPages) + } else { + chapterPages.addFirst(newChapter.id, newPages) + } + } + } + + suspend fun loadSingleChapter(manga: Manga, chapterId: Long) { + val pages = loadChapter(manga, chapterId) + mutex.withLock { + chapterPages.clear() + chapterPages.addLast(chapterId, pages) + } + } + + fun getPages(chapterId: Long): List { + return chapterPages.subList(chapterId) + } + + fun getPagesCount(chapterId: Long): Int { + return chapterPages.size(chapterId) + } + + fun snapshot() = chapterPages.toList() + + private suspend fun loadChapter(manga: Manga, chapterId: Long): List { + val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } + val repo = MangaRepository(manga.source) + return repo.getPages(chapter).mapIndexed { index, page -> + ReaderPage(page, index, chapterId) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageLabelFormatter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageLabelFormatter.kt new file mode 100644 index 000000000..749e7884c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageLabelFormatter.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.reader.ui + +import com.google.android.material.slider.LabelFormatter +import org.koitharu.kotatsu.parsers.util.format + +class PageLabelFormatter : LabelFormatter { + + override fun getFormattedValue(value: Float): String { + return (value + 1).format(0) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 690aa1db9..0e8fa4056 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -12,7 +12,6 @@ import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updatePadding -import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.transition.Slide import androidx.transition.TransitionManager @@ -20,8 +19,6 @@ import androidx.transition.TransitionSet import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.get @@ -44,7 +41,6 @@ import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.utils.GridTouchHelper -import org.koitharu.kotatsu.utils.ScreenOrientationHelper import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.* import java.util.concurrent.TimeUnit @@ -68,7 +64,6 @@ class ReaderActivity : } private lateinit var touchHelper: GridTouchHelper - private lateinit var orientationHelper: ScreenOrientationHelper private lateinit var controlDelegate: ReaderControlDelegate private val savePageRequest = registerForActivityResult(PageSaveContract(), this) private var gestureInsets: Insets = Insets.NONE @@ -81,18 +76,12 @@ class ReaderActivity : readerManager = ReaderManager(supportFragmentManager, R.id.container) supportActionBar?.setDisplayHomeAsUpEnabled(true) touchHelper = GridTouchHelper(this, this) - orientationHelper = ScreenOrientationHelper(this) controlDelegate = ReaderControlDelegate(lifecycleScope, get(), this) - binding.toolbarBottom.inflateMenu(R.menu.opt_reader_bottom) binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected) + binding.slider.setLabelFormatter(PageLabelFormatter()) + ReaderSliderListener(this, viewModel).attachToSlider(binding.slider) insetsDelegate.interceptingWindowInsetsListener = this - orientationHelper.observeAutoOrientation() - .flowWithLifecycle(lifecycle) - .onEach { - binding.toolbarBottom.menu.findItem(R.id.action_screen_rotate).isVisible = !it - }.launchIn(lifecycleScope) - viewModel.onError.observe(this, this::onError) viewModel.readerMode.observe(this, this::onInitReader) viewModel.onPageSaved.observe(this, this::onPageSaved) @@ -114,15 +103,6 @@ class ReaderActivity : if (readerManager.currentMode != mode) { readerManager.replace(mode) } - val iconRes = when (mode) { - ReaderMode.WEBTOON -> R.drawable.ic_script - ReaderMode.REVERSED -> R.drawable.ic_read_reversed - ReaderMode.STANDARD -> R.drawable.ic_book_page - } - binding.toolbarBottom.menu.findItem(R.id.action_reader_mode).run { - setIcon(iconRes) - setVisible(true) - } if (binding.appbarTop.isVisible) { lifecycle.postDelayed(hideUiRunnable, TimeUnit.SECONDS.toMillis(1)) } @@ -136,7 +116,7 @@ class ReaderActivity : override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_reader_mode -> { + R.id.action_menu -> { val currentMode = readerManager.currentMode ?: return false ReaderConfigDialog.show(supportFragmentManager, currentMode) } @@ -150,9 +130,6 @@ class ReaderActivity : viewModel.getCurrentState()?.chapterId ?: 0L ) } - R.id.action_screen_rotate -> { - orientationHelper.toggleOrientation() - } R.id.action_pages_thumbs -> { val pages = viewModel.getCurrentChapterPages() if (!pages.isNullOrEmpty()) { @@ -166,12 +143,6 @@ class ReaderActivity : return false } } - R.id.action_save_page -> { - viewModel.getCurrentPage()?.also { page -> - viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) - viewModel.saveCurrentPage(page, savePageRequest) - } ?: return false - } R.id.action_bookmark -> { if (viewModel.isBookmarkAdded.value == true) { viewModel.removeBookmark() @@ -199,7 +170,6 @@ class ReaderActivity : val menu = binding.toolbarBottom.menu menu.findItem(R.id.action_bookmark).isVisible = hasPages menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages - menu.findItem(R.id.action_save_page).isVisible = hasPages } private fun onError(e: Throwable) { @@ -351,6 +321,7 @@ class ReaderActivity : title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_) if (uiState == null) { supportActionBar?.subtitle = null + binding.slider.isVisible = false return } supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) { @@ -363,6 +334,13 @@ class ReaderActivity : binding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION) } } + if (uiState.totalPages > 0) { + binding.slider.value = uiState.currentPage.toFloat() + binding.slider.valueTo = uiState.totalPages.toFloat() + binding.slider.isVisible = true + } else { + binding.slider.isVisible = false + } } private inner class ErrorDialogListener( diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt new file mode 100644 index 000000000..c1e706efa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt @@ -0,0 +1,39 @@ +package org.koitharu.kotatsu.reader.ui + +import com.google.android.material.slider.Slider +import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener + +class ReaderSliderListener( + private val pageSelectListener: OnPageSelectListener, + private val viewModel: ReaderViewModel, +) : Slider.OnChangeListener, Slider.OnSliderTouchListener { + + private var isChanged = false + + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + if (fromUser) { + isChanged = true + } + } + + override fun onStartTrackingTouch(slider: Slider) { + isChanged = false + } + + override fun onStopTrackingTouch(slider: Slider) { + if (isChanged) { + switchPageToIndex(slider.value.toInt()) + } + } + + fun attachToSlider(slider: Slider) { + slider.addOnChangeListener(this) + slider.addOnSliderTouchListener(this) + } + + private fun switchPageToIndex(index: Int) { + val pages = viewModel.getCurrentChapterPages() + val page = pages?.getOrNull(index) ?: return + pageSelectListener.onPageSelected(page) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 4a46f4904..450960bf2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui import android.net.Uri import android.util.LongSparseArray import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.AnyThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope @@ -24,17 +25,17 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.reader.data.filterChapters +import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.processLifecycleScope +import org.koitharu.kotatsu.utils.ext.requireValue import java.util.* private const val BOUNDS_PAGE_OFFSET = 2 -private const val PAGES_TRIM_THRESHOLD = 120 private const val PREFETCH_LIMIT = 10 class ReaderViewModel( @@ -53,25 +54,16 @@ class ReaderViewModel( private var bookmarkJob: Job? = null private val currentState = MutableStateFlow(initialState) private val mangaData = MutableStateFlow(intent.manga) - private val chapters = LongSparseArray() + private val chapters: LongSparseArray + get() = chaptersLoader.chapters val pageLoader = PageLoader() + private val chaptersLoader = ChaptersLoader() val readerMode = MutableLiveData() val onPageSaved = SingleLiveEvent() val onShowToast = SingleLiveEvent() - val uiState: LiveData = combine( - mangaData, - currentState, - ) { manga, state -> - val chapter = state?.chapterId?.let(chapters::get) - ReaderUiState( - mangaName = manga?.title, - chapterName = chapter?.name, - chapterNumber = chapter?.number ?: 0, - chaptersTotal = chapters.size() - ) - }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) + val uiState = MutableLiveData(null) val content = MutableLiveData(ReaderContent(emptyList(), null)) val manga: Manga? @@ -134,7 +126,6 @@ class ReaderViewModel( } } - // TODO check performance fun saveCurrentState(state: ReaderState? = null) { if (state != null) { currentState.value = state @@ -151,8 +142,7 @@ class ReaderViewModel( fun getCurrentChapterPages(): List? { val chapterId = currentState.value?.chapterId ?: return null - val pages = content.value?.pages ?: return null - return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() } + return chaptersLoader.getPages(chapterId).map { it.toMangaPage() } } fun saveCurrentPage( @@ -195,11 +185,12 @@ class ReaderViewModel( loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() content.postValue(ReaderContent(emptyList(), null)) - val newPages = loadChapter(id) - content.postValue(ReaderContent(newPages, ReaderState(id, 0, 0))) + chaptersLoader.loadSingleChapter(mangaData.requireValue(), id) + content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, 0, 0))) } } + // TODO move to background? fun onCurrentPageChanged(position: Int) { val pages = content.value?.pages ?: return pages.getOrNull(position)?.let { page -> @@ -207,14 +198,15 @@ class ReaderViewModel( cs?.copy(chapterId = page.chapterId, page = page.index) } } + notifyStateChanged() if (pages.isEmpty() || loadingJob?.isActive == true) { return } if (position <= BOUNDS_PAGE_OFFSET) { - loadPrevNextChapter(pages.first().chapterId, -1) + loadPrevNextChapter(pages.first().chapterId, isNext = false) } if (position >= pages.size - BOUNDS_PAGE_OFFSET) { - loadPrevNextChapter(pages.last().chapterId, 1) + loadPrevNextChapter(pages.last().chapterId, isNext = true) } if (pageLoader.isPrefetchApplicable()) { pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT)) @@ -279,55 +271,21 @@ class ReaderViewModel( mangaData.value = manga.filterChapters(branch) readerMode.postValue(mode) - val pages = loadChapter(requireNotNull(currentState.value).chapterId) + chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId) // save state currentState.value?.let { val percent = computePercent(it.chapterId, it.page) historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent) } - - content.postValue(ReaderContent(pages, currentState.value)) + notifyStateChanged() + content.postValue(ReaderContent(chaptersLoader.snapshot(), currentState.value)) } } - private suspend fun loadChapter(chapterId: Long): List { - val manga = checkNotNull(mangaData.value) { "Manga is null" } - val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } - val repo = MangaRepository(manga.source) - return repo.getPages(chapter).mapIndexed { index, page -> - ReaderPage(page, index, chapterId) - } - } - - private fun loadPrevNextChapter(currentId: Long, delta: Int) { + private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) { loadingJob = launchLoadingJob(Dispatchers.Default) { - val chapters = mangaData.value?.chapters ?: return@launchLoadingJob - val predicate: (MangaChapter) -> Boolean = { it.id == currentId } - val index = - if (delta < 0) chapters.indexOfLast(predicate) else chapters.indexOfFirst(predicate) - if (index == -1) return@launchLoadingJob - val newChapter = chapters.getOrNull(index + delta) ?: return@launchLoadingJob - val newPages = loadChapter(newChapter.id) - var currentPages = content.value?.pages ?: return@launchLoadingJob - // trim pages - if (currentPages.size > PAGES_TRIM_THRESHOLD) { - val firstChapterId = currentPages.first().chapterId - val lastChapterId = currentPages.last().chapterId - if (firstChapterId != lastChapterId) { - currentPages = when (delta) { - 1 -> currentPages.dropWhile { it.chapterId == firstChapterId } - -1 -> currentPages.dropLastWhile { it.chapterId == lastChapterId } - else -> currentPages - } - } - } - val pages = when (delta) { - 0 -> newPages - -1 -> newPages + currentPages - 1 -> currentPages + newPages - else -> error("Invalid delta $delta") - } - content.postValue(ReaderContent(pages, null)) + chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext) + content.postValue(ReaderContent(chaptersLoader.snapshot(), null)) } } @@ -368,12 +326,26 @@ class ReaderViewModel( }.getOrDefault(defaultMode) } + @AnyThread + private fun notifyStateChanged() { + val state = getCurrentState() + val chapter = state?.chapterId?.let(chapters::get) + val newState = ReaderUiState( + mangaName = manga?.title, + chapterName = chapter?.name, + chapterNumber = chapter?.number ?: 0, + chaptersTotal = chapters.size(), + totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0, + currentPage = state?.page ?: 0, + ) + uiState.postValue(newState) + } + private fun computePercent(chapterId: Long, pageIndex: Int): Float { val chapters = manga?.chapters ?: return PROGRESS_NONE val chaptersCount = chapters.size val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } - val pages = content.value?.pages ?: return PROGRESS_NONE - val pagesCount = pages.count { x -> x.chapterId == chapterId } + val pagesCount = chaptersLoader.getPagesCount(chapterId) if (chaptersCount == 0 || pagesCount == 0) { return PROGRESS_NONE } @@ -401,4 +373,4 @@ private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, p it.printStackTraceDebug() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt index be72b896c..f306d8837 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt @@ -4,5 +4,7 @@ data class ReaderUiState( val mangaName: String?, val chapterName: String?, val chapterNumber: Int, - val chaptersTotal: Int -) \ No newline at end of file + val chaptersTotal: Int, + val currentPage: Int, + val totalPages: Int, +) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt index ac4dc90b9..03ac09a19 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt @@ -2,10 +2,7 @@ package org.koitharu.kotatsu.utils.ext import android.os.SystemClock import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.* fun Flow.onFirst(action: suspend (T) -> Unit): Flow { var isFirstCall = true @@ -34,4 +31,8 @@ fun Flow.throttle(timeoutMillis: (T) -> Long): Flow { emit(value) lastEmittedAt = now } -} \ No newline at end of file +} + +fun StateFlow.requireValue(): T = checkNotNull(value) { + "StateFlow value is null" +} diff --git a/app/src/main/res/layout-w600dp/activity_reader.xml b/app/src/main/res/layout-w600dp/activity_reader.xml deleted file mode 100644 index 90a78c533..000000000 --- a/app/src/main/res/layout-w600dp/activity_reader.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_reader.xml b/app/src/main/res/layout/activity_reader.xml index 6aa8ef866..4491710c1 100644 --- a/app/src/main/res/layout/activity_reader.xml +++ b/app/src/main/res/layout/activity_reader.xml @@ -58,7 +58,19 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" - app:popupTheme="@style/ThemeOverlay.Kotatsu" /> + app:menu="@menu/opt_reader_bottom" + app:popupTheme="@style/ThemeOverlay.Kotatsu"> + + + + @@ -86,4 +98,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/menu/opt_reader_bottom.xml b/app/src/main/res/menu/opt_reader_bottom.xml index 569bffc11..c9dbdcba5 100644 --- a/app/src/main/res/menu/opt_reader_bottom.xml +++ b/app/src/main/res/menu/opt_reader_bottom.xml @@ -20,28 +20,9 @@ app:showAsAction="always" /> - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf65c280b..56bc429e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -356,4 +356,5 @@ Enter your email to continue Removed from favourites Removed from \"%s\" + Options diff --git a/app/src/test/java/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt b/app/src/test/java/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt index 1417493f6..04627babf 100644 --- a/app/src/test/java/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt +++ b/app/src/test/java/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt @@ -21,6 +21,7 @@ class JsonSerializerTest { categoryId = 20, sortKey = 1, createdAt = System.currentTimeMillis(), + deletedAt = 0L, ) val json = JsonSerializer(entity).toJson() val result = JsonDeserializer(json).toFavouriteEntity() @@ -71,6 +72,7 @@ class JsonSerializerTest { page = 35, scroll = 24.0f, percent = 0.6f, + deletedAt = 0L, ) val json = JsonSerializer(entity).toJson() val result = JsonDeserializer(json).toHistoryEntity() @@ -87,9 +89,10 @@ class JsonSerializerTest { order = SortOrder.RATING.name, track = false, isVisibleInLibrary = true, + deletedAt = 0L, ) val json = JsonSerializer(entity).toJson() val result = JsonDeserializer(json).toFavouriteCategoryEntity() assertEquals(entity, result) } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/koitharu/kotatsu/reader/domain/ChapterPagesTest.kt b/app/src/test/java/org/koitharu/kotatsu/reader/domain/ChapterPagesTest.kt new file mode 100644 index 000000000..57572b95f --- /dev/null +++ b/app/src/test/java/org/koitharu/kotatsu/reader/domain/ChapterPagesTest.kt @@ -0,0 +1,79 @@ +package org.koitharu.kotatsu.reader.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import kotlin.random.Random + +class ChapterPagesTest { + + @Test + fun getChaptersSize() { + val pages = ChapterPages() + pages.addFirst(1L, List(12) { page(1L) }) + pages.addFirst(2L, List(17) { page(2L) }) + assertEquals(2, pages.chaptersSize) + } + + @Test + fun removeFirst() { + val pages = ChapterPages() + pages.addLast(1L, List(12) { page(1L) }) + pages.addLast(2L, List(17) { page(2L) }) + pages.addLast(4L, List(2) { page(4L) }) + pages.removeFirst() + assertEquals(2, pages.chaptersSize) + assertEquals(17 + 2, pages.size) + } + + @Test + fun removeLast() { + val pages = ChapterPages() + pages.addLast(1L, List(12) { page(1L) }) + pages.addLast(2L, List(17) { page(2L) }) + pages.addLast(4L, List(2) { page(4L) }) + pages.removeLast() + assertEquals(2, pages.chaptersSize) + assertEquals(12 + 17, pages.size) + } + + @Test + fun clear() { + val pages = ChapterPages() + pages.addLast(1L, List(12) { page(1L) }) + pages.addLast(2L, List(17) { page(2L) }) + pages.addLast(4L, List(2) { page(4L) }) + pages.clear() + assertEquals(0, pages.chaptersSize) + assertEquals(0, pages.size) + assertEquals(0, pages.size(1L)) + assertEquals(0, pages.size(2L)) + assertEquals(0, pages.size(4L)) + } + + @Test + fun subList() { + val pages = ChapterPages() + pages.addLast(1L, List(12) { page(1L) }) + pages.addLast(2L, List(17) { page(2L) }) + pages.addFirst(4L, List(2) { page(4L) }) + val subList = pages.subList(2L) + assertEquals(17, subList.size) + assertEquals(2L, subList.first().chapterId) + assertEquals(2L, subList.last().chapterId) + assertTrue(subList.all { it.chapterId == 2L }) + assertEquals(subList.size, pages.size(2L)) + } + + private fun page(chapterId: Long) = ReaderPage( + id = Random.nextLong(), + url = "http://localhost", + referer = "http://localhost", + preview = null, + chapterId = chapterId, + index = Random.nextInt(), + source = MangaSource.DUMMY, + ) +}