From af4786a4c197fcfd5e2d3328ba381f31422080cf Mon Sep 17 00:00:00 2001 From: Xizhi Zhu Date: Wed, 23 Nov 2022 16:08:14 +0900 Subject: [PATCH] Refactor StrongNumber Activity / ViewModel to use single view state --- .../me/xizzhu/android/joshua/Injection.kt | 58 +++--- .../core/CoroutineDispatcherProvider.kt | 41 ++++ .../android/joshua/infra/BaseActivityV2.kt | 105 ++++++++++ .../android/joshua/infra/BaseViewModelV2.kt | 45 ++++ .../android/joshua/preview/PreviewViewData.kt | 21 +- .../strongnumber/StrongNumberActivity.kt | 112 +++++----- .../strongnumber/StrongNumberViewModel.kt | 137 +++++++----- .../me/xizzhu/android/joshua/ui/Dialog.kt | 117 ++++++----- .../strongnumber/StrongNumberViewModelTest.kt | 196 ++++++++++++++---- .../android/joshua/tests/BaseUnitTest.kt | 12 ++ 10 files changed, 615 insertions(+), 229 deletions(-) create mode 100644 app/src/main/kotlin/me/xizzhu/android/joshua/core/CoroutineDispatcherProvider.kt create mode 100644 app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt create mode 100644 app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseViewModelV2.kt diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/Injection.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/Injection.kt index 8ef7f12b..d0aed5f5 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/Injection.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/Injection.kt @@ -43,6 +43,10 @@ object AppModule { @Singleton fun provideApp(application: Application): App = application as App + @Provides + @Singleton + fun provideCoroutineDispatcherProvider(): CoroutineDispatcherProvider = DefaultCoroutineDispatcherProvider() + @Provides @Singleton fun provideAppScope(): CoroutineScope = appScope @@ -57,67 +61,67 @@ object AppModule { highlightRepository: VerseAnnotationRepository, noteRepository: VerseAnnotationRepository, readingProgressRepository: ReadingProgressRepository): BackupManager = - BackupManager(BackupJsonSerializer(), bookmarkRepository, highlightRepository, noteRepository, readingProgressRepository) + BackupManager(BackupJsonSerializer(), bookmarkRepository, highlightRepository, noteRepository, readingProgressRepository) @Provides @Singleton fun provideBibleReadingManager( - bibleReadingRepository: BibleReadingRepository, - translationRepository: TranslationRepository, - appScope: CoroutineScope + bibleReadingRepository: BibleReadingRepository, + translationRepository: TranslationRepository, + appScope: CoroutineScope ): BibleReadingManager = BibleReadingManager(bibleReadingRepository, translationRepository, appScope) @Provides @Singleton fun provideBookmarkManager(bookmarkRepository: VerseAnnotationRepository): VerseAnnotationManager = - VerseAnnotationManager(bookmarkRepository) + VerseAnnotationManager(bookmarkRepository) @Provides @Singleton fun provideCrossReferencesManager(crossReferencesRepository: CrossReferencesRepository): CrossReferencesManager = - CrossReferencesManager(crossReferencesRepository) + CrossReferencesManager(crossReferencesRepository) @Provides @Singleton fun provideHighlightManager(highlightRepository: VerseAnnotationRepository): VerseAnnotationManager = - VerseAnnotationManager(highlightRepository) + VerseAnnotationManager(highlightRepository) @Provides @Singleton fun provideNoteManager(noteRepository: VerseAnnotationRepository): VerseAnnotationManager = - VerseAnnotationManager(noteRepository) + VerseAnnotationManager(noteRepository) @Provides @Singleton fun provideReadingProgressManager( - bibleReadingRepository: BibleReadingRepository, - readingProgressRepository: ReadingProgressRepository, - appScope: CoroutineScope + bibleReadingRepository: BibleReadingRepository, + readingProgressRepository: ReadingProgressRepository, + appScope: CoroutineScope ): ReadingProgressManager = ReadingProgressManager(bibleReadingRepository, readingProgressRepository, appScope) @Provides @Singleton fun provideSearchManager( - bibleReadingRepository: BibleReadingRepository, - bookmarkRepository: VerseAnnotationRepository, - highlightRepository: VerseAnnotationRepository, - noteRepository: VerseAnnotationRepository + bibleReadingRepository: BibleReadingRepository, + bookmarkRepository: VerseAnnotationRepository, + highlightRepository: VerseAnnotationRepository, + noteRepository: VerseAnnotationRepository ): SearchManager = SearchManager(bibleReadingRepository, bookmarkRepository, highlightRepository, noteRepository) @Provides @Singleton fun provideSettingsManager(settingsRepository: SettingsRepository): SettingsManager = - SettingsManager(settingsRepository) + SettingsManager(settingsRepository) @Provides @Singleton fun provideStrongNumberManager(strongNumberRepository: StrongNumberRepository): StrongNumberManager = - StrongNumberManager(strongNumberRepository) + StrongNumberManager(strongNumberRepository) @Provides @Singleton fun provideTranslationManager(translationRepository: TranslationRepository): TranslationManager = - TranslationManager(translationRepository) + TranslationManager(translationRepository) } @Module @@ -130,45 +134,45 @@ object RepositoryModule { @Provides @Singleton fun provideBibleReadingRepository(androidDatabase: AndroidDatabase, appScope: CoroutineScope): BibleReadingRepository = - BibleReadingRepository(AndroidReadingStorage(androidDatabase), appScope) + BibleReadingRepository(AndroidReadingStorage(androidDatabase), appScope) @Provides @Singleton fun provideBookmarkRepository(androidDatabase: AndroidDatabase, appScope: CoroutineScope): VerseAnnotationRepository = - VerseAnnotationRepository(AndroidBookmarkStorage(androidDatabase), appScope) + VerseAnnotationRepository(AndroidBookmarkStorage(androidDatabase), appScope) @Provides @Singleton fun provideCrossReferencesRepository(app: App, androidDatabase: AndroidDatabase): CrossReferencesRepository = - CrossReferencesRepository(AndroidCrossReferencesStorage(androidDatabase), HttpCrossReferencesService(app)) + CrossReferencesRepository(AndroidCrossReferencesStorage(androidDatabase), HttpCrossReferencesService(app)) @Provides @Singleton fun provideHighlightRepository(androidDatabase: AndroidDatabase, appScope: CoroutineScope): VerseAnnotationRepository = - VerseAnnotationRepository(AndroidHighlightStorage(androidDatabase), appScope) + VerseAnnotationRepository(AndroidHighlightStorage(androidDatabase), appScope) @Provides @Singleton fun provideNoteRepository(androidDatabase: AndroidDatabase, appScope: CoroutineScope): VerseAnnotationRepository = - VerseAnnotationRepository(AndroidNoteStorage(androidDatabase), appScope) + VerseAnnotationRepository(AndroidNoteStorage(androidDatabase), appScope) @Provides @Singleton fun provideReadingProgressRepository(androidDatabase: AndroidDatabase): ReadingProgressRepository = - ReadingProgressRepository(AndroidReadingProgressStorage(androidDatabase)) + ReadingProgressRepository(AndroidReadingProgressStorage(androidDatabase)) @Provides @Singleton fun provideSettingsRepository(androidDatabase: AndroidDatabase, appScope: CoroutineScope): SettingsRepository = - SettingsRepository(AndroidSettingsStorage(androidDatabase), appScope) + SettingsRepository(AndroidSettingsStorage(androidDatabase), appScope) @Provides @Singleton fun provideStrongNumberRepository(app: App, androidDatabase: AndroidDatabase): StrongNumberRepository = - StrongNumberRepository(AndroidStrongNumberStorage(androidDatabase), HttpStrongNumberService(app)) + StrongNumberRepository(AndroidStrongNumberStorage(androidDatabase), HttpStrongNumberService(app)) @Provides @Singleton fun provideTranslationRepository(app: App, androidDatabase: AndroidDatabase, appScope: CoroutineScope): TranslationRepository = - TranslationRepository(AndroidTranslationStorage(androidDatabase), HttpTranslationService(app), appScope) + TranslationRepository(AndroidTranslationStorage(androidDatabase), HttpTranslationService(app), appScope) } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/core/CoroutineDispatcherProvider.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/core/CoroutineDispatcherProvider.kt new file mode 100644 index 00000000..6cb70f01 --- /dev/null +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/core/CoroutineDispatcherProvider.kt @@ -0,0 +1,41 @@ +/* + * 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.core + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +interface CoroutineDispatcherProvider { + val main: CoroutineDispatcher + val default: CoroutineDispatcher + val io: CoroutineDispatcher + val unconfined: CoroutineDispatcher +} + +class DefaultCoroutineDispatcherProvider : CoroutineDispatcherProvider { + override val main: CoroutineDispatcher + get() = Dispatchers.Main + + override val default: CoroutineDispatcher + get() = Dispatchers.Default + + override val io: CoroutineDispatcher + get() = Dispatchers.IO + + override val unconfined: CoroutineDispatcher + get() = Dispatchers.Unconfined +} 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..0a1cbbbf --- /dev/null +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt @@ -0,0 +1,105 @@ +/* + * 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 + + protected abstract fun inflateViewBinding(): VB + + protected abstract fun viewModel(): VM + + protected abstract fun onViewActionEmitted(viewAction: ViewAction) + + protected abstract fun onViewStateUpdated(viewState: ViewState) + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.i(tag, "onCreate()") + + viewBinding = inflateViewBinding() + setContentView(viewBinding.root) + + viewModel().viewAction().onEach(::onViewActionEmitted).launchIn(lifecycleScope) + viewModel().viewState().onEach(::onViewStateUpdated).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..ce7a5ed5 --- /dev/null +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseViewModelV2.kt @@ -0,0 +1,45 @@ +/* + * 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 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.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +abstract class BaseViewModelV2(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(): StateFlow = viewState + + protected fun updateViewState(block: (currentViewState: ViewState) -> ViewState?) { + viewState.update { block(it) ?: it } + } +} 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..2ea7f821 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 @@ -29,7 +29,7 @@ import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import me.xizzhu.android.joshua.utils.firstNotEmpty import java.util.ArrayList -class PreviewViewData(val settings: Settings, val title: String, val items: List, val currentPosition: Int) +data class PreviewViewData(val settings: Settings, val title: String, val items: List, val currentPosition: Int) fun loadPreview( bibleReadingManager: BibleReadingManager, @@ -37,16 +37,25 @@ fun loadPreview( verseIndex: VerseIndex, converter: List.() -> List ): Flow> = viewData { + loadPreviewV2(bibleReadingManager, settingsManager, verseIndex, converter) +} + +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() - 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 + 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 ) } 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..5d4bbe02 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 @@ -17,90 +17,96 @@ package me.xizzhu.android.joshua.strongnumber import android.os.Bundle -import android.view.View import androidx.activity.viewModels -import androidx.lifecycle.lifecycleScope +import androidx.core.view.isVisible +import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach 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.BaseActivityV2 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" fun bundle(strongNumber: String): Bundle = Bundle().apply { putString(KEY_STRONG_NUMBER, strongNumber) } + + fun strongNumber(savedStateHandle: SavedStateHandle): String = savedStateHandle.get(KEY_STRONG_NUMBER).orEmpty() } private val strongNumberViewModel: StrongNumberViewModel by viewModels() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + override fun inflateViewBinding(): ActivityStrongNumberBinding = ActivityStrongNumberBinding.inflate(layoutInflater) - observeSettings() - observeStrongNumber() - loadStrongNumber() - } + override fun viewModel(): StrongNumberViewModel = strongNumberViewModel - private fun observeSettings() { - strongNumberViewModel.settings().onEach { viewBinding.strongNumberList.setSettings(it) }.launchIn(lifecycleScope) + override fun onViewActionEmitted(viewAction: StrongNumberViewModel.ViewAction) { + when (viewAction) { + is StrongNumberViewModel.ViewAction.OpenReadingScreen -> navigator.navigate(this, Navigator.SCREEN_READING) + } } - 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) - } + override fun onViewStateUpdated(viewState: StrongNumberViewModel.ViewState) = with(viewBinding) { + viewState.settings?.let { strongNumberList.setSettings(it) } - private fun loadStrongNumber() { - strongNumberViewModel.loadStrongNumber(intent.getStringExtra(KEY_STRONG_NUMBER) ?: "") - } + if (viewState.loading) { + loadingSpinner.fadeIn() + strongNumberList.isVisible = false + } else { + loadingSpinner.isVisible = false + strongNumberList.fadeIn() + } - override fun inflateViewBinding(): ActivityStrongNumberBinding = ActivityStrongNumberBinding.inflate(layoutInflater) + strongNumberList.setItems(viewState.items) - override fun viewModel(): StrongNumberViewModel = strongNumberViewModel + viewState.preview?.let { preview -> + listDialog(preview.title, preview.settings, preview.items, preview.currentPosition) + } + + when (val error = viewState.error) { + is StrongNumberViewModel.ViewState.Error.PreviewLoadingError -> { + strongNumberViewModel.markErrorAsShown(error) + + // Very unlikely to fail, so just falls back to open the verse. + openVerse(error.verseToPreview) + } + is StrongNumberViewModel.ViewState.Error.StrongNumberLoadingError -> { + dialog( + cancelable = false, + title = R.string.dialog_title_error, + message = R.string.dialog_message_failed_to_load_strong_numbers, + onPositive = { _, _ -> strongNumberViewModel.loadStrongNumber() }, + onNegative = { _, _ -> finish() }, + onDismiss = { strongNumberViewModel.markErrorAsShown(error) } + ) + } + is StrongNumberViewModel.ViewState.Error.VerseOpeningError -> { + dialog( + cancelable = true, + title = R.string.dialog_title_error, + message = R.string.dialog_message_failed_to_select_verse, + onPositive = { _, _ -> openVerse(error.verseToOpen) }, + onDismiss = { strongNumberViewModel.markErrorAsShown(error) } + ) + } + null -> { + // Do nothing + } + } + } override fun openVerse(verseToOpen: VerseIndex) { - strongNumberViewModel.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) + 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.loadPreview(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..ca81db57 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 @@ -16,25 +16,24 @@ package me.xizzhu.android.joshua.strongnumber -import android.app.Application import android.text.SpannableStringBuilder +import androidx.lifecycle.SavedStateHandle 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.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import me.xizzhu.android.joshua.core.BibleReadingManager +import me.xizzhu.android.joshua.core.CoroutineDispatcherProvider +import me.xizzhu.android.joshua.core.Settings 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.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,57 +45,81 @@ 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) + private val bibleReadingManager: BibleReadingManager, + private val strongNumberManager: StrongNumberManager, + private val settingsManager: SettingsManager, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, + private val savedStateHandle: SavedStateHandle +) : BaseViewModelV2( + initialViewState = ViewState( + settings = null, + loading = false, + items = emptyList(), + preview = null, + error = null, + ) +) { + sealed class ViewAction { + object OpenReadingScreen : ViewAction() + } - fun strongNumber(): Flow> = strongNumber.filterNotNull() + data class ViewState( + val settings: Settings?, + val loading: Boolean, + val items: List, + val preview: PreviewViewData?, + val error: Error?, + ) { + sealed class Error { + data class PreviewLoadingError(val verseToPreview: VerseIndex) : Error() + object StrongNumberLoadingError : Error() + data class VerseOpeningError(val verseToOpen: VerseIndex) : Error() + } + } - fun loadStrongNumber(sn: String) { + init { + settingsManager.settings().onEach { settings -> updateViewState { it.copy(settings = settings) } }.launchIn(viewModelScope) + loadStrongNumber() + } + + fun loadStrongNumber() { + val sn = StrongNumberActivity.strongNumber(savedStateHandle) if (sn.isEmpty()) { - strongNumber.value = ViewData.Failure(IllegalStateException("Requested Strong number is empty")) + updateViewState { it.copy(loading = false, items = emptyList(), error = ViewState.Error.StrongNumberLoadingError) } return } - viewModelScope.launch { - try { - strongNumber.value = ViewData.Loading() + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + updateViewState { it.copy(loading = true, error = null) } 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) - ) - )) - } catch (e: Exception) { + val 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) + ) + updateViewState { it.copy(loading = false, items = items) } + }.onFailure { e -> Log.e(tag, "Error occurred which loading Strong's Numbers", e) - strongNumber.value = ViewData.Failure(e) + updateViewState { it.copy(loading = false, error = ViewState.Error.StrongNumberLoadingError) } } } } private fun buildStrongNumberItems( - strongNumber: StrongNumber, - verses: List, - bookNames: List, - bookShortNames: List): List { + strongNumber: StrongNumber, + verses: List, + bookNames: List, + bookShortNames: List + ): List { val items: ArrayList = ArrayList() - val title = SpannableStringBuilder().append(strongNumber.sn) - .setSpans(createTitleSpans()) - .append(' ').append(strongNumber.meaning) - .toCharSequence() + val title = SpannableStringBuilder().append(strongNumber.sn).setSpans(createTitleSpans()).append(' ').append(strongNumber.meaning).toCharSequence() items.add(TextItem(title)) var currentBookIndex = -1 @@ -115,11 +138,31 @@ 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 { + runCatching { + bibleReadingManager.saveCurrentVerseIndex(verseToOpen) + emitViewAction(ViewAction.OpenReadingScreen) + }.onFailure { e -> + Log.e(tag, "Failed to save current verse", e) + updateViewState { it.copy(error = ViewState.Error.VerseOpeningError(verseToOpen)) } + } + } + } + + fun loadPreview(verseToPreview: VerseIndex) { + viewModelScope.launch { + runCatching { + val preview = loadPreviewV2(bibleReadingManager, settingsManager, verseToPreview, ::toVersePreviewItems) + updateViewState { it.copy(preview = preview) } + }.onFailure { e -> + Log.e(tag, "Failed to load verses for preview", e) + updateViewState { it.copy(error = ViewState.Error.PreviewLoadingError(verseToPreview)) } + } + } + } - fun loadVersesForPreview(verseIndex: VerseIndex): Flow> = - loadPreview(bibleReadingManager, settingsManager, verseIndex, ::toVersePreviewItems) - .onFailure { Log.e(tag, "Failed to load verses for preview", it) } + fun markErrorAsShown(error: ViewState.Error) { + updateViewState { current -> if (current.error == error) current.copy(error = null) else null } + } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/ui/Dialog.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/ui/Dialog.kt index d5622bf5..4cdcc6fc 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/ui/Dialog.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/ui/Dialog.kt @@ -54,17 +54,24 @@ class ProgressDialog(private val dialog: AlertDialog, private val progressBar: P } } -fun Activity.dialog(cancelable: Boolean, @StringRes title: Int, @StringRes message: Int, - onPositive: DialogInterface.OnClickListener, onNegative: DialogInterface.OnClickListener? = null) { +fun Activity.dialog( + cancelable: Boolean, + @StringRes title: Int, + @StringRes message: Int, + onPositive: DialogInterface.OnClickListener, + onNegative: DialogInterface.OnClickListener? = null, + onDismiss: DialogInterface.OnDismissListener? = null +) { if (isDestroyed) return MaterialAlertDialogBuilder(this) - .setCancelable(cancelable) - .setTitle(title) - .setMessage(message) - .setPositiveButton(android.R.string.ok, onPositive) - .setNegativeButton(android.R.string.cancel, onNegative) - .show() + .setCancelable(cancelable) + .setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, onPositive) + .setNegativeButton(android.R.string.cancel, onNegative) + .setOnDismissListener(onDismiss) + .show() } fun Activity.dialog(cancelable: Boolean, title: CharSequence, @StringRes message: Int, @@ -72,48 +79,48 @@ fun Activity.dialog(cancelable: Boolean, title: CharSequence, @StringRes message if (isDestroyed) return MaterialAlertDialogBuilder(this) - .setCancelable(cancelable) - .setTitle(title) - .setMessage(message) - .setPositiveButton(android.R.string.ok, onPositive) - .setNegativeButton(android.R.string.cancel, onNegative) - .show() + .setCancelable(cancelable) + .setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, onPositive) + .setNegativeButton(android.R.string.cancel, onNegative) + .show() } fun Activity.progressDialog(@StringRes title: Int, maxProgress: Int, onCancel: () -> Unit): ProgressDialog? { if (isDestroyed) return null val progressBar = (View.inflate(this, R.layout.widget_progress_bar, null) as ProgressBar) - .apply { max = maxProgress } + .apply { max = maxProgress } return ProgressDialog( - MaterialAlertDialogBuilder(this) - .setTitle(title) - .setView(progressBar) - .setCancelable(true) - .setOnCancelListener { onCancel() } - .setNegativeButton(android.R.string.cancel) { _, _ -> onCancel() } - .show(), - progressBar) + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setView(progressBar) + .setCancelable(true) + .setOnCancelListener { onCancel() } + .setNegativeButton(android.R.string.cancel) { _, _ -> onCancel() } + .show(), + progressBar) } fun Activity.indeterminateProgressDialog(@StringRes title: Int): AlertDialog? { if (isDestroyed) return null return MaterialAlertDialogBuilder(this) - .setCancelable(false) - .setTitle(title) - .setView(View.inflate(this, R.layout.widget_indeterminate_progress_bar, null)) - .show() + .setCancelable(false) + .setTitle(title) + .setView(View.inflate(this, R.layout.widget_indeterminate_progress_bar, null)) + .show() } fun Activity.seekBarDialog( - @StringRes title: Int, - initialValue: Float, - minValue: Float, - maxValue: Float, - onValueChanged: (Float) -> Unit, - onPositive: ((Float) -> Unit)? = null, - onNegative: (() -> Unit)? = null + @StringRes title: Int, + initialValue: Float, + minValue: Float, + maxValue: Float, + onValueChanged: (Float) -> Unit, + onPositive: ((Float) -> Unit)? = null, + onNegative: (() -> Unit)? = null ): AlertDialog? { if (isDestroyed) return null @@ -129,12 +136,12 @@ fun Activity.seekBarDialog( override fun onStopTrackingTouch(seekBar: SeekBar) {} }) return MaterialAlertDialogBuilder(this) - .setCancelable(false) - .setTitle(title) - .setView(viewBinding.root) - .setPositiveButton(android.R.string.ok, onPositive?.let { { _, _ -> it(viewBinding.seekBar.calculateValue(minValue, maxValue)) } }) - .setNegativeButton(android.R.string.cancel, onNegative?.let { { _, _ -> it() } }) - .show() + .setCancelable(false) + .setTitle(title) + .setView(viewBinding.root) + .setPositiveButton(android.R.string.ok, onPositive?.let { { _, _ -> it(viewBinding.seekBar.calculateValue(minValue, maxValue)) } }) + .setNegativeButton(android.R.string.cancel, onNegative?.let { { _, _ -> it() } }) + .show() } private fun SeekBar.setProgress(value: Float, minValue: Float, maxValue: Float) { @@ -142,14 +149,14 @@ private fun SeekBar.setProgress(value: Float, minValue: Float, maxValue: Float) } private fun SeekBar.calculateValue(minValue: Float, maxValue: Float): Float = - minValue + (maxValue - minValue) * progress.toFloat() / max.toFloat() + minValue + (maxValue - minValue) * progress.toFloat() / max.toFloat() fun Activity.listDialog( - title: CharSequence, - settings: Settings, - items: List, - selected: Int, - onDismiss: DialogInterface.OnDismissListener? = null + title: CharSequence, + settings: Settings, + items: List, + selected: Int, + onDismiss: DialogInterface.OnDismissListener? = null ): AlertDialog? { if (isDestroyed) return null @@ -158,19 +165,19 @@ fun Activity.listDialog( viewBinding.recyclerView.setItems(items) viewBinding.recyclerView.scrollToPosition(selected) return MaterialAlertDialogBuilder(this) - .setCancelable(true) - .setTitle(title) - .setView(viewBinding.root) - .setOnDismissListener(onDismiss) - .show() + .setCancelable(true) + .setTitle(title) + .setView(viewBinding.root) + .setOnDismissListener(onDismiss) + .show() } fun Activity.listDialog(@StringRes title: Int, items: Array, selected: Int, onClicked: DialogInterface.OnClickListener) { if (isDestroyed) return MaterialAlertDialogBuilder(this) - .setCancelable(true) - .setSingleChoiceItems(items, selected, onClicked) - .setTitle(title) - .show() + .setCancelable(true) + .setSingleChoiceItems(items, selected, onClicked) + .setTitle(title) + .show() } 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..af4faa6f 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 @@ -16,13 +16,15 @@ package me.xizzhu.android.joshua.strongnumber -import android.app.Application +import androidx.lifecycle.SavedStateHandle import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +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.core.BibleReadingManager import me.xizzhu.android.joshua.core.Settings @@ -30,7 +32,6 @@ 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 @@ -40,6 +41,8 @@ import org.robolectric.RobolectricTestRunner import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) @@ -47,7 +50,7 @@ class StrongNumberViewModelTest : BaseUnitTest() { private lateinit var bibleReadingManager: BibleReadingManager private lateinit var strongNumberManager: StrongNumberManager private lateinit var settingsManager: SettingsManager - private lateinit var application: Application + private lateinit var savedStateHandle: SavedStateHandle private lateinit var strongNumberViewModel: StrongNumberViewModel @@ -57,20 +60,73 @@ class StrongNumberViewModelTest : BaseUnitTest() { bibleReadingManager = mockk() strongNumberManager = mockk() - settingsManager = mockk() - application = mockk() + settingsManager = mockk().apply { every { settings() } returns emptyFlow() } + savedStateHandle = mockk().apply { every { get("me.xizzhu.android.joshua.KEY_STRONG_NUMBER") } returns "" } - strongNumberViewModel = StrongNumberViewModel(bibleReadingManager, strongNumberManager, settingsManager, application) + strongNumberViewModel = StrongNumberViewModel(bibleReadingManager, strongNumberManager, settingsManager, testCoroutineDispatcherProvider, savedStateHandle) } @Test - fun `test loadStrongNumber() with empty sn`() = runTest { - strongNumberViewModel.loadStrongNumber("") - assertTrue(strongNumberViewModel.strongNumber().first() is BaseViewModel.ViewData.Failure) + fun `test loadStrongNumber(), called in constructor, with empty sn`() = runTest { + assertEquals( + StrongNumberViewModel.ViewState( + settings = null, + loading = false, + items = emptyList(), + preview = null, + error = StrongNumberViewModel.ViewState.Error.StrongNumberLoadingError + ), + strongNumberViewModel.viewState().first() + ) + + strongNumberViewModel.markErrorAsShown(StrongNumberViewModel.ViewState.Error.VerseOpeningError(VerseIndex.INVALID)) + assertEquals( + StrongNumberViewModel.ViewState( + settings = null, + loading = false, + items = emptyList(), + preview = null, + error = StrongNumberViewModel.ViewState.Error.StrongNumberLoadingError + ), + strongNumberViewModel.viewState().first() + ) + + strongNumberViewModel.markErrorAsShown(StrongNumberViewModel.ViewState.Error.StrongNumberLoadingError) + assertEquals( + StrongNumberViewModel.ViewState( + settings = null, + loading = false, + items = emptyList(), + preview = null, + error = null + ), + strongNumberViewModel.viewState().first() + ) } @Test - fun `test loadStrongNumber()`() = runTest { + fun `test loadStrongNumber(), called in constructor, with exception`() = runTest { + val sn = "H7225" + coEvery { bibleReadingManager.currentTranslation() } throws RuntimeException("random exception") + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) + every { savedStateHandle.get("me.xizzhu.android.joshua.KEY_STRONG_NUMBER") } returns sn + + strongNumberViewModel = StrongNumberViewModel(bibleReadingManager, strongNumberManager, settingsManager, testCoroutineDispatcherProvider, savedStateHandle) + + assertEquals( + StrongNumberViewModel.ViewState( + settings = Settings.DEFAULT, + loading = false, + items = emptyList(), + preview = null, + error = StrongNumberViewModel.ViewState.Error.StrongNumberLoadingError + ), + strongNumberViewModel.viewState().first() + ) + } + + @Test + fun `test loadStrongNumber(), called in constructor`() = runTest { val sn = "H7225" coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) coEvery { strongNumberManager.readStrongNumber(sn) } returns StrongNumber(sn, MockContents.strongNumberWords.getValue(sn)) @@ -78,48 +134,102 @@ class StrongNumberViewModelTest : BaseUnitTest() { coEvery { bibleReadingManager.readVerses(MockContents.kjvShortName, MockContents.strongNumberReverseIndex.getValue(sn)) } returns mapOf( - Pair(MockContents.kjvExtraVerses[0].verseIndex, MockContents.kjvExtraVerses[0]), - Pair(MockContents.kjvExtraVerses[1].verseIndex, MockContents.kjvExtraVerses[1]), - Pair(MockContents.kjvVerses[0].verseIndex, MockContents.kjvVerses[0]) + Pair(MockContents.kjvExtraVerses[0].verseIndex, MockContents.kjvExtraVerses[0]), + Pair(MockContents.kjvExtraVerses[1].verseIndex, MockContents.kjvExtraVerses[1]), + Pair(MockContents.kjvVerses[0].verseIndex, MockContents.kjvVerses[0]) ) coEvery { bibleReadingManager.readBookNames(MockContents.kjvShortName) } returns MockContents.kjvBookNames coEvery { bibleReadingManager.readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames - strongNumberViewModel.loadStrongNumber(sn) + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) + every { savedStateHandle.get("me.xizzhu.android.joshua.KEY_STRONG_NUMBER") } returns sn + + strongNumberViewModel = StrongNumberViewModel(bibleReadingManager, strongNumberManager, settingsManager, testCoroutineDispatcherProvider, savedStateHandle) - val actual = strongNumberViewModel.strongNumber().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(6, actual.data.items.size) + val actual = strongNumberViewModel.viewState().first() + assertEquals(Settings.DEFAULT, actual.settings) + assertFalse(actual.loading) + assertEquals(6, actual.items.size) assertEquals( - "H7225 beginning, chief(-est), first(-fruits, part, time), principal thing.", - (actual.data.items[0] as TextItem).title.toString() + "H7225 beginning, chief(-est), first(-fruits, part, time), principal thing.", + (actual.items[0] as TextItem).title.toString() ) - assertEquals("Genesis", (actual.data.items[1] as TitleItem).title.toString()) + assertEquals("Genesis", (actual.items[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() + "Gen. 1:1 In the beginning God created the heaven and the earth.", + (actual.items[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() + "Gen. 10:10 And the beginning of his kingdom was Babel, and Erech, and Accad, and Calneh, in the land of Shinar.", + (actual.items[3] as StrongNumberItem).textForDisplay.toString() ) - assertEquals("Exodus", (actual.data.items[4] as TitleItem).title.toString()) + assertEquals("Exodus", (actual.items[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() + "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.items[5] as StrongNumberItem).textForDisplay.toString() ) + assertNull(actual.preview) + assertNull(actual.error) } @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") + + strongNumberViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertEquals( + StrongNumberViewModel.ViewState( + settings = null, + loading = false, + items = emptyList(), + preview = null, + error = StrongNumberViewModel.ViewState.Error.VerseOpeningError(VerseIndex(0, 0, 0)) + ), + strongNumberViewModel.viewState().first() + ) + } + + @Test + fun `test openVerse()`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } returns Unit + + val viewAction = async(Dispatchers.Unconfined) { strongNumberViewModel.viewAction().first() } + + strongNumberViewModel.markErrorAsShown(StrongNumberViewModel.ViewState.Error.StrongNumberLoadingError) + strongNumberViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertEquals( + StrongNumberViewModel.ViewState( + settings = null, + loading = false, + items = emptyList(), + preview = null, + error = null + ), + strongNumberViewModel.viewState().first() + ) + assertEquals(StrongNumberViewModel.ViewAction.OpenReadingScreen, viewAction.await()) + } + + @Test + fun `test loadPreview() with invalid verse index`() = runTest { + strongNumberViewModel.loadPreview(VerseIndex.INVALID) + + assertEquals( + StrongNumberViewModel.ViewState( + settings = null, + loading = false, + items = emptyList(), + preview = null, + error = StrongNumberViewModel.ViewState.Error.PreviewLoadingError(VerseIndex.INVALID) + ), + strongNumberViewModel.viewState().first() + ) } @Test - fun `test loadVersesForPreview()`() = runTest { + fun `test loadPreview()`() = runTest { coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) coEvery { bibleReadingManager.readVerses(MockContents.kjvShortName, 0, 0) @@ -127,13 +237,17 @@ 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) + strongNumberViewModel.markErrorAsShown(StrongNumberViewModel.ViewState.Error.StrongNumberLoadingError) + strongNumberViewModel.loadPreview(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 = strongNumberViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) + assertTrue(actual.items.isEmpty()) + assertEquals(Settings.DEFAULT, actual.preview?.settings) + assertEquals("Gen., 1", actual.preview?.title) + assertEquals(3, actual.preview?.items?.size) + assertEquals(1, actual.preview?.currentPosition) + assertNull(actual.error) } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/tests/BaseUnitTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/tests/BaseUnitTest.kt index bd9953c6..173a0d4b 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/tests/BaseUnitTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/tests/BaseUnitTest.kt @@ -17,12 +17,14 @@ package me.xizzhu.android.joshua.tests import androidx.annotation.CallSuper +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import me.xizzhu.android.joshua.core.CoroutineDispatcherProvider import java.util.* import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -30,6 +32,16 @@ import kotlin.test.BeforeTest abstract class BaseUnitTest { private val testDispatcher = UnconfinedTestDispatcher() protected val testScope = CoroutineScope(SupervisorJob() + testDispatcher) + protected val testCoroutineDispatcherProvider = object : CoroutineDispatcherProvider { + override val main: CoroutineDispatcher + get() = testDispatcher + override val default: CoroutineDispatcher + get() = testDispatcher + override val io: CoroutineDispatcher + get() = testDispatcher + override val unconfined: CoroutineDispatcher + get() = testDispatcher + } @CallSuper @BeforeTest