diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt index 078941cf..fa124d7a 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt @@ -31,10 +31,7 @@ import me.xizzhu.android.joshua.core.Constants import me.xizzhu.android.joshua.core.VerseAnnotation import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.databinding.ActivityAnnotatedBinding -import me.xizzhu.android.joshua.infra.BaseActivity -import me.xizzhu.android.joshua.infra.onEach -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.onSuccess +import me.xizzhu.android.joshua.infra.* import me.xizzhu.android.joshua.preview.VersePreviewItem import me.xizzhu.android.joshua.ui.dialog import me.xizzhu.android.joshua.ui.fadeIn @@ -43,52 +40,54 @@ import javax.inject.Inject abstract class AnnotatedVersesActivity>( @StringRes private val toolbarText: Int -) : BaseActivity(), BookmarkItem.Callback, HighlightItem.Callback, NoteItem.Callback, VersePreviewItem.Callback { +) : BaseActivityV2(), BookmarkItem.Callback, HighlightItem.Callback, NoteItem.Callback, VersePreviewItem.Callback { @Inject lateinit var annotatedVersesViewModel: VM override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - observeSettings() - observeSortOrder() - observeAnnotatedVerses() + annotatedVersesViewModel.viewAction().onEach(::onViewAction).launchIn(lifecycleScope) + annotatedVersesViewModel.viewState().onEach(::onViewState).launchIn(lifecycleScope) initializeToolbar() } - private fun observeSettings() { - annotatedVersesViewModel.settings().onEach { viewBinding.verseList.setSettings(it) }.launchIn(lifecycleScope) + private fun onViewAction(viewAction: AnnotatedVersesViewModel.ViewAction) = when (viewAction) { + AnnotatedVersesViewModel.ViewAction.OpenReadingScreen -> navigator.navigate(this, Navigator.SCREEN_READING, extrasForOpeningVerse()) + AnnotatedVersesViewModel.ViewAction.ShowLoadAnnotatedVersesFailedError -> { + dialog( + false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_annotated_verses, + { _, _ -> annotatedVersesViewModel.loadAnnotatedVerses() }, { _, _ -> finish() } + ) + } + is AnnotatedVersesViewModel.ViewAction.ShowOpenPreviewFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_verses, { _, _ -> showPreview(viewAction.verseIndex) }) + } + is AnnotatedVersesViewModel.ViewAction.ShowOpenVerseFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_select_verse, { _, _ -> openVerse(viewAction.verseToOpen) }) + } + is AnnotatedVersesViewModel.ViewAction.ShowPreview -> { + listDialog(viewAction.previewViewData.title, viewAction.previewViewData.settings, viewAction.previewViewData.items, viewAction.previewViewData.currentPosition) + Unit + } + is AnnotatedVersesViewModel.ViewAction.ShowSaveSortOrderFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_save_sort_order, { _, _ -> saveSortOrder(viewAction.sortOrderToSave) }) + } } - private fun observeSortOrder() { - annotatedVersesViewModel.sortOrder().onEach { viewBinding.toolbar.setSortOrder(it) }.launchIn(lifecycleScope) - } + private fun onViewState(viewState: AnnotatedVersesViewModel.ViewState) = with(viewBinding) { + viewState.settings?.let { verseList.setSettings(it) } + + if (viewState.loading) { + loadingSpinner.fadeIn() + verseList.visibility = View.GONE + } else { + loadingSpinner.visibility = View.GONE + verseList.fadeIn() + } - private fun observeAnnotatedVerses() { - annotatedVersesViewModel.annotatedVerses() - .onEach( - onLoading = { - with(viewBinding) { - loadingSpinner.fadeIn() - verseList.visibility = View.GONE - } - }, - onSuccess = { - with(viewBinding) { - verseList.setItems(it.items) - verseList.fadeIn() - loadingSpinner.visibility = View.GONE - } - }, - onFailure = { - viewBinding.loadingSpinner.visibility = View.GONE - dialog( - false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_annotated_verses, - { _, _ -> annotatedVersesViewModel.loadAnnotatedVerses() }, { _, _ -> finish() } - ) - } - ) - .launchIn(lifecycleScope) + verseList.setItems(viewState.annotatedVerseItems) + toolbar.setSortOrder(viewState.sortOrder) } private fun initializeToolbar() { @@ -100,8 +99,6 @@ abstract class AnnotatedVersesActivity saveSortOrder(sortOrder) }) } - .launchIn(lifecycleScope) } override fun inflateViewBinding(): ActivityAnnotatedBinding = ActivityAnnotatedBinding.inflate(layoutInflater) @@ -109,17 +106,11 @@ abstract class AnnotatedVersesActivity openVerse(verseToOpen) }) } - .launchIn(lifecycleScope) + annotatedVersesViewModel.openVerse(verseToOpen) } override fun showPreview(verseIndex: VerseIndex) { - annotatedVersesViewModel.loadVersesForPreview(verseIndex) - .onSuccess { preview -> listDialog(preview.title, preview.settings, preview.items, preview.currentPosition) } - .onFailure { openVerse(verseIndex) } // Very unlikely to fail, so just falls back to open the verse. - .launchIn(lifecycleScope) + annotatedVersesViewModel.showPreview(verseIndex) } protected open fun extrasForOpeningVerse(): Bundle? = null diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModel.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModel.kt index ac33b8d5..5c6b288a 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModel.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModel.kt @@ -20,27 +20,13 @@ import android.app.Application import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import me.xizzhu.android.joshua.R -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.Constants -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.Verse -import me.xizzhu.android.joshua.core.VerseAnnotation -import me.xizzhu.android.joshua.core.VerseAnnotationManager -import me.xizzhu.android.joshua.core.VerseIndex -import me.xizzhu.android.joshua.infra.BaseViewModel -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.viewData +import me.xizzhu.android.joshua.core.* +import me.xizzhu.android.joshua.infra.BaseViewModelV2 import me.xizzhu.android.joshua.preview.PreviewViewData -import me.xizzhu.android.joshua.preview.loadPreview +import me.xizzhu.android.joshua.preview.loadPreviewV2 import me.xizzhu.android.joshua.preview.toVersePreviewItems import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import me.xizzhu.android.joshua.ui.recyclerview.TextItem @@ -51,66 +37,110 @@ import me.xizzhu.android.logger.Log import java.util.Calendar import kotlin.collections.ArrayList -class AnnotatedVersesViewData(val items: List) - abstract class AnnotatedVersesViewModel( private val bibleReadingManager: BibleReadingManager, private val verseAnnotationManager: VerseAnnotationManager, @StringRes private val noItemText: Int, settingsManager: SettingsManager, application: Application -) : BaseViewModel(settingsManager, application) { - private val annotatedVerses: MutableStateFlow?> = MutableStateFlow(null) +) : BaseViewModelV2( + settingsManager = settingsManager, + application = application, + initialViewState = ViewState( + settings = null, + loading = false, + sortOrder = Constants.DEFAULT_SORT_ORDER, + annotatedVerseItems = emptyList(), + ), +) { + sealed class ViewAction { + object OpenReadingScreen : ViewAction() + object ShowLoadAnnotatedVersesFailedError : ViewAction() + class ShowOpenPreviewFailedError(val verseIndex: VerseIndex) : ViewAction() + class ShowOpenVerseFailedError(val verseToOpen: VerseIndex) : ViewAction() + class ShowPreview(val previewViewData: PreviewViewData) : ViewAction() + class ShowSaveSortOrderFailedError(@Constants.SortOrder val sortOrderToSave: Int) : ViewAction() + } + + data class ViewState( + val settings: Settings?, + val loading: Boolean, + @Constants.SortOrder val sortOrder: Int, + val annotatedVerseItems: List, + ) init { + settings().onEach { settings -> emitViewState { it.copy(settings = settings) } }.launchIn(viewModelScope) + combine( bibleReadingManager.currentTranslation().filter { it.isNotEmpty() }, verseAnnotationManager.sortOrder() ) { currentTranslation, sortOrder -> try { - annotatedVerses.value = ViewData.Loading() - annotatedVerses.value = ViewData.Success(loadAnnotatedVerses(currentTranslation, sortOrder)) + emitViewState { currentViewState -> + currentViewState.copy(loading = true, sortOrder = sortOrder, annotatedVerseItems = emptyList()) + } + + val items = loadAnnotatedVerses(currentTranslation, sortOrder) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, annotatedVerseItems = items) + } } catch (e: Exception) { Log.e(tag, "Error occurred while loading annotated verses", e) - annotatedVerses.value = ViewData.Failure(e) + emitViewAction(ViewAction.ShowLoadAnnotatedVersesFailedError) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, annotatedVerseItems = emptyList()) + } } - loadAnnotatedVerses(currentTranslation, sortOrder) }.launchIn(viewModelScope) } - fun sortOrder(): Flow = verseAnnotationManager.sortOrder() - - fun saveSortOrder(@Constants.SortOrder sortOrder: Int): Flow> = viewData { - verseAnnotationManager.saveSortOrder(sortOrder) - }.onFailure { Log.e(tag, "Failed to save sort order", it) } - - fun annotatedVerses(): Flow> = annotatedVerses.filterNotNull() + fun saveSortOrder(@Constants.SortOrder sortOrder: Int) { + viewModelScope.launch { + try { + verseAnnotationManager.saveSortOrder(sortOrder) + emitViewState { currentViewState -> + currentViewState.copy(sortOrder = sortOrder) + } + } catch (e: Exception) { + Log.e(tag, "Failed to save sort order", e) + emitViewAction(ViewAction.ShowSaveSortOrderFailedError(sortOrder)) + } + } + } fun loadAnnotatedVerses() { viewModelScope.launch { try { - annotatedVerses.value = ViewData.Loading() - annotatedVerses.value = ViewData.Success(loadAnnotatedVerses( + emitViewState { currentViewState -> + currentViewState.copy(loading = true, annotatedVerseItems = emptyList()) + } + + val items = loadAnnotatedVerses( currentTranslation = bibleReadingManager.currentTranslation().firstNotEmpty(), sortOrder = verseAnnotationManager.sortOrder().first() - )) + ) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, annotatedVerseItems = items) + } } catch (e: Exception) { Log.e(tag, "Error occurred while loading annotated verses", e) - annotatedVerses.value = ViewData.Failure(e) + emitViewAction(ViewAction.ShowLoadAnnotatedVersesFailedError) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, annotatedVerseItems = emptyList()) + } } } } - private suspend fun loadAnnotatedVerses(currentTranslation: String, @Constants.SortOrder sortOrder: Int): AnnotatedVersesViewData { + private suspend fun loadAnnotatedVerses(currentTranslation: String, @Constants.SortOrder sortOrder: Int): List { val annotations = verseAnnotationManager.read(sortOrder) val verses = bibleReadingManager.readVerses(currentTranslation, annotations.map { it.verseIndex }) - return AnnotatedVersesViewData( - items = buildAnnotatedVersesItems( - sortOrder = sortOrder, - verses = annotations.mapNotNull { annotation -> verses[annotation.verseIndex]?.let { Pair(annotation, it) } }, - bookNames = bibleReadingManager.readBookNames(currentTranslation), - bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) - ) + return buildAnnotatedVersesItems( + sortOrder = sortOrder, + verses = annotations.mapNotNull { annotation -> verses[annotation.verseIndex]?.let { Pair(annotation, it) } }, + bookNames = bibleReadingManager.readBookNames(currentTranslation), + bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) ) } @@ -192,11 +222,28 @@ abstract class AnnotatedVersesViewModel( return items } - fun saveCurrentVerseIndex(verseToOpen: VerseIndex): Flow> = viewData { - bibleReadingManager.saveCurrentVerseIndex(verseToOpen) - }.onFailure { Log.e(tag, "Failed to save current verse", it) } + fun openVerse(verseToOpen: VerseIndex) { + viewModelScope.launch { + try { + bibleReadingManager.saveCurrentVerseIndex(verseToOpen) + emitViewAction(ViewAction.OpenReadingScreen) + } catch (e: Exception) { + Log.e(tag, "Failed to save current verse", e) + emitViewAction(ViewAction.ShowOpenVerseFailedError(verseToOpen)) + } + } + } - fun loadVersesForPreview(verseIndex: VerseIndex): Flow> = - loadPreview(bibleReadingManager, settingsManager, verseIndex, ::toVersePreviewItems) - .onFailure { Log.e(tag, "Failed to load verses for preview", it) } + fun showPreview(verseIndex: VerseIndex) { + viewModelScope.launch { + try { + emitViewAction(ViewAction.ShowPreview( + previewViewData = loadPreviewV2(bibleReadingManager, settingsManager, verseIndex, ::toVersePreviewItems) + )) + } catch (e: Exception) { + Log.e(tag, "Failed to load verses for preview", e) + emitViewAction(ViewAction.ShowOpenPreviewFailedError(verseIndex)) + } + } + } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt new file mode 100644 index 00000000..d267b4ae --- /dev/null +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2022 Xizhi Zhu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.xizzhu.android.joshua.infra + +import android.os.Bundle +import androidx.annotation.CallSuper +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.viewbinding.ViewBinding +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import me.xizzhu.android.joshua.Navigator +import me.xizzhu.android.logger.Log +import javax.inject.Inject + +abstract class BaseActivityV2> : AppCompatActivity() { + protected val tag: String = javaClass.simpleName + + @Inject + protected lateinit var navigator: Navigator + + protected lateinit var viewBinding: VB + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.i(tag, "onCreate()") + + viewBinding = inflateViewBinding() + setContentView(viewBinding.root) + + observeSettings() + } + + protected abstract fun inflateViewBinding(): VB + + protected abstract fun viewModel(): VM + + private fun observeSettings() { + viewModel().settings() + .onEach { settings -> + window.decorView.keepScreenOn = settings.keepScreenOn + } + .launchIn(lifecycleScope) + } + + @CallSuper + override fun onStart() { + super.onStart() + Log.i(tag, "onStart()") + } + + @CallSuper + override fun onResume() { + super.onResume() + Log.i(tag, "onResume()") + } + + @CallSuper + override fun onPause() { + super.onPause() + Log.i(tag, "onPause()") + } + + @CallSuper + override fun onStop() { + super.onStop() + Log.i(tag, "onStop()") + } + + @CallSuper + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Log.i(tag, "onSaveInstanceState()") + } + + @CallSuper + override fun onDestroy() { + super.onDestroy() + Log.i(tag, "onDestroy()") + } + + @CallSuper + override fun onLowMemory() { + super.onLowMemory() + Log.i(tag, "onLowMemory()") + } + + @CallSuper + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + Log.i(tag, "onTrimMemory(): level - $level") + } +} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseViewModelV2.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseViewModelV2.kt new file mode 100644 index 00000000..a4d4d614 --- /dev/null +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseViewModelV2.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2022 Xizhi Zhu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.xizzhu.android.joshua.infra + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import me.xizzhu.android.joshua.core.Settings +import me.xizzhu.android.joshua.core.SettingsManager + +abstract class BaseViewModelV2( + protected val settingsManager: SettingsManager, + protected val application: Application, + initialViewState: ViewState +) : ViewModel() { + protected val tag: String = javaClass.simpleName + + private val viewAction: MutableSharedFlow = MutableSharedFlow() + private val viewState: MutableStateFlow = MutableStateFlow(initialViewState) + + fun viewAction(): Flow = viewAction + + fun emitViewAction(action: ViewAction) { + viewModelScope.launch { viewAction.emit(action) } + } + + fun viewState(): Flow = viewState + + fun emitViewState(block: (currentViewState: ViewState) -> ViewState?) { + viewState.update { block(it) ?: it } + } + + fun settings(): Flow = settingsManager.settings() +} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/preview/PreviewViewData.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/preview/PreviewViewData.kt index a4371275..f56ce013 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/preview/PreviewViewData.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/preview/PreviewViewData.kt @@ -31,6 +31,25 @@ import java.util.ArrayList class PreviewViewData(val settings: Settings, val title: String, val items: List, val currentPosition: Int) +suspend fun loadPreviewV2( + bibleReadingManager: BibleReadingManager, + settingsManager: SettingsManager, + verseIndex: VerseIndex, + converter: List.() -> List +): PreviewViewData { + if (!verseIndex.isValid()) { + throw IllegalArgumentException("Verse index [$verseIndex] is invalid") + } + + val currentTranslation = bibleReadingManager.currentTranslation().firstNotEmpty() + return PreviewViewData( + settings = settingsManager.settings().first(), + title = "${bibleReadingManager.readBookShortNames(currentTranslation)[verseIndex.bookIndex]}, ${verseIndex.chapterIndex + 1}", + items = converter(bibleReadingManager.readVerses(currentTranslation, verseIndex.bookIndex, verseIndex.chapterIndex)), + currentPosition = verseIndex.verseIndex + ) +} + fun loadPreview( bibleReadingManager: BibleReadingManager, settingsManager: SettingsManager, diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressActivity.kt index ba5f019a..143522cf 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressActivity.kt @@ -27,50 +27,44 @@ import me.xizzhu.android.joshua.Navigator import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.databinding.ActivityReadingProgressBinding -import me.xizzhu.android.joshua.infra.BaseActivity -import me.xizzhu.android.joshua.infra.onEach -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.onSuccess +import me.xizzhu.android.joshua.infra.* import me.xizzhu.android.joshua.ui.dialog import me.xizzhu.android.joshua.ui.fadeIn @AndroidEntryPoint -class ReadingProgressActivity : BaseActivity(), ReadingProgressDetailItem.Callback { +class ReadingProgressActivity : BaseActivityV2(), ReadingProgressDetailItem.Callback { private val readingProgressViewModel: ReadingProgressViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - observeSettings() - observeReadingProgress() + readingProgressViewModel.viewAction().onEach(::onViewAction).launchIn(lifecycleScope) + readingProgressViewModel.viewState().onEach(::onViewState).launchIn(lifecycleScope) } - private fun observeSettings() { - readingProgressViewModel.settings().onEach { viewBinding.readingProgressList.setSettings(it) }.launchIn(lifecycleScope) + private fun onViewAction(viewAction: ReadingProgressViewModel.ViewAction) = when (viewAction) { + ReadingProgressViewModel.ViewAction.OpenReadingScreen -> { + navigator.navigate(this, Navigator.SCREEN_READING) + } + ReadingProgressViewModel.ViewAction.ShowLoadReadingProgressFailedError -> { + dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_reading_progress, + { _, _ -> loadReadingProgress() }, { _, _ -> finish() }) + } + is ReadingProgressViewModel.ViewAction.ShowOpenVerseFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_select_verse, { _, _ -> openVerse(viewAction.verseToOpen) }) + } } - private fun observeReadingProgress() { - readingProgressViewModel.readingProgress() - .onEach( - onLoading = { - with(viewBinding) { - loadingSpinner.fadeIn() - readingProgressList.visibility = View.GONE - } - }, - onSuccess = { - with(viewBinding) { - readingProgressList.setItems(it.items) - readingProgressList.fadeIn() - loadingSpinner.visibility = View.GONE - } - }, - onFailure = { - viewBinding.loadingSpinner.visibility = View.GONE - dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_reading_progress, { _, _ -> loadReadingProgress() }, { _, _ -> finish() }) - } - ) - .launchIn(lifecycleScope) + private fun onViewState(viewState: ReadingProgressViewModel.ViewState) = with(viewBinding) { + viewState.settings?.let { readingProgressList.setSettings(it) } + if (viewState.loading) { + loadingSpinner.fadeIn() + readingProgressList.visibility = View.GONE + } else { + readingProgressList.fadeIn() + loadingSpinner.visibility = View.GONE + } + readingProgressList.setItems(viewState.readingProgressItems) } override fun onStart() { @@ -87,9 +81,6 @@ class ReadingProgressActivity : BaseActivity openVerse(verseToOpen) }) } - .launchIn(lifecycleScope) + readingProgressViewModel.openVerse(verseToOpen) } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModel.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModel.kt index c3a56b80..dfa4204b 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModel.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModel.kt @@ -19,46 +19,68 @@ package me.xizzhu.android.joshua.progress import android.app.Application import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import me.xizzhu.android.joshua.core.* -import me.xizzhu.android.joshua.infra.BaseViewModel -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.viewData +import me.xizzhu.android.joshua.infra.BaseViewModelV2 import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import me.xizzhu.android.joshua.utils.firstNotEmpty import me.xizzhu.android.logger.Log import javax.inject.Inject -class ReadingProgressViewData(val items: List) - @HiltViewModel class ReadingProgressViewModel @Inject constructor( private val bibleReadingManager: BibleReadingManager, private val readingProgressManager: ReadingProgressManager, settingsManager: SettingsManager, application: Application -) : BaseViewModel(settingsManager, application) { - private val readingProgress: MutableStateFlow?> = MutableStateFlow(null) +) : BaseViewModelV2( + settingsManager = settingsManager, + application = application, + initialViewState = ViewState( + settings = null, + loading = false, + readingProgressItems = emptyList() + ) +) { + sealed class ViewAction { + object OpenReadingScreen : ViewAction() + object ShowLoadReadingProgressFailedError : ViewAction() + class ShowOpenVerseFailedError(val verseToOpen: VerseIndex) : ViewAction() + } + + data class ViewState( + val settings: Settings?, + val loading: Boolean, + val readingProgressItems: List, + ) + private val expanded: Array = Array(Bible.BOOK_COUNT) { it == 0 } - fun readingProgress(): Flow> = readingProgress.filterNotNull() + init { + settings().onEach { settings -> emitViewState { it.copy(settings = settings) } }.launchIn(viewModelScope) + } fun loadReadingProgress() { viewModelScope.launch { try { - readingProgress.value = ViewData.Loading() - readingProgress.value = ViewData.Success(ReadingProgressViewData( - items = buildReadingProgressItems( - readingProgress = readingProgressManager.read(), - bookNames = bibleReadingManager.readBookNames(bibleReadingManager.currentTranslation().firstNotEmpty()) - ) - )) + emitViewState { currentViewState -> + currentViewState.copy(loading = true, readingProgressItems = emptyList()) + } + + val readingProgressItems = buildReadingProgressItems( + readingProgress = readingProgressManager.read(), + bookNames = bibleReadingManager.readBookNames(bibleReadingManager.currentTranslation().firstNotEmpty()) + ) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, readingProgressItems = readingProgressItems) + } } catch (e: Exception) { Log.e(tag, "Error occurred while loading reading progress", e) - readingProgress.value = ViewData.Failure(e) + emitViewAction(ViewAction.ShowLoadReadingProgressFailedError) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, readingProgressItems = emptyList()) + } } } } @@ -110,7 +132,15 @@ class ReadingProgressViewModel @Inject constructor( this.expanded[bookIndex] = expanded } - fun saveCurrentVerseIndex(verseToOpen: VerseIndex): Flow> = viewData { - bibleReadingManager.saveCurrentVerseIndex(verseToOpen) - }.onFailure { Log.e(tag, "Failed to save current verse", it) } + fun openVerse(verseToOpen: VerseIndex) { + viewModelScope.launch { + try { + bibleReadingManager.saveCurrentVerseIndex(verseToOpen) + emitViewAction(ViewAction.OpenReadingScreen) + } catch (e: Exception) { + Log.e(tag, "Failed to save current verse", e) + emitViewAction(ViewAction.ShowOpenVerseFailedError(verseToOpen)) + } + } + } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt index 68b8631b..a2115508 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt @@ -33,18 +33,11 @@ import me.xizzhu.android.joshua.Navigator import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.databinding.ActivitySearchBinding -import me.xizzhu.android.joshua.infra.BaseActivity -import me.xizzhu.android.joshua.infra.onEach -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.onSuccess -import me.xizzhu.android.joshua.ui.dialog -import me.xizzhu.android.joshua.ui.fadeIn -import me.xizzhu.android.joshua.ui.hideKeyboard -import me.xizzhu.android.joshua.ui.listDialog -import me.xizzhu.android.joshua.ui.toast +import me.xizzhu.android.joshua.infra.* +import me.xizzhu.android.joshua.ui.* @AndroidEntryPoint -class SearchActivity : BaseActivity(), SearchNoteItem.Callback, SearchVerseItem.Callback, SearchVersePreviewItem.Callback { +class SearchActivity : BaseActivityV2(), SearchNoteItem.Callback, SearchVerseItem.Callback, SearchVersePreviewItem.Callback { private val searchViewModel: SearchViewModel by viewModels() private lateinit var searchRecentSuggestions: SearchRecentSuggestions @@ -53,63 +46,56 @@ class SearchActivity : BaseActivity(), S super.onCreate(savedInstanceState) searchRecentSuggestions = RecentSearchProvider.createSearchRecentSuggestions(this) - observeSettings() - observeSearchConfiguration() - observeSearchResults() + searchViewModel.viewAction().onEach(::onViewAction).launchIn(lifecycleScope) + searchViewModel.viewState().onEach(::onViewState).launchIn(lifecycleScope) initializeListeners() } - private fun observeSettings() { - searchViewModel.settings().onEach { viewBinding.searchResult.setSettings(it) }.launchIn(lifecycleScope) + private fun onViewAction(viewAction: SearchViewModel.ViewAction) = when (viewAction) { + SearchViewModel.ViewAction.OpenReadingScreen -> navigator.navigate(this, Navigator.SCREEN_READING) + is SearchViewModel.ViewAction.ShowOpenPreviewFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_verses, + { _, _ -> showPreview(viewAction.verseIndex) }) + } + is SearchViewModel.ViewAction.ShowOpenVerseFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_select_verse, + { _, _ -> openVerse(viewAction.verseToOpen) }) + } + is SearchViewModel.ViewAction.ShowPreview -> { + listDialog(viewAction.previewViewData.title, viewAction.previewViewData.settings, viewAction.previewViewData.items, viewAction.previewViewData.currentPosition) + Unit + } + is SearchViewModel.ViewAction.ShowToast -> toast(viewAction.message) + SearchViewModel.ViewAction.ShowSearchFailedError -> { + dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_search, + { _, _ -> searchViewModel.retrySearch() }, { _, _ -> finish() }) + } } - private fun observeSearchConfiguration() { - searchViewModel.searchConfig() - .onSuccess { searchConfiguration -> - viewBinding.toolbar.setSearchConfiguration( - includeOldTestament = searchConfiguration.searchConfig.includeOldTestament, - includeNewTestament = searchConfiguration.searchConfig.includeNewTestament, - includeBookmarks = searchConfiguration.searchConfig.includeBookmarks, - includeHighlights = searchConfiguration.searchConfig.includeHighlights, - includeNotes = searchConfiguration.searchConfig.includeNotes, - ) - } - .launchIn(lifecycleScope) - } - - private fun observeSearchResults() { - searchViewModel.searchResult() - .onEach( - onLoading = { - with(viewBinding) { - loadingSpinner.fadeIn() - searchResult.visibility = View.GONE - } - }, - onSuccess = { viewData -> - with(viewBinding) { - searchResult.setItems(viewData.items) - searchResult.scrollToPosition(0) - - if (viewData.instanceSearch) { - searchResult.visibility = View.VISIBLE - } else { - searchResult.fadeIn() - toast(viewData.toast) - } - - loadingSpinner.visibility = View.GONE - } - }, - onFailure = { - with(viewBinding) { - loadingSpinner.visibility = View.GONE - dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_search, - { _, _ -> searchViewModel.retrySearch() }, { _, _ -> finish() }) - } - } - ) - .launchIn(lifecycleScope) + private fun onViewState(viewState: SearchViewModel.ViewState) = with(viewBinding) { + viewState.settings?.let { searchResult.setSettings(it) } + viewState.searchConfig?.let { searchConfig -> + toolbar.setSearchConfiguration( + includeOldTestament = searchConfig.includeOldTestament, + includeNewTestament = searchConfig.includeNewTestament, + includeBookmarks = searchConfig.includeBookmarks, + includeHighlights = searchConfig.includeHighlights, + includeNotes = searchConfig.includeNotes, + ) + } + if (viewState.loading) { + loadingSpinner.fadeIn() + searchResult.visibility = View.GONE + } else { + loadingSpinner.visibility = View.GONE + if (viewState.instantSearch) { + searchResult.visibility = View.VISIBLE + } else { + searchResult.fadeIn() + } + } + searchResult.setItems(viewState.searchResults) + searchResult.scrollToPosition(0) } private fun initializeListeners() { @@ -148,16 +134,10 @@ class SearchActivity : BaseActivity(), S override fun viewModel(): SearchViewModel = searchViewModel override fun openVerse(verseToOpen: VerseIndex) { - searchViewModel.saveCurrentVerseIndex(verseToOpen) - .onSuccess { navigator.navigate(this, Navigator.SCREEN_READING) } - .onFailure { dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_select_verse, { _, _ -> openVerse(verseToOpen) }) } - .launchIn(lifecycleScope) + searchViewModel.openVerse(verseToOpen) } override fun showPreview(verseIndex: VerseIndex) { - searchViewModel.loadVersesForPreview(verseIndex) - .onSuccess { preview -> listDialog(preview.title, preview.settings, preview.items, preview.currentPosition) } - .onFailure { openVerse(verseIndex) } // Very unlikely to fail, so just falls back to open the verse. - .launchIn(lifecycleScope) + searchViewModel.showPreview(verseIndex) } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchViewModel.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchViewModel.kt index 9d067de2..156a2f19 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchViewModel.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchViewModel.kt @@ -20,31 +20,13 @@ import android.app.Application import androidx.annotation.VisibleForTesting import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import me.xizzhu.android.joshua.R -import me.xizzhu.android.joshua.core.Bible -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.Bookmark -import me.xizzhu.android.joshua.core.Highlight -import me.xizzhu.android.joshua.core.Note -import me.xizzhu.android.joshua.core.SearchConfiguration -import me.xizzhu.android.joshua.core.SearchManager -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.Verse -import me.xizzhu.android.joshua.core.VerseIndex -import me.xizzhu.android.joshua.infra.BaseViewModel -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.viewData +import me.xizzhu.android.joshua.core.* +import me.xizzhu.android.joshua.infra.BaseViewModelV2 import me.xizzhu.android.joshua.preview.PreviewViewData -import me.xizzhu.android.joshua.preview.loadPreview +import me.xizzhu.android.joshua.preview.loadPreviewV2 import me.xizzhu.android.joshua.preview.nextNonEmpty import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import me.xizzhu.android.joshua.ui.recyclerview.TitleItem @@ -52,51 +34,83 @@ import me.xizzhu.android.joshua.utils.firstNotEmpty import me.xizzhu.android.logger.Log import javax.inject.Inject -class SearchConfigurationViewData(val searchConfig: SearchConfiguration) - -class SearchResultViewData(val items: List, val query: String, val instanceSearch: Boolean, val toast: String) - @HiltViewModel class SearchViewModel @Inject constructor( private val bibleReadingManager: BibleReadingManager, private val searchManager: SearchManager, settingsManager: SettingsManager, application: Application -) : BaseViewModel(settingsManager, application) { - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal class SearchRequest(val query: String, val instanceSearch: Boolean) +) : BaseViewModelV2( + settingsManager = settingsManager, + application = application, + initialViewState = ViewState( + settings = null, + searchConfig = null, + loading = false, + searchQuery = "", + instantSearch = false, + searchResults = emptyList(), + ) +) { + sealed class ViewAction { + object OpenReadingScreen : ViewAction() + class ShowOpenPreviewFailedError(val verseIndex: VerseIndex) : ViewAction() + class ShowOpenVerseFailedError(val verseToOpen: VerseIndex) : ViewAction() + class ShowPreview(val previewViewData: PreviewViewData) : ViewAction() + class ShowToast(val message: String) : ViewAction() + object ShowSearchFailedError : ViewAction() + } + + data class ViewState( + val settings: Settings?, + val searchConfig: SearchConfiguration?, + val loading: Boolean, + val searchQuery: String, + val instantSearch: Boolean, + val searchResults: List, + ) - private val searchConfig: MutableStateFlow> = MutableStateFlow(ViewData.Loading()) + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal class SearchRequest(val query: String, val instantSearch: Boolean) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val searchRequest: MutableStateFlow = MutableStateFlow(null) - private val searchResult: MutableStateFlow?> = MutableStateFlow(null) init { + settings().onEach { settings -> emitViewState { it.copy(settings = settings) } }.launchIn(viewModelScope) + searchManager.configuration() - .onEach { - searchConfig.value = ViewData.Success(SearchConfigurationViewData(it)) + .onEach { searchConfig -> + emitViewState { currentViewState -> currentViewState.copy(searchConfig = searchConfig) } retrySearch() } .launchIn(viewModelScope) searchRequest.filterNotNull() .debounce(250L) - .distinctUntilChangedBy { it.query } + .distinctUntilChangedBy { request -> + if (request.instantSearch || request.query.length >= 2) { + request.query + } else { + "${request.query}:${request.instantSearch}" + } + } .mapLatest { it } - .onEach { doSearch(it.query, it.instanceSearch) } + .onEach { doSearch(it.query, it.instantSearch) } .launchIn(viewModelScope) } - fun searchConfig(): Flow> = searchConfig - fun includeOldTestament(include: Boolean) { updateSearchConfig { it.copy(includeOldTestament = include) } } - private inline fun updateSearchConfig(op: (SearchConfiguration) -> SearchConfiguration) { - (searchConfig.value as? ViewData.Success)?.data?.searchConfig?.let { current -> - op(current).takeIf { it != current }?.let { searchManager.saveConfiguration(it) } + private inline fun updateSearchConfig(crossinline op: (SearchConfiguration) -> SearchConfiguration) { + emitViewState { currentViewState -> + currentViewState.searchConfig?.let { currentSearchConfig -> + op(currentSearchConfig).takeIf { it != currentSearchConfig } + ?.also { searchManager.saveConfiguration(it) } + ?.let { currentViewState.copy(searchConfig = it) } + } } } @@ -116,50 +130,54 @@ class SearchViewModel @Inject constructor( updateSearchConfig { it.copy(includeNotes = include) } } - fun searchResult(): Flow> = searchResult.filterNotNull() - - fun search(query: String, instanceSearch: Boolean) { - searchRequest.value = SearchRequest(query, instanceSearch) + fun search(query: String, instantSearch: Boolean) { + searchRequest.value = SearchRequest(query, instantSearch) } fun retrySearch() { - searchRequest.value?.let { request -> doSearch(request.query, request.instanceSearch) } + searchRequest.value?.let { request -> doSearch(request.query, request.instantSearch) } } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun doSearch(query: String, instanceSearch: Boolean) { - if (query.length < 2) { - searchResult.value = ViewData.Success(SearchResultViewData(emptyList(), query, instanceSearch, "")) + private fun doSearch(query: String, instantSearch: Boolean) { + if (instantSearch && query.length < 2) { + emitViewState { currentViewState -> + currentViewState.copy(searchQuery = query, instantSearch = instantSearch, searchResults = emptyList()) + } return } viewModelScope.launch { try { - if (!instanceSearch) { - searchResult.value = ViewData.Loading() + if (!instantSearch) { + emitViewState { currentViewState -> + currentViewState.copy(loading = true, searchQuery = query, instantSearch = instantSearch, searchResults = emptyList()) + } } val currentTranslation = bibleReadingManager.currentTranslation().firstNotEmpty() - searchResult.value = searchManager.search(query).let { searchResult -> - ViewData.Success(SearchResultViewData( - items = buildSearchResultItems( - query = query, - verses = searchResult.verses, - bookmarks = searchResult.bookmarks, - highlights = searchResult.highlights, - notes = searchResult.notes, - bookNames = bibleReadingManager.readBookNames(currentTranslation), - bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) - ), - query = query, - instanceSearch = instanceSearch, - toast = application.getString(R.string.toast_search_result, - searchResult.verses.size + searchResult.bookmarks.size + searchResult.highlights.size + searchResult.notes.size) - )) + val searchResult = searchManager.search(query) + val searchResultItems = buildSearchResultItems( + query = query, + verses = searchResult.verses, + bookmarks = searchResult.bookmarks, + highlights = searchResult.highlights, + notes = searchResult.notes, + bookNames = bibleReadingManager.readBookNames(currentTranslation), + bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) + ) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, searchQuery = query, instantSearch = instantSearch, searchResults = searchResultItems) + } + if (!instantSearch) { + val searchResultCount = searchResult.verses.size + searchResult.bookmarks.size + searchResult.highlights.size + searchResult.notes.size + emitViewAction(ViewAction.ShowToast(application.getString(R.string.toast_search_result, searchResultCount))) } } catch (e: Exception) { - Log.e(tag, "Error occurred which searching, query=$query instanceSearch=$instanceSearch, searchConfig=$searchConfig", e) - searchResult.value = ViewData.Failure(e) + Log.e(tag, "Error occurred which searching, query=$query instantSearch=$instantSearch, searchConfig=${viewState().firstOrNull()?.searchConfig}", e) + emitViewAction(ViewAction.ShowSearchFailedError) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, searchResults = emptyList()) + } } } } @@ -213,13 +231,30 @@ class SearchViewModel @Inject constructor( return items } - fun saveCurrentVerseIndex(verseToOpen: VerseIndex): Flow> = viewData { - bibleReadingManager.saveCurrentVerseIndex(verseToOpen) - }.onFailure { Log.e(tag, "Failed to save current verse", it) } + fun openVerse(verseToOpen: VerseIndex) { + viewModelScope.launch { + try { + bibleReadingManager.saveCurrentVerseIndex(verseToOpen) + emitViewAction(ViewAction.OpenReadingScreen) + } catch (e: Exception) { + Log.e(tag, "Failed to save current verse", e) + emitViewAction(ViewAction.ShowOpenVerseFailedError(verseToOpen)) + } + } + } - fun loadVersesForPreview(verseIndex: VerseIndex): Flow> = - loadPreview(bibleReadingManager, settingsManager, verseIndex, ::toSearchVersePreviewItems) - .onFailure { Log.e(tag, "Failed to load verses for preview", it) } + fun showPreview(verseIndex: VerseIndex) { + viewModelScope.launch { + try { + emitViewAction(ViewAction.ShowPreview( + previewViewData = loadPreviewV2(bibleReadingManager, settingsManager, verseIndex, ::toSearchVersePreviewItems) + )) + } catch (e: Exception) { + Log.e(tag, "Failed to load verses for preview", e) + emitViewAction(ViewAction.ShowOpenPreviewFailedError(verseIndex)) + } + } + } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun toSearchVersePreviewItems(verses: List): List { diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberActivity.kt index c0143ec6..242144f7 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberActivity.kt @@ -27,17 +27,14 @@ import me.xizzhu.android.joshua.Navigator import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.databinding.ActivityStrongNumberBinding -import me.xizzhu.android.joshua.infra.BaseActivity -import me.xizzhu.android.joshua.infra.onEach -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.onSuccess +import me.xizzhu.android.joshua.infra.* import me.xizzhu.android.joshua.preview.VersePreviewItem import me.xizzhu.android.joshua.ui.dialog import me.xizzhu.android.joshua.ui.fadeIn import me.xizzhu.android.joshua.ui.listDialog @AndroidEntryPoint -class StrongNumberActivity : BaseActivity(), StrongNumberItem.Callback, VersePreviewItem.Callback { +class StrongNumberActivity : BaseActivityV2(), StrongNumberItem.Callback, VersePreviewItem.Callback { companion object { private const val KEY_STRONG_NUMBER = "me.xizzhu.android.joshua.KEY_STRONG_NUMBER" @@ -49,37 +46,42 @@ class StrongNumberActivity : BaseActivity navigator.navigate(this, Navigator.SCREEN_READING) + StrongNumberViewModel.ViewAction.ShowLoadStrongNumberFailedError -> { + dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_strong_numbers, + { _, _ -> loadStrongNumber() }, { _, _ -> finish() }) + } + is StrongNumberViewModel.ViewAction.ShowOpenPreviewFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_verses, + { _, _ -> showPreview(viewAction.verseIndex) }) + } + is StrongNumberViewModel.ViewAction.ShowOpenVerseFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_select_verse, { _, _ -> openVerse(viewAction.verseToOpen) }) + } + is StrongNumberViewModel.ViewAction.ShowPreview -> { + listDialog(viewAction.previewViewData.title, viewAction.previewViewData.settings, viewAction.previewViewData.items, viewAction.previewViewData.currentPosition) + Unit + } } - private fun observeStrongNumber() { - strongNumberViewModel.strongNumber() - .onEach( - onLoading = { - with(viewBinding) { - loadingSpinner.fadeIn() - strongNumberList.visibility = View.GONE - } - }, - onSuccess = { - with(viewBinding) { - strongNumberList.setItems(it.items) - strongNumberList.fadeIn() - loadingSpinner.visibility = View.GONE - } - }, - onFailure = { - viewBinding.loadingSpinner.visibility = View.GONE - dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_strong_numbers, { _, _ -> loadStrongNumber() }, { _, _ -> finish() }) - } - ) - .launchIn(lifecycleScope) + private fun onViewState(viewState: StrongNumberViewModel.ViewState) = with(viewBinding) { + viewState.settings?.let { strongNumberList.setSettings(it) } + + if (viewState.loading) { + loadingSpinner.fadeIn() + strongNumberList.visibility = View.GONE + } else { + loadingSpinner.visibility = View.GONE + strongNumberList.fadeIn() + } + + strongNumberList.setItems(viewState.strongNumberItems) } private fun loadStrongNumber() { @@ -91,16 +93,10 @@ class StrongNumberActivity : BaseActivity openVerse(verseToOpen) }) } - .launchIn(lifecycleScope) + strongNumberViewModel.openVerse(verseToOpen) } override fun showPreview(verseIndex: VerseIndex) { - strongNumberViewModel.loadVersesForPreview(verseIndex) - .onSuccess { preview -> listDialog(preview.title, preview.settings, preview.items, preview.currentPosition) } - .onFailure { openVerse(verseIndex) } // Very unlikely to fail, so just falls back to open the verse. - .launchIn(lifecycleScope) + strongNumberViewModel.showPreview(verseIndex) } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModel.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModel.kt index 2fadd365..0b84bf68 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModel.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModel.kt @@ -20,21 +20,12 @@ import android.app.Application import android.text.SpannableStringBuilder import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.StrongNumber -import me.xizzhu.android.joshua.core.StrongNumberManager -import me.xizzhu.android.joshua.core.Verse -import me.xizzhu.android.joshua.core.VerseIndex -import me.xizzhu.android.joshua.infra.BaseViewModel -import me.xizzhu.android.joshua.infra.viewData -import me.xizzhu.android.joshua.infra.onFailure +import me.xizzhu.android.joshua.core.* +import me.xizzhu.android.joshua.infra.BaseViewModelV2 import me.xizzhu.android.joshua.preview.PreviewViewData -import me.xizzhu.android.joshua.preview.loadPreview +import me.xizzhu.android.joshua.preview.loadPreviewV2 import me.xizzhu.android.joshua.preview.toVersePreviewItems import me.xizzhu.android.joshua.ui.createTitleSpans import me.xizzhu.android.joshua.ui.recyclerview.BaseItem @@ -46,42 +37,68 @@ import me.xizzhu.android.joshua.utils.firstNotEmpty import me.xizzhu.android.logger.Log import javax.inject.Inject -class StrongNumberViewData(val items: List) - @HiltViewModel class StrongNumberViewModel @Inject constructor( private val bibleReadingManager: BibleReadingManager, private val strongNumberManager: StrongNumberManager, settingsManager: SettingsManager, application: Application -) : BaseViewModel(settingsManager, application) { - private val strongNumber: MutableStateFlow?> = MutableStateFlow(null) +) : BaseViewModelV2( + settingsManager = settingsManager, + application = application, + initialViewState = ViewState( + settings = null, + loading = false, + strongNumberItems = emptyList() + ) +) { + sealed class ViewAction { + object OpenReadingScreen : ViewAction() + object ShowLoadStrongNumberFailedError : ViewAction() + class ShowOpenPreviewFailedError(val verseIndex: VerseIndex) : ViewAction() + class ShowOpenVerseFailedError(val verseToOpen: VerseIndex) : ViewAction() + class ShowPreview(val previewViewData: PreviewViewData) : ViewAction() + } - fun strongNumber(): Flow> = strongNumber.filterNotNull() + data class ViewState( + val settings: Settings?, + val loading: Boolean, + val strongNumberItems: List + ) + + init { + settings().onEach { settings -> emitViewState { it.copy(settings = settings) } }.launchIn(viewModelScope) + } fun loadStrongNumber(sn: String) { if (sn.isEmpty()) { - strongNumber.value = ViewData.Failure(IllegalStateException("Requested Strong number is empty")) + emitViewAction(ViewAction.ShowLoadStrongNumberFailedError) return } viewModelScope.launch { try { - strongNumber.value = ViewData.Loading() + emitViewState { currentViewState -> + currentViewState.copy(loading = true, strongNumberItems = emptyList()) + } val currentTranslation = bibleReadingManager.currentTranslation().firstNotEmpty() - strongNumber.value = ViewData.Success(StrongNumberViewData( - items = buildStrongNumberItems( - strongNumber = strongNumberManager.readStrongNumber(sn), - verses = bibleReadingManager.readVerses(currentTranslation, strongNumberManager.readVerseIndexes(sn)).values - .sortedBy { with(it.verseIndex) { bookIndex * 100000 + chapterIndex * 1000 + verseIndex } }, - bookNames = bibleReadingManager.readBookNames(currentTranslation), - bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) - ) - )) + val strongNumberItems = buildStrongNumberItems( + strongNumber = strongNumberManager.readStrongNumber(sn), + verses = bibleReadingManager.readVerses(currentTranslation, strongNumberManager.readVerseIndexes(sn)).values + .sortedBy { with(it.verseIndex) { bookIndex * 100000 + chapterIndex * 1000 + verseIndex } }, + bookNames = bibleReadingManager.readBookNames(currentTranslation), + bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) + ) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, strongNumberItems = strongNumberItems) + } } catch (e: Exception) { Log.e(tag, "Error occurred which loading Strong's Numbers", e) - strongNumber.value = ViewData.Failure(e) + emitViewAction(ViewAction.ShowLoadStrongNumberFailedError) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, strongNumberItems = emptyList()) + } } } } @@ -115,11 +132,28 @@ class StrongNumberViewModel @Inject constructor( return items } - fun saveCurrentVerseIndex(verseToOpen: VerseIndex): Flow> = viewData { - bibleReadingManager.saveCurrentVerseIndex(verseToOpen) - }.onFailure { Log.e(tag, "Failed to save current verse", it) } + fun openVerse(verseToOpen: VerseIndex) { + viewModelScope.launch { + try { + bibleReadingManager.saveCurrentVerseIndex(verseToOpen) + emitViewAction(ViewAction.OpenReadingScreen) + } catch (e: Exception) { + Log.e(tag, "Failed to save current verse", e) + emitViewAction(ViewAction.ShowOpenVerseFailedError(verseToOpen)) + } + } + } - fun loadVersesForPreview(verseIndex: VerseIndex): Flow> = - loadPreview(bibleReadingManager, settingsManager, verseIndex, ::toVersePreviewItems) - .onFailure { Log.e(tag, "Failed to load verses for preview", it) } + fun showPreview(verseIndex: VerseIndex) { + viewModelScope.launch { + try { + emitViewAction(ViewAction.ShowPreview( + previewViewData = loadPreviewV2(bibleReadingManager, settingsManager, verseIndex, ::toVersePreviewItems) + )) + } catch (e: Exception) { + Log.e(tag, "Failed to load verses for preview", e) + emitViewAction(ViewAction.ShowOpenPreviewFailedError(verseIndex)) + } + } + } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/translations/TranslationsActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/translations/TranslationsActivity.kt index 121c81bc..3494f6b7 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/translations/TranslationsActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/translations/TranslationsActivity.kt @@ -22,168 +22,118 @@ import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.TranslationInfo import me.xizzhu.android.joshua.databinding.ActivityTranslationsBinding -import me.xizzhu.android.joshua.infra.BaseActivity -import me.xizzhu.android.joshua.infra.onEach -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.onSuccess +import me.xizzhu.android.joshua.infra.* import me.xizzhu.android.joshua.ui.ProgressDialog import me.xizzhu.android.joshua.ui.dialog import me.xizzhu.android.joshua.ui.fadeIn import me.xizzhu.android.joshua.ui.indeterminateProgressDialog import me.xizzhu.android.joshua.ui.progressDialog import me.xizzhu.android.joshua.ui.toast -import me.xizzhu.android.logger.Log @AndroidEntryPoint -class TranslationsActivity : BaseActivity(), TranslationItem.Callback { +class TranslationsActivity : BaseActivityV2(), TranslationItem.Callback { private val translationsViewModel: TranslationsViewModel by viewModels() - private var downloadTranslationJob: Job? = null private var downloadTranslationDialog: ProgressDialog? = null - - private var removeTranslationJob: Job? = null private var removeTranslationDialog: AlertDialog? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - observeSettings() - observeTranslationList() + translationsViewModel.viewAction().onEach(::onViewAction).launchIn(lifecycleScope) + translationsViewModel.viewState().onEach(::onViewState).launchIn(lifecycleScope) initializeListeners() + translationsViewModel.refreshTranslations(false) } - private fun observeSettings() { - translationsViewModel.settings().onEach { viewBinding.translationList.setSettings(it) }.launchIn(lifecycleScope) + private fun onViewAction(viewAction: TranslationsViewModel.ViewAction) = when (viewAction) { + TranslationsViewModel.ViewAction.GoBack -> navigator.goBack(this) + is TranslationsViewModel.ViewAction.ShowDownloadTranslationFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_download, + { _, _ -> translationsViewModel.downloadTranslation(viewAction.translationToDownload) }) + } + TranslationsViewModel.ViewAction.ShowNoTranslationAvailableError -> { + dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_translation_list, + { _, _ -> translationsViewModel.refreshTranslations(true) }, { _, _ -> finish() }) + } + is TranslationsViewModel.ViewAction.ShowRemoveTranslationFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_delete, + { _, _ -> translationsViewModel.removeTranslation(viewAction.translationToRemove) }) + } + is TranslationsViewModel.ViewAction.ShowSelectTranslationFailedError -> { + dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_select_translation, { _, _ -> selectTranslation(viewAction.translationToSelect) }) + } + TranslationsViewModel.ViewAction.ShowTranslationDownloaded -> toast(R.string.toast_downloaded) + TranslationsViewModel.ViewAction.ShowTranslationRemoved -> toast(R.string.toast_deleted) } - private fun observeTranslationList() { - translationsViewModel.translations() - .onEach( - onLoading = { - with(viewBinding) { - swipeRefresher.isRefreshing = true - translationList.visibility = View.GONE - } - }, - onSuccess = { - with(viewBinding) { - swipeRefresher.isRefreshing = false - translationList.setItems(it.items) - translationList.fadeIn() - } - }, - onFailure = { - Log.e(tag, "Error while loading translation list", it) - viewBinding.swipeRefresher.isRefreshing = false - dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_translation_list, - { _, _ -> loadTranslationList() }, { _, _ -> finish() }) - } - ) - .launchIn(lifecycleScope) + private fun onViewState(viewState: TranslationsViewModel.ViewState) = with(viewBinding) { + viewState.settings?.let { translationList.setSettings(it) } + + if (viewState.loading) { + swipeRefresher.isRefreshing = true + translationList.visibility = View.GONE + } else { + swipeRefresher.isRefreshing = false + translationList.fadeIn() + } + + translationList.setItems(viewState.translationItems) + + if (viewState.downloadingTranslation) { + if (downloadTranslationDialog == null) { + downloadTranslationDialog = progressDialog(R.string.dialog_title_downloading, 100) { translationsViewModel.cancelDownloadingTranslation() } + } + if (viewState.downloadingProgress in 0..99) { + downloadTranslationDialog?.setProgress(viewState.downloadingProgress) + } else { + downloadTranslationDialog?.let { dialog -> + dialog.setTitle(R.string.dialog_title_installing) + dialog.setIsIndeterminate(true) + } + } + } else { + downloadTranslationDialog?.dismiss() + downloadTranslationDialog = null + } + + if (viewState.removingTranslation) { + if (removeTranslationDialog == null) { + removeTranslationDialog = indeterminateProgressDialog(R.string.dialog_title_deleting) + } + } else { + removeTranslationDialog?.dismiss() + removeTranslationDialog = null + } } private fun initializeListeners() { with(viewBinding.swipeRefresher) { setColorSchemeResources(R.color.primary, R.color.secondary, R.color.dark_cyan, R.color.dark_lime) - setOnRefreshListener { loadTranslationList() } + setOnRefreshListener { translationsViewModel.refreshTranslations(true) } } } - private fun loadTranslationList() { - translationsViewModel.refreshTranslations(true) - } - override fun inflateViewBinding(): ActivityTranslationsBinding = ActivityTranslationsBinding.inflate(layoutInflater) override fun viewModel(): TranslationsViewModel = translationsViewModel override fun selectTranslation(translationToSelect: TranslationInfo) { translationsViewModel.selectTranslation(translationToSelect) - .onSuccess { navigator.goBack(this) } - .onFailure { dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_select_translation, { _, _ -> selectTranslation(translationToSelect) }) } - .launchIn(lifecycleScope) } override fun downloadTranslation(translationToDownload: TranslationInfo) { - dialog( - true, translationToDownload.name, R.string.dialog_message_download_translation_confirmation, - { _, _ -> doDownloadTranslation(translationToDownload) } - ) - } - - private fun doDownloadTranslation(translationToDownload: TranslationInfo) { - if (downloadTranslationJob != null || downloadTranslationDialog != null) { - // just in case the user clicks too fast - return - } - downloadTranslationDialog = progressDialog(R.string.dialog_title_downloading, 100) { downloadTranslationJob?.cancel() } - - downloadTranslationJob = lifecycleScope.launchWhenStarted { - translationsViewModel.downloadTranslation(translationToDownload) - .onEach( - onLoading = { progress -> - when (progress) { - in 0 until 99 -> { - downloadTranslationDialog?.setProgress(progress!!) - } - else -> { - downloadTranslationDialog?.run { - setTitle(R.string.dialog_title_installing) - setIsIndeterminate(true) - } - } - } - }, - onSuccess = { - toast(R.string.toast_downloaded) - }, - onFailure = { - dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_download, { _, _ -> doDownloadTranslation(translationToDownload) }) - } - ) - .onCompletion { - downloadTranslationDialog?.dismiss() - downloadTranslationDialog = null - downloadTranslationJob = null - } - .collect() - } + dialog(true, translationToDownload.name, R.string.dialog_message_download_translation_confirmation, + { _, _ -> translationsViewModel.downloadTranslation(translationToDownload) }) } override fun removeTranslation(translationToRemove: TranslationInfo) { - dialog( - true, translationToRemove.name, R.string.dialog_message_delete_translation_confirmation, - { _, _ -> doRemoveTranslation(translationToRemove) } - ) - } - - private fun doRemoveTranslation(translationToRemove: TranslationInfo) { - if (removeTranslationJob != null || removeTranslationDialog != null) { - // just in case the user clicks too fast - return - } - removeTranslationDialog = indeterminateProgressDialog(R.string.dialog_title_deleting) - - removeTranslationJob = translationsViewModel.removeTranslation(translationToRemove) - .onSuccess { - toast(R.string.toast_deleted) - } - .onFailure { - dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_delete, { _, _ -> doRemoveTranslation(translationToRemove) }) - } - .onCompletion { - removeTranslationDialog?.dismiss() - removeTranslationDialog = null - removeTranslationJob = null - } - .launchIn(lifecycleScope) + dialog(true, translationToRemove.name, R.string.dialog_message_delete_translation_confirmation, + { _, _ -> translationsViewModel.removeTranslation(translationToRemove) }) } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/translations/TranslationsViewModel.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/translations/TranslationsViewModel.kt index b5cd1bb0..21302cfb 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/translations/TranslationsViewModel.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/translations/TranslationsViewModel.kt @@ -20,22 +20,12 @@ import android.app.Application import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import me.xizzhu.android.joshua.R -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.TranslationInfo -import me.xizzhu.android.joshua.core.TranslationManager -import me.xizzhu.android.joshua.infra.BaseViewModel -import me.xizzhu.android.joshua.infra.viewData -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.onSuccess +import me.xizzhu.android.joshua.core.* +import me.xizzhu.android.joshua.infra.* import me.xizzhu.android.joshua.ui.TranslationInfoComparator import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import me.xizzhu.android.joshua.ui.recyclerview.TitleItem @@ -43,31 +33,69 @@ import me.xizzhu.android.logger.Log import java.util.* import javax.inject.Inject -class TranslationsViewData(val items: List) - @HiltViewModel class TranslationsViewModel @Inject constructor( private val bibleReadingManager: BibleReadingManager, private val translationManager: TranslationManager, settingsManager: SettingsManager, application: Application -) : BaseViewModel(settingsManager, application) { +) : BaseViewModelV2( + settingsManager = settingsManager, + application = application, + initialViewState = ViewState( + settings = null, + loading = false, + translationItems = emptyList(), + downloadingTranslation = false, + downloadingProgress = 0, + removingTranslation = false, + ), +) { + sealed class ViewAction { + object GoBack : ViewAction() + class ShowDownloadTranslationFailedError(val translationToDownload: TranslationInfo) : ViewAction() + object ShowNoTranslationAvailableError : ViewAction() + class ShowRemoveTranslationFailedError(val translationToRemove: TranslationInfo) : ViewAction() + class ShowSelectTranslationFailedError(val translationToSelect: TranslationInfo) : ViewAction() + object ShowTranslationDownloaded : ViewAction() + object ShowTranslationRemoved : ViewAction() + } + + data class ViewState( + val settings: Settings?, + val loading: Boolean, + val translationItems: List, + val downloadingTranslation: Boolean, + val downloadingProgress: Int, + val removingTranslation: Boolean, + ) + private val translationComparator = TranslationInfoComparator(TranslationInfoComparator.SORT_ORDER_LANGUAGE_THEN_NAME) - private val translations: MutableStateFlow?> = MutableStateFlow(null) + + private var downloadTranslationJob: Job? = null init { - refreshTranslations(false) + settings().onEach { settings -> emitViewState { it.copy(settings = settings) } }.launchIn(viewModelScope) } - fun selectTranslation(translationToSelect: TranslationInfo): Flow> = viewData { - bibleReadingManager.saveCurrentTranslation(translationToSelect.shortName) - }.onFailure { Log.e(tag, "Failed to select translation", it) } - - fun translations(): Flow> = translations.filterNotNull() + fun selectTranslation(translationToSelect: TranslationInfo) { + viewModelScope.launch { + try { + bibleReadingManager.saveCurrentTranslation(translationToSelect.shortName) + emitViewAction(ViewAction.GoBack) + } catch (e: Exception) { + Log.e(tag, "Failed to select translation", e) + emitViewAction(ViewAction.ShowSelectTranslationFailedError(translationToSelect)) + } + } + } fun refreshTranslations(forceRefresh: Boolean) { viewModelScope.launch { - translations.value = ViewData.Loading() + emitViewState { currentViewState -> + currentViewState.copy(loading = true, translationItems = emptyList()) + } + translationManager.reload(forceRefresh) // After the refresh, if no change is detected, nothing will be emitted, therefore we need to manually load here. @@ -75,16 +103,22 @@ class TranslationsViewModel @Inject constructor( val availableTranslations = translationManager.availableTranslations().first().sortedWith(translationComparator) val downloadedTranslations = translationManager.downloadedTranslations().first().sortedWith(translationComparator) if (availableTranslations.isEmpty() && downloadedTranslations.isEmpty()) { - translations.value = ViewData.Failure(IllegalStateException("No available nor downloaded translation")) + emitViewAction(ViewAction.ShowNoTranslationAvailableError) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, translationItems = emptyList()) + } return@launch } + val currentTranslation = bibleReadingManager.currentTranslation().first() items.addAll(downloadedTranslations.toItems(currentTranslation)) if (availableTranslations.isNotEmpty()) { items.add(TitleItem(application.getString(R.string.header_available_translations), false)) items.addAll(availableTranslations.toItems(currentTranslation)) } - translations.value = ViewData.Success(TranslationsViewData(items)) + emitViewState { currentViewState -> + currentViewState.copy(loading = false, translationItems = items) + } } } @@ -102,22 +136,59 @@ class TranslationsViewModel @Inject constructor( return items } - fun downloadTranslation(translationToDownload: TranslationInfo): Flow> = - translationManager.downloadTranslation(translationToDownload) - .map { progress -> - when (progress) { - -1 -> ViewData.Failure(CancellationException("Translation downloading cancelled by user")) - in 0 until 100 -> ViewData.Loading(progress) - else -> ViewData.Success(100) + fun downloadTranslation(translationToDownload: TranslationInfo) { + if (downloadTranslationJob != null) return + + downloadTranslationJob = translationManager.downloadTranslation(translationToDownload) + .onStart { + emitViewState { currentViewState -> + currentViewState.copy(downloadingTranslation = true, downloadingProgress = 0) + } + } + .onEach { progress -> + when (progress) { + in Integer.MIN_VALUE until 0 -> throw CancellationException("Translation downloading cancelled by user") + else -> { + emitViewState { currentViewState -> + currentViewState.copy(downloadingTranslation = true, downloadingProgress = progress) + } } } - .catch { e -> - Log.e(tag, "Failed to download translation", e) - emit(ViewData.Failure(e)) + } + .onCompletion { e -> + if (e == null) { + emitViewAction(ViewAction.ShowTranslationDownloaded) + refreshTranslations(false) } - .onSuccess { refreshTranslations(false) } + emitViewState { currentViewState -> currentViewState.copy(downloadingTranslation = false, downloadingProgress = 0) } + + downloadTranslationJob = null + } + .catch { e -> + Log.e(tag, "Failed to download translation", e) + emitViewAction(ViewAction.ShowDownloadTranslationFailedError(translationToDownload)) + } + .launchIn(viewModelScope) + } - fun removeTranslation(translationToDownload: TranslationInfo): Flow> = viewData { - translationManager.removeTranslation(translationToDownload) - }.onSuccess { refreshTranslations(false) }.onFailure { Log.e(tag, "Failed to remove translation", it) } + fun cancelDownloadingTranslation() { + downloadTranslationJob?.cancel() + downloadTranslationJob = null + } + + fun removeTranslation(translationToRemove: TranslationInfo) { + viewModelScope.launch { + try { + emitViewState { currentViewState -> currentViewState.copy(removingTranslation = true) } + translationManager.removeTranslation(translationToRemove) + refreshTranslations(false) + emitViewAction(ViewAction.ShowTranslationRemoved) + } catch (e: Exception) { + Log.e(tag, "Failed to remove translation", e) + emitViewAction(ViewAction.ShowRemoveTranslationFailedError(translationToRemove)) + } finally { + emitViewState { currentViewState -> currentViewState.copy(removingTranslation = false) } + } + } + } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModelTest.kt index aeee1037..6448deba 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModelTest.kt @@ -23,11 +23,12 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.spyk +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.annotated.bookmarks.BookmarkItem @@ -38,16 +39,14 @@ import me.xizzhu.android.joshua.core.SettingsManager import me.xizzhu.android.joshua.core.VerseAnnotation import me.xizzhu.android.joshua.core.VerseAnnotationManager import me.xizzhu.android.joshua.core.VerseIndex -import me.xizzhu.android.joshua.infra.BaseViewModel import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import me.xizzhu.android.joshua.ui.recyclerview.TextItem import me.xizzhu.android.joshua.ui.recyclerview.TitleItem -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue +import me.xizzhu.android.joshua.utils.currentTimeMillis +import java.util.* +import kotlin.test.* class AnnotatedVersesViewModelTest : BaseUnitTest() { private class TestAnnotatedVerse(verseIndex: VerseIndex, timestamp: Long) : VerseAnnotation(verseIndex, timestamp) @@ -83,6 +82,7 @@ class AnnotatedVersesViewModelTest : BaseUnitTest() { every { verseAnnotationManager.sortOrder() } returns emptyFlow() settingsManager = mockk() + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) resources = mockk() every { resources.getStringArray(R.array.text_months) } returns arrayOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12") @@ -95,21 +95,56 @@ class AnnotatedVersesViewModelTest : BaseUnitTest() { } @Test - fun `test loadAnnotatedVerses() from constructor`() = runTest { + fun `test loadAnnotatedVerses() from constructor with exception`() = runTest { every { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) + every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_BOOK) + coEvery { verseAnnotationManager.read(Constants.SORT_BY_BOOK) } throws RuntimeException("random exception") + + annotatedVersesViewModel = TestAnnotatedVersesViewModel(bibleReadingManager, verseAnnotationManager, settingsManager, application) + delay(100) + + with(annotatedVersesViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertEquals(Constants.SORT_BY_BOOK, sortOrder) + assertTrue(annotatedVerseItems.isEmpty()) + } + } + + @Test + fun `test loadAnnotatedVerses() from constructor`() = runTest { + every { bibleReadingManager.currentTranslation() } returns flowOf("", MockContents.kjvShortName, "") coEvery { bibleReadingManager.readBookNames(MockContents.kjvShortName) } returns MockContents.kjvBookNames coEvery { bibleReadingManager.readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames coEvery { bibleReadingManager.readVerses(MockContents.kjvShortName, emptyList()) } returns emptyMap() every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_BOOK) coEvery { verseAnnotationManager.read(Constants.SORT_BY_BOOK) } returns emptyList() - val job = async { annotatedVersesViewModel.annotatedVerses().first { it is BaseViewModel.ViewData.Success } } annotatedVersesViewModel = TestAnnotatedVersesViewModel(bibleReadingManager, verseAnnotationManager, settingsManager, application) + delay(100) + + with(annotatedVersesViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertEquals(Constants.SORT_BY_BOOK, sortOrder) + assertEquals(1, annotatedVerseItems.size) + assertEquals("NO ANNOTATED VERSES", (annotatedVerseItems[0] as TextItem).title.toString()) + } + } + + @Test + fun `test loadAnnotatedVerses() with exception`() = runTest { + every { bibleReadingManager.currentTranslation() } throws RuntimeException("random excption") + + annotatedVersesViewModel.loadAnnotatedVerses() + delay(100) - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(1, actual.data.items.size) - assertEquals("NO ANNOTATED VERSES", (actual.data.items[0] as TextItem).title.toString()) + with(annotatedVersesViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertEquals(Constants.SORT_BY_DATE, sortOrder) + assertTrue(annotatedVerseItems.isEmpty()) + } } @Test @@ -118,16 +153,19 @@ class AnnotatedVersesViewModelTest : BaseUnitTest() { coEvery { bibleReadingManager.readBookNames(MockContents.kjvShortName) } returns MockContents.kjvBookNames coEvery { bibleReadingManager.readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames coEvery { bibleReadingManager.readVerses(MockContents.kjvShortName, emptyList()) } returns emptyMap() - every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_BOOK) - coEvery { verseAnnotationManager.read(Constants.SORT_BY_BOOK) } returns emptyList() + every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_DATE) + coEvery { verseAnnotationManager.read(Constants.SORT_BY_DATE) } returns emptyList() - val job = async { annotatedVersesViewModel.annotatedVerses().first { it is BaseViewModel.ViewData.Success } } annotatedVersesViewModel.loadAnnotatedVerses() + delay(100) - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(1, actual.data.items.size) - assertEquals("NO ANNOTATED VERSES", (actual.data.items[0] as TextItem).title.toString()) + with(annotatedVersesViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertEquals(Constants.SORT_BY_DATE, sortOrder) + assertEquals(1, annotatedVerseItems.size) + assertEquals("NO ANNOTATED VERSES", (annotatedVerseItems[0] as TextItem).title.toString()) + } } @Test @@ -159,18 +197,20 @@ class AnnotatedVersesViewModelTest : BaseUnitTest() { TestAnnotatedVerse(VerseIndex(1, 22, 18), 12345L) ) - val job = async { annotatedVersesViewModel.annotatedVerses().first { it is BaseViewModel.ViewData.Success } } annotatedVersesViewModel.loadAnnotatedVerses() + delay(100) - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(6, actual.data.items.size) - assertEquals("Genesis", (actual.data.items[0] as TitleItem).title.toString()) - assertEquals(VerseIndex(0, 0, 0), (actual.data.items[1] as BookmarkItem).verseIndex) - assertEquals(VerseIndex(0, 0, 1), (actual.data.items[2] as BookmarkItem).verseIndex) - assertEquals(VerseIndex(0, 9, 9), (actual.data.items[3] as BookmarkItem).verseIndex) - assertEquals("Exodus", (actual.data.items[4] as TitleItem).title.toString()) - assertEquals(VerseIndex(1, 22, 18), (actual.data.items[5] as BookmarkItem).verseIndex) + with(annotatedVersesViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertEquals(6, annotatedVerseItems.size) + assertEquals("Genesis", (annotatedVerseItems[0] as TitleItem).title.toString()) + assertEquals(VerseIndex(0, 0, 0), (annotatedVerseItems[1] as BookmarkItem).verseIndex) + assertEquals(VerseIndex(0, 0, 1), (annotatedVerseItems[2] as BookmarkItem).verseIndex) + assertEquals(VerseIndex(0, 9, 9), (annotatedVerseItems[3] as BookmarkItem).verseIndex) + assertEquals("Exodus", (annotatedVerseItems[4] as TitleItem).title.toString()) + assertEquals(VerseIndex(1, 22, 18), (annotatedVerseItems[5] as BookmarkItem).verseIndex) + } } @Test @@ -186,51 +226,152 @@ class AnnotatedVersesViewModelTest : BaseUnitTest() { listOf( VerseIndex(0, 0, 0), VerseIndex(0, 0, 1), + VerseIndex(0, 0, 2), VerseIndex(0, 9, 9), VerseIndex(1, 22, 18) ) ) } returns mapOf( - Pair(VerseIndex(0, 0, 0), MockContents.kjvVerses[0]), - Pair(VerseIndex(0, 0, 1), MockContents.kjvVerses[1]), - Pair(VerseIndex(0, 9, 9), MockContents.kjvExtraVerses[0]), - Pair(VerseIndex(1, 22, 18), MockContents.kjvExtraVerses[1]) + VerseIndex(0, 0, 0) to MockContents.kjvVerses[0], + VerseIndex(0, 0, 1) to MockContents.kjvVerses[1], + VerseIndex(0, 9, 9) to MockContents.kjvExtraVerses[0], + VerseIndex(1, 22, 18) to MockContents.kjvExtraVerses[1] ) every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_DATE) coEvery { verseAnnotationManager.read(Constants.SORT_BY_DATE) } returns listOf( - TestAnnotatedVerse(VerseIndex(0, 0, 0), 4L + DateUtils.DAY_IN_MILLIS * 360), - TestAnnotatedVerse(VerseIndex(0, 0, 1), 3L), + TestAnnotatedVerse(VerseIndex(0, 0, 0), 5L + DateUtils.DAY_IN_MILLIS * 360), + TestAnnotatedVerse(VerseIndex(0, 0, 1), 4L), + TestAnnotatedVerse(VerseIndex(0, 0, 2), 3L), // This verse is "not available" when reading from bibleReadingManager.readVerses TestAnnotatedVerse(VerseIndex(0, 9, 9), 2L), TestAnnotatedVerse(VerseIndex(1, 22, 18), 1L) ) - val job = async { annotatedVersesViewModel.annotatedVerses().first { it is BaseViewModel.ViewData.Success } } annotatedVersesViewModel.loadAnnotatedVerses() + delay(100) - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(6, actual.data.items.size) - assertEquals("31104000004", (actual.data.items[0] as TitleItem).title.toString()) - assertEquals(VerseIndex(0, 0, 0), (actual.data.items[1] as BookmarkItem).verseIndex) - assertEquals("3", (actual.data.items[2] as TitleItem).title.toString()) - assertEquals(VerseIndex(0, 0, 1), (actual.data.items[3] as BookmarkItem).verseIndex) - assertEquals(VerseIndex(0, 9, 9), (actual.data.items[4] as BookmarkItem).verseIndex) - assertEquals(VerseIndex(1, 22, 18), (actual.data.items[5] as BookmarkItem).verseIndex) + with(annotatedVersesViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertEquals(6, annotatedVerseItems.size) + assertEquals("31104000005", (annotatedVerseItems[0] as TitleItem).title.toString()) + assertEquals(VerseIndex(0, 0, 0), (annotatedVerseItems[1] as BookmarkItem).verseIndex) + assertEquals("4", (annotatedVerseItems[2] as TitleItem).title.toString()) + assertEquals(VerseIndex(0, 0, 1), (annotatedVerseItems[3] as BookmarkItem).verseIndex) + assertEquals(VerseIndex(0, 9, 9), (annotatedVerseItems[4] as BookmarkItem).verseIndex) + assertEquals(VerseIndex(1, 22, 18), (annotatedVerseItems[5] as BookmarkItem).verseIndex) + } } @Test - fun `test loadVersesForPreview`() = runTest { - every { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) - coEvery { bibleReadingManager.readVerses(MockContents.kjvShortName, 0, 0) } returns MockContents.kjvVerses + fun `test formatDate()`() { + every { application.getString(R.string.text_date_without_year, *anyVararg()) } answers { + (it.invocation.args[1] as Array).joinToString(separator = "-") + } + every { application.getString(R.string.text_date, *anyVararg()) } answers { + (it.invocation.args[1] as Array).joinToString(separator = ".") + } + + currentTimeMillis = Calendar.getInstance().apply { + set(Calendar.YEAR, 2022) + set(Calendar.MONTH, Calendar.FEBRUARY) + set(Calendar.DATE, 10) + }.timeInMillis + + val sameYear = Calendar.getInstance().apply { + set(Calendar.YEAR, 2022) + set(Calendar.MONTH, Calendar.JANUARY) + set(Calendar.DATE, 30) + }.timeInMillis + assertEquals("1-30", annotatedVersesViewModel.formatDate(Calendar.getInstance(), sameYear)) + + val differentYear = Calendar.getInstance().apply { + set(Calendar.YEAR, 2021) + set(Calendar.MONTH, Calendar.FEBRUARY) + set(Calendar.DATE, 10) + }.timeInMillis + assertEquals("2.10.2021", annotatedVersesViewModel.formatDate(Calendar.getInstance(), differentYear)) + } + + @Test + fun `test saveSortOrder() with exception`() = runTest { + coEvery { verseAnnotationManager.saveSortOrder(Constants.SORT_BY_BOOK) } throws RuntimeException("random exception") + + val viewActionAsync = async(Dispatchers.Default) { annotatedVersesViewModel.viewAction().first() } + delay(100) + + annotatedVersesViewModel.saveSortOrder(Constants.SORT_BY_BOOK) + + with(viewActionAsync.await()) { + assertTrue(this is AnnotatedVersesViewModel.ViewAction.ShowSaveSortOrderFailedError) + assertEquals(Constants.SORT_BY_BOOK, sortOrderToSave) + } + } + + @Test + fun `test saveSortOrder()`() = runTest { + coEvery { verseAnnotationManager.saveSortOrder(Constants.SORT_BY_BOOK) } returns Unit + + annotatedVersesViewModel.saveSortOrder(Constants.SORT_BY_BOOK) + + assertEquals(Constants.SORT_BY_BOOK, annotatedVersesViewModel.viewState().first().sortOrder) + } + + @Test + fun `test openVerse() with exception`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } throws RuntimeException("random exception") + + val viewActionAsync = async(Dispatchers.Default) { annotatedVersesViewModel.viewAction().first() } + delay(100) + + annotatedVersesViewModel.openVerse(VerseIndex(0, 0, 0)) + + with(viewActionAsync.await()) { + assertTrue(this is AnnotatedVersesViewModel.ViewAction.ShowOpenVerseFailedError) + assertEquals(VerseIndex(0, 0, 0), verseToOpen) + } + } + + @Test + fun `test openVerse()`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } returns Unit + + val viewActionAsync = async(Dispatchers.Default) { annotatedVersesViewModel.viewAction().first() } + delay(100) + + annotatedVersesViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertTrue(viewActionAsync.await() is AnnotatedVersesViewModel.ViewAction.OpenReadingScreen) + } + + @Test + fun `test showPreview() with invalid verse index`() = runTest { + val viewActionAsync = async(Dispatchers.Default) { annotatedVersesViewModel.viewAction().first() } + delay(100) + + annotatedVersesViewModel.showPreview(VerseIndex.INVALID) + + assertTrue(viewActionAsync.await() is AnnotatedVersesViewModel.ViewAction.ShowOpenPreviewFailedError) + } + + @Test + fun `test showPreview()`() = runTest { + coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) + coEvery { + bibleReadingManager.readVerses(MockContents.kjvShortName, 0, 0) + } returns listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[1], MockContents.kjvVerses[2]) coEvery { bibleReadingManager.readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) - val actual = annotatedVersesViewModel.loadVersesForPreview(VerseIndex(0, 0, 1)).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertEquals(Settings.DEFAULT, (actual[1] as BaseViewModel.ViewData.Success).data.settings) - assertEquals("Gen., 1", (actual[1] as BaseViewModel.ViewData.Success).data.title) - assertEquals(MockContents.kjvVerses.size, (actual[1] as BaseViewModel.ViewData.Success).data.items.size) - assertEquals(1, (actual[1] as BaseViewModel.ViewData.Success).data.currentPosition) + val viewActionAsync = async(Dispatchers.Default) { annotatedVersesViewModel.viewAction().first() } + delay(100) + + annotatedVersesViewModel.showPreview(VerseIndex(0, 0, 1)) + + val actual = viewActionAsync.await() + assertTrue(actual is AnnotatedVersesViewModel.ViewAction.ShowPreview) + assertEquals(Settings.DEFAULT, actual.previewViewData.settings) + assertEquals("Gen., 1", actual.previewViewData.title) + assertEquals(3, actual.previewViewData.items.size) + assertEquals(1, actual.previewViewData.currentPosition) } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksViewModelTest.kt index e417e1ba..b89d48b9 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksViewModelTest.kt @@ -20,12 +20,8 @@ import android.app.Application import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.Bookmark -import me.xizzhu.android.joshua.core.Constants -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.VerseAnnotationManager -import me.xizzhu.android.joshua.core.VerseIndex +import kotlinx.coroutines.flow.flowOf +import me.xizzhu.android.joshua.core.* import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import org.junit.runner.RunWith @@ -52,6 +48,7 @@ class BookmarksViewModelTest : BaseUnitTest() { bookmarksManager = mockk() every { bookmarksManager.sortOrder() } returns emptyFlow() settingsManager = mockk() + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) application = mockk() bookmarksViewModel = BookmarksViewModel(bibleReadingManager, bookmarksManager, settingsManager, application) } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsViewModelTest.kt index 4dcfc2f4..6ea3dfe5 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsViewModelTest.kt @@ -20,12 +20,8 @@ import android.app.Application import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.Constants -import me.xizzhu.android.joshua.core.Highlight -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.VerseAnnotationManager -import me.xizzhu.android.joshua.core.VerseIndex +import kotlinx.coroutines.flow.flowOf +import me.xizzhu.android.joshua.core.* import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import org.junit.runner.RunWith @@ -52,6 +48,7 @@ class HighlightsViewModelTest : BaseUnitTest() { highlightsManager = mockk() every { highlightsManager.sortOrder() } returns emptyFlow() settingsManager = mockk() + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) application = mockk() highlightsViewModel = HighlightsViewModel(bibleReadingManager, highlightsManager, settingsManager, application) } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesViewModelTest.kt index 192f2258..40651f65 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesViewModelTest.kt @@ -20,12 +20,8 @@ import android.app.Application import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.emptyFlow -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.Constants -import me.xizzhu.android.joshua.core.Note -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.VerseAnnotationManager -import me.xizzhu.android.joshua.core.VerseIndex +import kotlinx.coroutines.flow.flowOf +import me.xizzhu.android.joshua.core.* import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import org.junit.runner.RunWith @@ -52,6 +48,7 @@ class NotesViewModelTest : BaseUnitTest() { notesManager = mockk() every { notesManager.sortOrder() } returns emptyFlow() settingsManager = mockk() + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) application = mockk() notesViewModel = NotesViewModel(bibleReadingManager, notesManager, settingsManager, application) } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModelTest.kt index 430377a0..3e41f05a 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModelTest.kt @@ -18,15 +18,14 @@ package me.xizzhu.android.joshua.progress import android.app.Application import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.ReadingProgress -import me.xizzhu.android.joshua.core.ReadingProgressManager -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.infra.BaseViewModel +import me.xizzhu.android.joshua.core.* import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import kotlin.test.BeforeTest @@ -50,6 +49,7 @@ class ReadingProgressViewModelTest : BaseUnitTest() { readingProgressManager = mockk() bibleReadingManager = mockk() settingsManager = mockk() + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) application = mockk() readingProgressViewModel = ReadingProgressViewModel(bibleReadingManager, readingProgressManager, settingsManager, application) @@ -59,10 +59,17 @@ class ReadingProgressViewModelTest : BaseUnitTest() { fun `test loadReadingProgress with exception`() = runTest { coEvery { readingProgressManager.read() } throws RuntimeException("random exception") - val job = async { readingProgressViewModel.readingProgress().first { it is BaseViewModel.ViewData.Failure } } + val viewActionAsync = async(Dispatchers.Default) { readingProgressViewModel.viewAction().first() } + delay(100) + readingProgressViewModel.loadReadingProgress() - job.await() + assertTrue(viewActionAsync.await() is ReadingProgressViewModel.ViewAction.ShowLoadReadingProgressFailedError) + with(readingProgressViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertTrue(readingProgressItems.isEmpty()) + } } @Test @@ -81,18 +88,31 @@ class ReadingProgressViewModelTest : BaseUnitTest() { ) ) - val job = async { readingProgressViewModel.readingProgress().first { it is BaseViewModel.ViewData.Success } } + val viewStateAsync = async(Dispatchers.Default) { + readingProgressViewModel.viewState() + .drop(1) // drop the initial state + .take(2) + .toList() + } + delay(100) // makes sure the async is up and running + readingProgressViewModel.loadReadingProgress() - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(67, actual.data.items.size) - assertEquals(2, (actual.data.items[0] as ReadingProgressSummaryItem).continuousReadingDays) - assertEquals(5, (actual.data.items[0] as ReadingProgressSummaryItem).chaptersRead) - assertEquals(2, (actual.data.items[0] as ReadingProgressSummaryItem).finishedBooks) - assertEquals(1, (actual.data.items[0] as ReadingProgressSummaryItem).finishedOldTestament) - assertEquals(1, (actual.data.items[0] as ReadingProgressSummaryItem).finishedNewTestament) - actual.data.items.subList(1, actual.data.items.size).forEachIndexed { book, item -> + val viewStates = viewStateAsync.await() + + assertTrue(viewStates[0].loading) + assertEquals(Settings.DEFAULT, viewStates[0].settings) + assertTrue(viewStates[0].readingProgressItems.isEmpty()) + + assertEquals(Settings.DEFAULT, viewStates[1].settings) + assertFalse(viewStates[1].loading) + assertEquals(67, viewStates[1].readingProgressItems.size) + assertEquals(2, (viewStates[1].readingProgressItems[0] as ReadingProgressSummaryItem).continuousReadingDays) + assertEquals(5, (viewStates[1].readingProgressItems[0] as ReadingProgressSummaryItem).chaptersRead) + assertEquals(2, (viewStates[1].readingProgressItems[0] as ReadingProgressSummaryItem).finishedBooks) + assertEquals(1, (viewStates[1].readingProgressItems[0] as ReadingProgressSummaryItem).finishedOldTestament) + assertEquals(1, (viewStates[1].readingProgressItems[0] as ReadingProgressSummaryItem).finishedNewTestament) + viewStates[1].readingProgressItems.subList(1, viewStates[1].readingProgressItems.size).forEachIndexed { book, item -> assertTrue(item is ReadingProgressDetailItem) when (book) { 0 -> { @@ -118,4 +138,31 @@ class ReadingProgressViewModelTest : BaseUnitTest() { } } } + + @Test + fun `test openVerse() with exception`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } throws RuntimeException("random exception") + + val viewActionAsync = async(Dispatchers.Default) { readingProgressViewModel.viewAction().first() } + delay(100) + + readingProgressViewModel.openVerse(VerseIndex(0, 0, 0)) + + with(viewActionAsync.await()) { + assertTrue(this is ReadingProgressViewModel.ViewAction.ShowOpenVerseFailedError) + assertEquals(VerseIndex(0, 0, 0), verseToOpen) + } + } + + @Test + fun `test openVerse()`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } returns Unit + + val viewActionAsync = async(Dispatchers.Default) { readingProgressViewModel.viewAction().first() } + delay(100) + + readingProgressViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertTrue(viewActionAsync.await() is ReadingProgressViewModel.ViewAction.OpenReadingScreen) + } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/search/SearchViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/search/SearchViewModelTest.kt index 42a968c4..a659386d 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/search/SearchViewModelTest.kt @@ -21,12 +21,13 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.* -import me.xizzhu.android.joshua.infra.BaseViewModel import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import me.xizzhu.android.joshua.ui.recyclerview.TitleItem @@ -55,9 +56,11 @@ class SearchViewModelTest : BaseUnitTest() { searchManager = mockk() every { searchManager.configuration() } returns flowOf(SearchConfiguration(true, true, true, true, true)) + every { searchManager.saveConfiguration(any()) } returns Unit coEvery { searchManager.search(any()) } returns SearchResult(emptyList(), emptyList(), emptyList(), emptyList()) settingsManager = mockk() + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) application = mockk() every { application.getString(R.string.title_bookmarks) } returns "Bookmarks" @@ -70,145 +73,199 @@ class SearchViewModelTest : BaseUnitTest() { @Test fun `test includeOldTestament`() = runTest { - every { - searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = false, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true - )) - } returns Unit - + searchViewModel.includeOldTestament(false) searchViewModel.includeOldTestament(false) searchViewModel.includeOldTestament(true) + searchViewModel.includeOldTestament(true) - verify(exactly = 1) { searchManager.saveConfiguration(any()) } + verify(exactly = 2) { searchManager.saveConfiguration(any()) } } @Test fun `test includeNewTestament`() = runTest { - every { - searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = true, includeNewTestament = false, includeBookmarks = true, includeHighlights = true, includeNotes = true - )) - } returns Unit - searchViewModel.includeNewTestament(false) + searchViewModel.includeNewTestament(false) + searchViewModel.includeNewTestament(true) searchViewModel.includeNewTestament(true) - verify(exactly = 1) { searchManager.saveConfiguration(any()) } + verify(exactly = 2) { searchManager.saveConfiguration(any()) } } @Test fun `test includeBookmarks`() = runTest { - every { - searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = true, includeNewTestament = true, includeBookmarks = false, includeHighlights = true, includeNotes = true - )) - } returns Unit - searchViewModel.includeBookmarks(false) + searchViewModel.includeBookmarks(false) + searchViewModel.includeBookmarks(true) searchViewModel.includeBookmarks(true) - verify(exactly = 1) { searchManager.saveConfiguration(any()) } + verify(exactly = 2) { searchManager.saveConfiguration(any()) } } @Test fun `test includeHighlights`() = runTest { - every { - searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = false, includeNotes = true - )) - } returns Unit - searchViewModel.includeHighlights(false) + searchViewModel.includeHighlights(false) + searchViewModel.includeHighlights(true) searchViewModel.includeHighlights(true) - verify(exactly = 1) { searchManager.saveConfiguration(any()) } + verify(exactly = 2) { searchManager.saveConfiguration(any()) } } @Test fun `test includeNotes`() = runTest { - every { - searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = false - )) - } returns Unit - + searchViewModel.includeNotes(false) searchViewModel.includeNotes(false) searchViewModel.includeNotes(true) + searchViewModel.includeNotes(true) - verify(exactly = 1) { searchManager.saveConfiguration(any()) } + verify(exactly = 2) { searchManager.saveConfiguration(any()) } } @Test - fun `test search() with empty query`() = runTest { + fun `test instant search() with empty query`() = runTest { searchViewModel.search("", true) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertTrue(actual.data.items.isEmpty()) - assertTrue(actual.data.query.isEmpty()) - assertTrue(actual.data.instanceSearch) - assertTrue(actual.data.toast.isEmpty()) + val actual = searchViewModel.viewState().first() + assertTrue(actual.searchQuery.isEmpty()) + assertTrue(actual.instantSearch) + assertTrue(actual.searchResults.isEmpty()) + + verify(exactly = 0) { bibleReadingManager.currentTranslation() } + } + + @Test + fun `test search() with error`() = runTest { + coEvery { searchManager.search("query") } throws RuntimeException("random exception") + + val viewActionAsync = async { searchViewModel.viewAction().take(1).toList() } + + searchViewModel.search("query", false) + delay(1000L) + + val viewActions = viewActionAsync.await() + assertEquals(1, viewActions.size) + assertTrue(viewActions[0] is SearchViewModel.ViewAction.ShowSearchFailedError) + + val actual = searchViewModel.viewState().first() + assertEquals("query", actual.searchQuery) + assertFalse(actual.instantSearch) + assertTrue(actual.searchResults.isEmpty()) + } + + @Test + fun `test search() with empty query`() = runTest { + val viewActionAsync = async { searchViewModel.viewAction().take(1).toList() } + + searchViewModel.search("", false) + delay(1000L) + + val viewActions = viewActionAsync.await() + assertEquals(1, viewActions.size) + assertTrue(viewActions[0] is SearchViewModel.ViewAction.ShowToast) + assertEquals("0 result(s) found.", (viewActions[0] as SearchViewModel.ViewAction.ShowToast).message) + + val actual = searchViewModel.viewState().first() + assertTrue(actual.searchQuery.isEmpty()) + assertFalse(actual.instantSearch) + assertTrue(actual.searchResults.isEmpty()) + } + + @Test + fun `test instant search() with one-character query`() = runTest { + searchViewModel.search("1", true) + delay(1000L) + + val actual = searchViewModel.viewState().first() + assertEquals("1", actual.searchQuery) + assertTrue(actual.instantSearch) + assertTrue(actual.searchResults.isEmpty()) + + verify(exactly = 0) { bibleReadingManager.currentTranslation() } } @Test fun `test search() with one-character query`() = runTest { + val viewActionAsync = async { searchViewModel.viewAction().take(1).toList() } + + // query too short, instant search should not be executed searchViewModel.search("1", true) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertTrue(actual.data.items.isEmpty()) - assertEquals("1", actual.data.query) - assertTrue(actual.data.instanceSearch) - assertTrue(actual.data.toast.isEmpty()) + with(searchViewModel.viewState().first()) { + assertEquals("1", searchQuery) + assertTrue(instantSearch) + assertTrue(searchResults.isEmpty()) + } + verify(exactly = 0) { bibleReadingManager.currentTranslation() } + + // short query with non-instant query should always be executed + searchViewModel.search("1", false) + delay(1000L) + + with(viewActionAsync.await()) { + assertEquals(1, size) + assertTrue(this[0] is SearchViewModel.ViewAction.ShowToast) + assertEquals("0 result(s) found.", (this[0] as SearchViewModel.ViewAction.ShowToast).message) + } + + with(searchViewModel.viewState().first()) { + assertEquals("1", searchQuery) + assertFalse(instantSearch) + assertTrue(searchResults.isEmpty()) + } } @Test fun `test search() with empty result`() = runTest { + val viewActionAsync = async { searchViewModel.viewAction().take(1).toList() } + searchViewModel.search("query", false) delay(1000L) - searchViewModel.searchResult().first().assertSuccessEmpty("query", false) - } + val viewActions = viewActionAsync.await() + assertEquals(1, viewActions.size) + assertTrue(viewActions[0] is SearchViewModel.ViewAction.ShowToast) + assertEquals("0 result(s) found.", (viewActions[0] as SearchViewModel.ViewAction.ShowToast).message) - private fun BaseViewModel.ViewData.assertSuccessEmpty(query: String, instanceSearch: Boolean) { - assertTrue(this is BaseViewModel.ViewData.Success) - assertTrue(data.items.isEmpty()) - assertEquals(query, data.query) - assertEquals(instanceSearch, data.instanceSearch) - assertEquals("0 result(s) found.", data.toast) + val actual = searchViewModel.viewState().first() + assertEquals("query", actual.searchQuery) + assertFalse(actual.instantSearch) + assertTrue(actual.searchResults.isEmpty()) } @Test fun `test calling search() multiple times`() = runTest { - coEvery { searchManager.search("not used") } returns SearchResult(listOf(MockContents.kjvVerses[1]), emptyList(), emptyList(), emptyList()) coEvery { searchManager.search("query") } returns SearchResult(listOf(MockContents.kjvVerses[0]), emptyList(), emptyList(), emptyList()) searchViewModel.search("invalid", true) delay(1000L) - searchViewModel.searchResult().first().assertSuccessEmpty("invalid", true) - - searchViewModel.search("not used", false) - delay(100L) // delay is not long enough, so the search should NOT be executed - searchViewModel.searchResult().first().assertSuccessEmpty("invalid", true) + val viewState1 = searchViewModel.viewState().first() + assertEquals("invalid", viewState1.searchQuery) + assertTrue(viewState1.instantSearch) + assertTrue(viewState1.searchResults.isEmpty()) + + // delay is not long enough, so the instant search should NOT be executed + searchViewModel.search("not used", true) + delay(100L) + val viewState2 = searchViewModel.viewState().first() + assertEquals("invalid", viewState2.searchQuery) + assertTrue(viewState2.instantSearch) + assertTrue(viewState2.searchResults.isEmpty()) searchViewModel.search("query", false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) + val viewState3 = searchViewModel.viewState().first() + assertEquals("query", viewState3.searchQuery) + assertFalse(viewState3.instantSearch) - assertEquals(2, actual.data.items.size) - assertEquals("Genesis", (actual.data.items[0] as TitleItem).title.toString()) + assertEquals(2, viewState3.searchResults.size) + assertEquals("Genesis", (viewState3.searchResults[0] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[1] as SearchVerseItem).textForDisplay.toString() + (viewState3.searchResults[1] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("1 result(s) found.", actual.data.toast) } @Test @@ -220,35 +277,39 @@ class SearchViewModelTest : BaseUnitTest() { emptyList() ) + val viewActionAsync = async { searchViewModel.viewAction().take(1).toList() } + searchViewModel.search("query", false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) + val viewActions = viewActionAsync.await() + assertEquals(1, viewActions.size) + assertTrue(viewActions[0] is SearchViewModel.ViewAction.ShowToast) + assertEquals("4 result(s) found.", (viewActions[0] as SearchViewModel.ViewAction.ShowToast).message) + + val actual = searchViewModel.viewState().first() + assertEquals("query", actual.searchQuery) + assertFalse(actual.instantSearch) - assertEquals(6, actual.data.items.size) - assertEquals("Genesis", (actual.data.items[0] as TitleItem).title.toString()) + assertEquals(6, actual.searchResults.size) + assertEquals("Genesis", (actual.searchResults[0] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[1] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[1] as SearchVerseItem).textForDisplay.toString() ) assertEquals( "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[2] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[2] as SearchVerseItem).textForDisplay.toString() ) assertEquals( "Gen. 10:10\nAnd the beginning of his kingdom was Babel, and Erech, and Accad, and Calneh, in the land of Shinar.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[3] as SearchVerseItem).textForDisplay.toString() ) - assertEquals("Exodus", (actual.data.items[4] as TitleItem).title.toString()) + assertEquals("Exodus", (actual.searchResults[4] as TitleItem).title.toString()) assertEquals( "Ex. 23:19\nThe first of the firstfruits of thy land thou shalt bring into the house of the LORD thy God. Thou shalt not seethe a kid in his mother’s milk.", - (actual.data.items[5] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[5] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("4 result(s) found.", actual.data.toast) } @Test @@ -260,31 +321,35 @@ class SearchViewModelTest : BaseUnitTest() { emptyList() ) + val viewActionAsync = async { searchViewModel.viewAction().take(1).toList() } + searchViewModel.search("query", false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) + val viewActions = viewActionAsync.await() + assertEquals(1, viewActions.size) + assertTrue(viewActions[0] is SearchViewModel.ViewAction.ShowToast) + assertEquals("3 result(s) found.", (viewActions[0] as SearchViewModel.ViewAction.ShowToast).message) + + val actual = searchViewModel.viewState().first() + assertEquals("query", actual.searchQuery) + assertFalse(actual.instantSearch) - assertEquals(5, actual.data.items.size) - assertEquals("Bookmarks", (actual.data.items[0] as TitleItem).title.toString()) + assertEquals(5, actual.searchResults.size) + assertEquals("Bookmarks", (actual.searchResults[0] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[1] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[1] as SearchVerseItem).textForDisplay.toString() ) - assertEquals("Genesis", (actual.data.items[2] as TitleItem).title.toString()) + assertEquals("Genesis", (actual.searchResults[2] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[3] as SearchVerseItem).textForDisplay.toString() ) assertEquals( "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[4] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[4] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("3 result(s) found.", actual.data.toast) } @Test @@ -296,31 +361,35 @@ class SearchViewModelTest : BaseUnitTest() { emptyList() ) + val viewActionAsync = async { searchViewModel.viewAction().take(1).toList() } + searchViewModel.search("query", false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) + val viewActions = viewActionAsync.await() + assertEquals(1, viewActions.size) + assertTrue(viewActions[0] is SearchViewModel.ViewAction.ShowToast) + assertEquals("3 result(s) found.", (viewActions[0] as SearchViewModel.ViewAction.ShowToast).message) + + val actual = searchViewModel.viewState().first() + assertEquals("query", actual.searchQuery) + assertFalse(actual.instantSearch) - assertEquals(5, actual.data.items.size) - assertEquals("Highlights", (actual.data.items[0] as TitleItem).title.toString()) + assertEquals(5, actual.searchResults.size) + assertEquals("Highlights", (actual.searchResults[0] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[1] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[1] as SearchVerseItem).textForDisplay.toString() ) - assertEquals("Genesis", (actual.data.items[2] as TitleItem).title.toString()) + assertEquals("Genesis", (actual.searchResults[2] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[3] as SearchVerseItem).textForDisplay.toString() ) assertEquals( "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[4] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[4] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("3 result(s) found.", actual.data.toast) } @Test @@ -332,32 +401,36 @@ class SearchViewModelTest : BaseUnitTest() { listOf(Pair(Note(VerseIndex(0, 0, 9), "just a note", 12345L), MockContents.kjvVerses[9])) ) + val viewActionAsync = async { searchViewModel.viewAction().take(1).toList() } + searchViewModel.search("query", false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) + val viewActions = viewActionAsync.await() + assertEquals(1, viewActions.size) + assertTrue(viewActions[0] is SearchViewModel.ViewAction.ShowToast) + assertEquals("3 result(s) found.", (viewActions[0] as SearchViewModel.ViewAction.ShowToast).message) - assertEquals(5, actual.data.items.size) - assertEquals("Notes", (actual.data.items[0] as TitleItem).title.toString()) + val actual = searchViewModel.viewState().first() + assertEquals("query", actual.searchQuery) + assertFalse(actual.instantSearch) + + assertEquals(5, actual.searchResults.size) + assertEquals("Notes", (actual.searchResults[0] as TitleItem).title.toString()) assertEquals( "Gen. 1:10 And God called the dry land Earth; and the gathering together of the waters called he Seas: and God saw that it was good.", - (actual.data.items[1] as SearchNoteItem).verseForDisplay.toString() + (actual.searchResults[1] as SearchNoteItem).verseForDisplay.toString() ) - assertEquals("just a note", (actual.data.items[1] as SearchNoteItem).noteForDisplay.toString()) - assertEquals("Genesis", (actual.data.items[2] as TitleItem).title.toString()) + assertEquals("just a note", (actual.searchResults[1] as SearchNoteItem).noteForDisplay.toString()) + assertEquals("Genesis", (actual.searchResults[2] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[3] as SearchVerseItem).textForDisplay.toString() ) assertEquals( "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[4] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[4] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("3 result(s) found.", actual.data.toast) } @Test @@ -369,54 +442,87 @@ class SearchViewModelTest : BaseUnitTest() { listOf(Pair(Note(VerseIndex(0, 0, 9), "just a note", 12345L), MockContents.kjvVerses[9])) ) + val viewActionAsync = async { searchViewModel.viewAction().take(1).toList() } + searchViewModel.search("query", false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) + val actual = searchViewModel.viewState().first() + assertEquals("query", actual.searchQuery) + assertFalse(actual.instantSearch) - assertEquals(9, actual.data.items.size) - assertEquals("Notes", (actual.data.items[0] as TitleItem).title.toString()) + val viewActions = viewActionAsync.await() + assertEquals(1, viewActions.size) + assertTrue(viewActions[0] is SearchViewModel.ViewAction.ShowToast) + assertEquals("5 result(s) found.", (viewActions[0] as SearchViewModel.ViewAction.ShowToast).message) + + assertEquals(9, actual.searchResults.size) + assertEquals("Notes", (actual.searchResults[0] as TitleItem).title.toString()) assertEquals( "Gen. 1:10 And God called the dry land Earth; and the gathering together of the waters called he Seas: and God saw that it was good.", - (actual.data.items[1] as SearchNoteItem).verseForDisplay.toString() + (actual.searchResults[1] as SearchNoteItem).verseForDisplay.toString() ) - assertEquals("just a note", (actual.data.items[1] as SearchNoteItem).noteForDisplay.toString()) - assertEquals("Bookmarks", (actual.data.items[2] as TitleItem).title.toString()) + assertEquals("just a note", (actual.searchResults[1] as SearchNoteItem).noteForDisplay.toString()) + assertEquals("Bookmarks", (actual.searchResults[2] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[3] as SearchVerseItem).textForDisplay.toString() ) - assertEquals("Highlights", (actual.data.items[4] as TitleItem).title.toString()) + assertEquals("Highlights", (actual.searchResults[4] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[5] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[5] as SearchVerseItem).textForDisplay.toString() ) - assertEquals("Genesis", (actual.data.items[6] as TitleItem).title.toString()) + assertEquals("Genesis", (actual.searchResults[6] as TitleItem).title.toString()) assertEquals( "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[7] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[7] as SearchVerseItem).textForDisplay.toString() ) assertEquals( "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[8] as SearchVerseItem).textForDisplay.toString() + (actual.searchResults[8] as SearchVerseItem).textForDisplay.toString() ) + } - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("5 result(s) found.", actual.data.toast) + @Test + fun `test openVerse() with exception`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } throws RuntimeException("random exception") + + val viewActionAsync = async(Dispatchers.Default) { searchViewModel.viewAction().first() } + delay(100) + + searchViewModel.openVerse(VerseIndex(0, 0, 0)) + + with(viewActionAsync.await()) { + assertTrue(this is SearchViewModel.ViewAction.ShowOpenVerseFailedError) + assertEquals(VerseIndex(0, 0, 0), verseToOpen) + } } @Test - fun `test loadVersesForPreview() with invalid verse index`() = runTest { - val actual = searchViewModel.loadVersesForPreview(VerseIndex.INVALID).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertTrue(actual[1] is BaseViewModel.ViewData.Failure) + fun `test openVerse()`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } returns Unit + + val viewActionAsync = async(Dispatchers.Default) { searchViewModel.viewAction().first() } + delay(100) + + searchViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertTrue(viewActionAsync.await() is SearchViewModel.ViewAction.OpenReadingScreen) + } + + @Test + fun `test showPreview() with invalid verse index`() = runTest { + val viewActionAsync = async(Dispatchers.Default) { searchViewModel.viewAction().first() } + delay(100) + + searchViewModel.showPreview(VerseIndex.INVALID) + + assertTrue(viewActionAsync.await() is SearchViewModel.ViewAction.ShowOpenPreviewFailedError) } @Test - fun `test loadVersesForPreview()`() = runTest { + fun `test showPreview()`() = runTest { coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) coEvery { bibleReadingManager.readVerses(MockContents.kjvShortName, 0, 0) @@ -424,14 +530,17 @@ class SearchViewModelTest : BaseUnitTest() { coEvery { bibleReadingManager.readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) - val actual = searchViewModel.loadVersesForPreview(VerseIndex(0, 0, 1)).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) + val viewActionAsync = async(Dispatchers.Default) { searchViewModel.viewAction().first() } + delay(100) + + searchViewModel.showPreview(VerseIndex(0, 0, 1)) - assertEquals(Settings.DEFAULT, (actual[1] as BaseViewModel.ViewData.Success).data.settings) - assertEquals("Gen., 1", (actual[1] as BaseViewModel.ViewData.Success).data.title) - assertEquals(3, (actual[1] as BaseViewModel.ViewData.Success).data.items.size) - assertEquals(1, (actual[1] as BaseViewModel.ViewData.Success).data.currentPosition) + val actual = viewActionAsync.await() + assertTrue(actual is SearchViewModel.ViewAction.ShowPreview) + assertEquals(Settings.DEFAULT, actual.previewViewData.settings) + assertEquals("Gen., 1", actual.previewViewData.title) + assertEquals(3, actual.previewViewData.items.size) + assertEquals(1, actual.previewViewData.currentPosition) } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModelTest.kt index d39e6ee9..58383737 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModelTest.kt @@ -20,9 +20,10 @@ import android.app.Application import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest import me.xizzhu.android.joshua.core.BibleReadingManager import me.xizzhu.android.joshua.core.Settings @@ -30,17 +31,13 @@ import me.xizzhu.android.joshua.core.SettingsManager import me.xizzhu.android.joshua.core.StrongNumber import me.xizzhu.android.joshua.core.StrongNumberManager import me.xizzhu.android.joshua.core.VerseIndex -import me.xizzhu.android.joshua.infra.BaseViewModel import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import me.xizzhu.android.joshua.ui.recyclerview.TextItem import me.xizzhu.android.joshua.ui.recyclerview.TitleItem import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue +import kotlin.test.* @RunWith(RobolectricTestRunner::class) class StrongNumberViewModelTest : BaseUnitTest() { @@ -58,6 +55,7 @@ class StrongNumberViewModelTest : BaseUnitTest() { bibleReadingManager = mockk() strongNumberManager = mockk() settingsManager = mockk() + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) application = mockk() strongNumberViewModel = StrongNumberViewModel(bibleReadingManager, strongNumberManager, settingsManager, application) @@ -65,8 +63,29 @@ class StrongNumberViewModelTest : BaseUnitTest() { @Test fun `test loadStrongNumber() with empty sn`() = runTest { + val viewActionAsync = async(Dispatchers.Default) { strongNumberViewModel.viewAction().first() } + delay(100) + strongNumberViewModel.loadStrongNumber("") - assertTrue(strongNumberViewModel.strongNumber().first() is BaseViewModel.ViewData.Failure) + + assertTrue(viewActionAsync.await() is StrongNumberViewModel.ViewAction.ShowLoadStrongNumberFailedError) + } + + @Test + fun `test loadStrongNumber() with exception`() = runTest { + every { bibleReadingManager.currentTranslation() } throws RuntimeException("random exception") + + val viewActionAsync = async(Dispatchers.Default) { strongNumberViewModel.viewAction().first() } + delay(100) + + strongNumberViewModel.loadStrongNumber("H7225") + + assertTrue(viewActionAsync.await() is StrongNumberViewModel.ViewAction.ShowLoadStrongNumberFailedError) + with(strongNumberViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertTrue(strongNumberItems.isEmpty()) + } } @Test @@ -87,39 +106,69 @@ class StrongNumberViewModelTest : BaseUnitTest() { strongNumberViewModel.loadStrongNumber(sn) - val actual = strongNumberViewModel.strongNumber().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(6, actual.data.items.size) + val viewState = strongNumberViewModel.viewState().first() + assertFalse(viewState.loading) + assertEquals(Settings.DEFAULT, viewState.settings) + assertEquals(6, viewState.strongNumberItems.size) assertEquals( "H7225 beginning, chief(-est), first(-fruits, part, time), principal thing.", - (actual.data.items[0] as TextItem).title.toString() + (viewState.strongNumberItems[0] as TextItem).title.toString() ) - assertEquals("Genesis", (actual.data.items[1] as TitleItem).title.toString()) + assertEquals("Genesis", (viewState.strongNumberItems[1] as TitleItem).title.toString()) assertEquals( "Gen. 1:1 In the beginning God created the heaven and the earth.", - (actual.data.items[2] as StrongNumberItem).textForDisplay.toString() + (viewState.strongNumberItems[2] as StrongNumberItem).textForDisplay.toString() ) assertEquals( "Gen. 10:10 And the beginning of his kingdom was Babel, and Erech, and Accad, and Calneh, in the land of Shinar.", - (actual.data.items[3] as StrongNumberItem).textForDisplay.toString() + (viewState.strongNumberItems[3] as StrongNumberItem).textForDisplay.toString() ) - assertEquals("Exodus", (actual.data.items[4] as TitleItem).title.toString()) + assertEquals("Exodus", (viewState.strongNumberItems[4] as TitleItem).title.toString()) assertEquals( "Ex. 23:19 The first of the firstfruits of thy land thou shalt bring into the house of the LORD thy God. Thou shalt not seethe a kid in his mother’s milk.", - (actual.data.items[5] as StrongNumberItem).textForDisplay.toString() + (viewState.strongNumberItems[5] as StrongNumberItem).textForDisplay.toString() ) } @Test - fun `test loadVersesForPreview() with invalid verse index`() = runTest { - val actual = strongNumberViewModel.loadVersesForPreview(VerseIndex.INVALID).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertTrue(actual[1] is BaseViewModel.ViewData.Failure) + fun `test openVerse() with exception`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } throws RuntimeException("random exception") + + val viewActionAsync = async(Dispatchers.Default) { strongNumberViewModel.viewAction().first() } + delay(100) + + strongNumberViewModel.openVerse(VerseIndex(0, 0, 0)) + + with(viewActionAsync.await()) { + assertTrue(this is StrongNumberViewModel.ViewAction.ShowOpenVerseFailedError) + assertEquals(VerseIndex(0, 0, 0), verseToOpen) + } + } + + @Test + fun `test openVerse()`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } returns Unit + + val viewActionAsync = async(Dispatchers.Default) { strongNumberViewModel.viewAction().first() } + delay(100) + + strongNumberViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertTrue(viewActionAsync.await() is StrongNumberViewModel.ViewAction.OpenReadingScreen) + } + + @Test + fun `test showPreview() with invalid verse index`() = runTest { + val viewActionAsync = async(Dispatchers.Default) { strongNumberViewModel.viewAction().first() } + delay(100) + + strongNumberViewModel.showPreview(VerseIndex.INVALID) + + assertTrue(viewActionAsync.await() is StrongNumberViewModel.ViewAction.ShowOpenPreviewFailedError) } @Test - fun `test loadVersesForPreview()`() = runTest { + fun `test showPreview()`() = runTest { coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) coEvery { bibleReadingManager.readVerses(MockContents.kjvShortName, 0, 0) @@ -127,13 +176,16 @@ class StrongNumberViewModelTest : BaseUnitTest() { coEvery { bibleReadingManager.readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) - val actual = strongNumberViewModel.loadVersesForPreview(VerseIndex(0, 0, 1)).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) + val viewActionAsync = async(Dispatchers.Default) { strongNumberViewModel.viewAction().first() } + delay(100) + + strongNumberViewModel.showPreview(VerseIndex(0, 0, 1)) - assertEquals(Settings.DEFAULT, (actual[1] as BaseViewModel.ViewData.Success).data.settings) - assertEquals("Gen., 1", (actual[1] as BaseViewModel.ViewData.Success).data.title) - assertEquals(3, (actual[1] as BaseViewModel.ViewData.Success).data.items.size) - assertEquals(1, (actual[1] as BaseViewModel.ViewData.Success).data.currentPosition) + val actual = viewActionAsync.await() + assertTrue(actual is StrongNumberViewModel.ViewAction.ShowPreview) + assertEquals(Settings.DEFAULT, actual.previewViewData.settings) + assertEquals("Gen., 1", actual.previewViewData.title) + assertEquals(3, actual.previewViewData.items.size) + assertEquals(1, actual.previewViewData.currentPosition) } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/translations/TranslationsViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/translations/TranslationsViewModelTest.kt index e065eeb3..1dee94a6 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/translations/TranslationsViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/translations/TranslationsViewModelTest.kt @@ -17,28 +17,19 @@ package me.xizzhu.android.joshua.translations import android.app.Application -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.toList +import io.mockk.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.BibleReadingManager +import me.xizzhu.android.joshua.core.Settings import me.xizzhu.android.joshua.core.SettingsManager import me.xizzhu.android.joshua.core.TranslationManager -import me.xizzhu.android.joshua.infra.BaseViewModel import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import me.xizzhu.android.joshua.ui.recyclerview.TitleItem -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue +import kotlin.test.* class TranslationsViewModelTest : BaseUnitTest() { private lateinit var bibleReadingManager: BibleReadingManager @@ -53,14 +44,10 @@ class TranslationsViewModelTest : BaseUnitTest() { super.setup() bibleReadingManager = mockk() - coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.cuvShortName) - translationManager = mockk() - coEvery { translationManager.availableTranslations() } returns flowOf(listOf(MockContents.kjvTranslationInfo)) - coEvery { translationManager.downloadedTranslations() } returns flowOf(listOf(MockContents.cuvDownloadedTranslationInfo)) - coEvery { translationManager.reload(any()) } returns Unit settingsManager = mockk() + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) application = mockk() every { application.getString(R.string.header_available_translations) } returns "AVAILABLE TRANSLATIONS" @@ -69,140 +56,334 @@ class TranslationsViewModelTest : BaseUnitTest() { } @Test - fun `test loadTranslation at init`() = runTest { - val actual = translationsViewModel.translations().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(5, actual.data.items.size) - assertEquals("Chinese", (actual.data.items[0] as TitleItem).title.toString()) - assertEquals(MockContents.cuvDownloadedTranslationInfo, (actual.data.items[1] as TranslationItem).translationInfo) - assertEquals("AVAILABLE TRANSLATIONS", (actual.data.items[2] as TitleItem).title.toString()) - assertEquals("English", (actual.data.items[3] as TitleItem).title.toString()) - assertEquals(MockContents.kjvTranslationInfo, (actual.data.items[4] as TranslationItem).translationInfo) + fun `test selectTranslation() with exception`() = runTest { + coEvery { bibleReadingManager.saveCurrentTranslation(MockContents.kjvShortName) } throws RuntimeException("random exception") + + val viewActionAsync = async(Dispatchers.Default) { translationsViewModel.viewAction().first() } + delay(100) + + translationsViewModel.selectTranslation(MockContents.kjvDownloadedTranslationInfo) + + with(viewActionAsync.await()) { + assertTrue(this is TranslationsViewModel.ViewAction.ShowSelectTranslationFailedError) + assertEquals(MockContents.kjvDownloadedTranslationInfo, this.translationToSelect) + } } @Test - fun `test refreshTranslations without available and downloaded`() = runTest { - coEvery { translationManager.availableTranslations() } returns flowOf(emptyList()) - coEvery { translationManager.downloadedTranslations() } returns flowOf(emptyList()) + fun `test selectTranslation()`() = runTest { + coEvery { bibleReadingManager.saveCurrentTranslation(MockContents.kjvShortName) } returns Unit - translationsViewModel.refreshTranslations(true) - assertTrue(translationsViewModel.translations().first() is BaseViewModel.ViewData.Failure) + val viewActionAsync = async(Dispatchers.Default) { translationsViewModel.viewAction().first() } + delay(100) + + translationsViewModel.selectTranslation(MockContents.kjvDownloadedTranslationInfo) + + assertTrue(viewActionAsync.await() is TranslationsViewModel.ViewAction.GoBack) } @Test - fun `test refreshTranslations without available`() = runTest { - coEvery { translationManager.availableTranslations() } returns flowOf(emptyList()) + fun `test refreshTranslations()`() = runTest { + every { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.cuvShortName) + coEvery { translationManager.reload(false) } returns Unit + every { translationManager.availableTranslations() } returns flowOf(listOf(MockContents.kjvTranslationInfo)) + every { translationManager.downloadedTranslations() } returns flowOf(listOf(MockContents.cuvDownloadedTranslationInfo)) + + translationsViewModel.refreshTranslations(false) + + with(translationsViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + + assertEquals(5, translationItems.size) + assertEquals("Chinese", (translationItems[0] as TitleItem).title.toString()) + assertEquals(MockContents.cuvDownloadedTranslationInfo, (translationItems[1] as TranslationItem).translationInfo) + assertEquals("AVAILABLE TRANSLATIONS", (translationItems[2] as TitleItem).title.toString()) + assertEquals("English", (translationItems[3] as TitleItem).title.toString()) + assertEquals(MockContents.kjvTranslationInfo, (translationItems[4] as TranslationItem).translationInfo) + + assertFalse(downloadingTranslation) + assertEquals(0, downloadingProgress) + assertFalse(removingTranslation) + } + + coVerify(exactly = 1) { translationManager.reload(false) } + } + + @Test + fun `test refreshTranslations() without available and downloaded`() = runTest { + coEvery { translationManager.reload(true) } returns Unit + every { translationManager.availableTranslations() } returns flowOf(emptyList()) + every { translationManager.downloadedTranslations() } returns flowOf(emptyList()) + + val viewActionAsync = async(Dispatchers.Default) { translationsViewModel.viewAction().first() } + delay(100) translationsViewModel.refreshTranslations(true) - val actual = translationsViewModel.translations().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(2, actual.data.items.size) - assertEquals("Chinese", (actual.data.items[0] as TitleItem).title.toString()) - assertEquals(MockContents.cuvDownloadedTranslationInfo, (actual.data.items[1] as TranslationItem).translationInfo) + assertTrue(viewActionAsync.await() is TranslationsViewModel.ViewAction.ShowNoTranslationAvailableError) + with(translationsViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertTrue(translationItems.isEmpty()) + assertFalse(downloadingTranslation) + assertEquals(0, downloadingProgress) + assertFalse(removingTranslation) + } + + coVerify(ordering = Ordering.SEQUENCE) { + translationManager.reload(true) + translationManager.availableTranslations() + translationManager.downloadedTranslations() + } } @Test - fun `test refreshTranslations without downloaded`() = runTest { - coEvery { translationManager.downloadedTranslations() } returns flowOf(emptyList()) + fun `test refreshTranslations() without available`() = runTest { + every { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.cuvShortName) + coEvery { translationManager.reload(true) } returns Unit + every { translationManager.availableTranslations() } returns flowOf(emptyList()) + every { translationManager.downloadedTranslations() } returns flowOf(listOf(MockContents.cuvDownloadedTranslationInfo, MockContents.kjvDownloadedTranslationInfo)) translationsViewModel.refreshTranslations(true) - val actual = translationsViewModel.translations().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(3, actual.data.items.size) - assertEquals("AVAILABLE TRANSLATIONS", (actual.data.items[0] as TitleItem).title.toString()) - assertEquals("English", (actual.data.items[1] as TitleItem).title.toString()) - assertEquals(MockContents.kjvTranslationInfo, (actual.data.items[2] as TranslationItem).translationInfo) + with(translationsViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + + assertEquals(4, translationItems.size) + assertEquals("English", (translationItems[0] as TitleItem).title.toString()) + assertEquals(MockContents.kjvDownloadedTranslationInfo, (translationItems[1] as TranslationItem).translationInfo) + assertEquals("Chinese", (translationItems[2] as TitleItem).title.toString()) + assertEquals(MockContents.cuvDownloadedTranslationInfo, (translationItems[3] as TranslationItem).translationInfo) + + assertFalse(downloadingTranslation) + assertEquals(0, downloadingProgress) + assertFalse(removingTranslation) + } + + coVerify(ordering = Ordering.SEQUENCE) { + translationManager.reload(true) + translationManager.availableTranslations() + translationManager.downloadedTranslations() + bibleReadingManager.currentTranslation() + } } @Test - fun `test refreshTranslations`() = runTest { - coEvery { translationManager.availableTranslations() } returns flowOf(listOf(MockContents.kjvTranslationInfo, MockContents.msgTranslationInfo)) - coEvery { translationManager.downloadedTranslations() } returns flowOf(listOf(MockContents.bbeDownloadedTranslationInfo, MockContents.cuvDownloadedTranslationInfo)) + fun `test refreshTranslations() without downloaded`() = runTest { + every { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.cuvShortName) + coEvery { translationManager.reload(true) } returns Unit + every { translationManager.availableTranslations() } returns flowOf(listOf(MockContents.kjvTranslationInfo, MockContents.bbeTranslationInfo)) + every { translationManager.downloadedTranslations() } returns flowOf(emptyList()) translationsViewModel.refreshTranslations(true) - val actual = translationsViewModel.translations().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(8, actual.data.items.size) - assertEquals("English", (actual.data.items[0] as TitleItem).title.toString()) - assertEquals(MockContents.bbeDownloadedTranslationInfo, (actual.data.items[1] as TranslationItem).translationInfo) - assertEquals("Chinese", (actual.data.items[2] as TitleItem).title.toString()) - assertEquals(MockContents.cuvDownloadedTranslationInfo, (actual.data.items[3] as TranslationItem).translationInfo) - assertEquals("AVAILABLE TRANSLATIONS", (actual.data.items[4] as TitleItem).title.toString()) - assertEquals("English", (actual.data.items[5] as TitleItem).title.toString()) - assertEquals(MockContents.kjvTranslationInfo, (actual.data.items[6] as TranslationItem).translationInfo) - assertEquals(MockContents.msgTranslationInfo, (actual.data.items[7] as TranslationItem).translationInfo) + with(translationsViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + + assertEquals(4, translationItems.size) + assertEquals("AVAILABLE TRANSLATIONS", (translationItems[0] as TitleItem).title.toString()) + assertEquals("English", (translationItems[1] as TitleItem).title.toString()) + assertEquals(MockContents.kjvTranslationInfo, (translationItems[2] as TranslationItem).translationInfo) + assertEquals(MockContents.bbeTranslationInfo, (translationItems[3] as TranslationItem).translationInfo) + + assertFalse(downloadingTranslation) + assertEquals(0, downloadingProgress) + assertFalse(removingTranslation) + } + + coVerify(ordering = Ordering.SEQUENCE) { + translationManager.reload(true) + translationManager.availableTranslations() + translationManager.downloadedTranslations() + bibleReadingManager.currentTranslation() + } } @Test - fun `test downloadTranslation with error`() = runTest { - val error = RuntimeException("Random exception") - coEvery { translationManager.downloadTranslation(MockContents.kjvTranslationInfo) } returns flow { - emit(1) - emit(5) - throw error - } - every { translationsViewModel.refreshTranslations(any()) } returns Unit + fun `test downloadTranslation() with exception`() = runTest { + every { translationManager.downloadTranslation(MockContents.kjvTranslationInfo) } returns flow { throw RuntimeException("random exception") } + + val viewActionAsync = async(Dispatchers.Default) { translationsViewModel.viewAction().first() } + delay(100) - val actual = translationsViewModel.downloadTranslation(MockContents.kjvTranslationInfo).toList() - assertEquals(3, actual.size) - assertEquals(BaseViewModel.ViewData.Loading(1), actual[0]) - assertEquals(BaseViewModel.ViewData.Loading(5), actual[1]) - assertEquals(BaseViewModel.ViewData.Failure(error), actual[2]) + translationsViewModel.downloadTranslation(MockContents.kjvTranslationInfo) + with(viewActionAsync.await()) { + assertTrue(this is TranslationsViewModel.ViewAction.ShowDownloadTranslationFailedError) + assertEquals(MockContents.kjvTranslationInfo, translationToDownload) + } + with(translationsViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertTrue(translationItems.isEmpty()) + assertFalse(downloadingTranslation) + assertEquals(0, downloadingProgress) + assertFalse(removingTranslation) + } verify(exactly = 0) { translationsViewModel.refreshTranslations(any()) } } @Test - fun `test downloadTranslation`() = runTest { - coEvery { translationManager.downloadTranslation(MockContents.kjvTranslationInfo) } returns flow { - emit(-1) - emit(0) + fun `test downloadTranslation()`() = runTest { + every { translationManager.downloadTranslation(MockContents.kjvTranslationInfo) } returns flow { + emit(1) emit(50) emit(99) emit(100) } - every { translationsViewModel.refreshTranslations(any()) } returns Unit + every { translationsViewModel.refreshTranslations(false) } returns Unit + + val viewActionAsync = async(Dispatchers.Default) { translationsViewModel.viewAction().first() } + val viewStatesAsync = async(Dispatchers.Default) { + translationsViewModel + .viewState() + .buffer() // makes sure we could handle all updates + .drop(1) // skip the initial state + .take(6) + .toList() + } + delay(100) + + translationsViewModel.downloadTranslation(MockContents.kjvTranslationInfo) + + assertTrue(viewActionAsync.await() is TranslationsViewModel.ViewAction.ShowTranslationDownloaded) + viewStatesAsync.await().forEachIndexed { index, viewState -> + assertEquals(Settings.DEFAULT, viewState.settings) + assertFalse(viewState.loading) + assertTrue(viewState.translationItems.isEmpty()) + when (index) { + 0 -> { + assertTrue(viewState.downloadingTranslation) + assertEquals(0, viewState.downloadingProgress) + } + 1 -> { + assertTrue(viewState.downloadingTranslation) + assertEquals(1, viewState.downloadingProgress) + } + 2 -> { + assertTrue(viewState.downloadingTranslation) + assertEquals(50, viewState.downloadingProgress) + } + 3 -> { + assertTrue(viewState.downloadingTranslation) + assertEquals(99, viewState.downloadingProgress) + } + 4 -> { + assertTrue(viewState.downloadingTranslation) + assertEquals(100, viewState.downloadingProgress) + } + 5 -> { + assertFalse(viewState.downloadingTranslation) + assertEquals(0, viewState.downloadingProgress) + } + } + assertFalse(viewState.removingTranslation) + } - val actual = translationsViewModel.downloadTranslation(MockContents.kjvTranslationInfo).toList() - assertEquals(5, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Failure) - assertEquals(BaseViewModel.ViewData.Loading(0), actual[1]) - assertEquals(BaseViewModel.ViewData.Loading(50), actual[2]) - assertEquals(BaseViewModel.ViewData.Loading(99), actual[3]) - assertEquals(BaseViewModel.ViewData.Success(100), actual[4]) + verify(exactly = 1) { translationsViewModel.refreshTranslations(false) } + } + + @Test + fun `test calling downloadTranslation() multiple times`() = runTest { + every { translationManager.downloadTranslation(MockContents.kjvTranslationInfo) } returns flow { delay(1000) } + every { translationsViewModel.refreshTranslations(false) } returns Unit + translationsViewModel.downloadTranslation(MockContents.kjvTranslationInfo) + + delay(500) + translationsViewModel.downloadTranslation(MockContents.kjvTranslationInfo) // current downloading not finished yet, should do nothing + + assertTrue(translationsViewModel.viewState().first().downloadingTranslation) + + delay(200) + translationsViewModel.downloadTranslation(MockContents.kjvTranslationInfo) // current downloading not finished yet, should do nothing + + assertTrue(translationsViewModel.viewState().first().downloadingTranslation) + + delay(1000) // wait until current downloading finishes + assertFalse(translationsViewModel.viewState().first().downloadingTranslation) verify(exactly = 1) { translationsViewModel.refreshTranslations(false) } - verify(exactly = 0) { translationsViewModel.refreshTranslations(true) } + + translationsViewModel.downloadTranslation(MockContents.kjvTranslationInfo) // previous downloading finished, start downloading again + + delay(200) + assertTrue(translationsViewModel.viewState().first().downloadingTranslation) + + delay(1000) // wait until current downloading finishes + assertFalse(translationsViewModel.viewState().first().downloadingTranslation) + verify(exactly = 2) { translationsViewModel.refreshTranslations(false) } + } + + @Test + fun `test cancelDownloadingTranslation() without ongoing translations`() { + translationsViewModel.cancelDownloadingTranslation() } @Test - fun `test removeTranslation with error`() = runTest { - val error = RuntimeException("Random exception") - coEvery { translationManager.removeTranslation(MockContents.kjvTranslationInfo) } throws error - every { translationsViewModel.refreshTranslations(any()) } returns Unit + fun `test cancelDownloadingTranslation()`() = runTest { + every { translationManager.downloadTranslation(MockContents.kjvTranslationInfo) } returns flow { + delay(1000) + fail() + }.flowOn(Dispatchers.Default) + + translationsViewModel.downloadTranslation(MockContents.kjvTranslationInfo) + delay(100) + translationsViewModel.cancelDownloadingTranslation() + + with(translationsViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertTrue(translationItems.isEmpty()) + assertFalse(downloadingTranslation) + assertEquals(0, downloadingProgress) + assertFalse(removingTranslation) + } + } - val actual = translationsViewModel.removeTranslation(MockContents.kjvTranslationInfo).toList() - assertEquals(2, actual.size) - assertEquals(BaseViewModel.ViewData.Loading(), actual[0]) - assertEquals(BaseViewModel.ViewData.Failure(error), actual[1]) + @Test + fun `test removeTranslation() with exception`() = runTest { + coEvery { translationManager.removeTranslation(MockContents.kjvTranslationInfo) } throws RuntimeException("Random exception") - verify(exactly = 0) { translationsViewModel.refreshTranslations(any()) } + val viewActionAsync = async(Dispatchers.Default) { translationsViewModel.viewAction().first() } + delay(100) + + translationsViewModel.removeTranslation(MockContents.kjvDownloadedTranslationInfo) + + with(viewActionAsync.await()) { + assertTrue(this is TranslationsViewModel.ViewAction.ShowRemoveTranslationFailedError) + assertEquals(MockContents.kjvDownloadedTranslationInfo, this.translationToRemove) + } + with(translationsViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertTrue(translationItems.isEmpty()) + assertFalse(downloadingTranslation) + assertEquals(0, downloadingProgress) + assertFalse(removingTranslation) + } } @Test - fun `test removeTranslation`() = runTest { + fun `test removeTranslation()`() = runTest { coEvery { translationManager.removeTranslation(MockContents.kjvTranslationInfo) } returns Unit - every { translationsViewModel.refreshTranslations(any()) } returns Unit + every { translationsViewModel.refreshTranslations(false) } returns Unit - val actual = translationsViewModel.removeTranslation(MockContents.kjvTranslationInfo).toList() - assertEquals(2, actual.size) - assertEquals(BaseViewModel.ViewData.Loading(), actual[0]) - assertEquals(BaseViewModel.ViewData.Success(Unit), actual[1]) + val viewActionAsync = async(Dispatchers.Default) { translationsViewModel.viewAction().first() } + delay(100) - verify(exactly = 1) { translationsViewModel.refreshTranslations(false) } - verify(exactly = 0) { translationsViewModel.refreshTranslations(true) } + translationsViewModel.removeTranslation(MockContents.kjvTranslationInfo) + + assertTrue(viewActionAsync.await() is TranslationsViewModel.ViewAction.ShowTranslationRemoved) + with(translationsViewModel.viewState().first()) { + assertEquals(Settings.DEFAULT, settings) + assertFalse(loading) + assertTrue(translationItems.isEmpty()) + assertFalse(downloadingTranslation) + assertEquals(0, downloadingProgress) + assertFalse(removingTranslation) + } } }