From a0776e110db1e58e2f0eee17b4b3f86ff81ecac6 Mon Sep 17 00:00:00 2001 From: Xizhi Zhu Date: Sun, 25 Dec 2022 22:18:23 +0900 Subject: [PATCH 1/2] Refactor Settings Activity / ViewModel to use single view state --- .../android/joshua/core/SettingsManager.kt | 26 +- .../android/joshua/reading/ReadingActivity.kt | 16 +- .../joshua/settings/SettingsActivity.kt | 318 +++++----- .../joshua/settings/SettingsViewModel.kt | 410 +++++++++---- .../me/xizzhu/android/joshua/ui/Dialog.kt | 13 +- .../me/xizzhu/android/joshua/ui/View.kt | 41 +- .../me/xizzhu/android/joshua/utils/Context.kt | 15 +- app/src/main/res/values/strings.xml | 3 +- .../joshua/settings/SettingsViewModelTest.kt | 566 +++++++++++++----- 9 files changed, 940 insertions(+), 468 deletions(-) diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/core/SettingsManager.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/core/SettingsManager.kt index eccacccd..4b2cfb4a 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/core/SettingsManager.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/core/SettingsManager.kt @@ -20,11 +20,19 @@ import androidx.annotation.IntDef import kotlinx.coroutines.flow.Flow import me.xizzhu.android.joshua.core.repository.SettingsRepository -data class Settings(val keepScreenOn: Boolean, @NightMode val nightMode: Int, val fontSizeScale: Float, - val simpleReadingModeOn: Boolean, val hideSearchButton: Boolean, - val consolidateVersesForSharing: Boolean, - @Highlight.Companion.AvailableColor val defaultHighlightColor: Int) { +data class Settings( + val keepScreenOn: Boolean, + @NightMode val nightMode: Int, + val fontSizeScale: Float, + val simpleReadingModeOn: Boolean, + val hideSearchButton: Boolean, + val consolidateVersesForSharing: Boolean, + @Highlight.Companion.AvailableColor val defaultHighlightColor: Int, +) { companion object { + const val MIN_FONT_SIZE_SCALE = 0.5F + const val MAX_FONT_SIZE_SCALE = 3.0F + const val NIGHT_MODE_ON = 0 const val NIGHT_MODE_OFF = 1 const val NIGHT_MODE_FOLLOW_SYSTEM = 2 @@ -34,9 +42,13 @@ data class Settings(val keepScreenOn: Boolean, @NightMode val nightMode: Int, va annotation class NightMode val DEFAULT = Settings( - keepScreenOn = true, nightMode = NIGHT_MODE_OFF, fontSizeScale = 1.0F, - simpleReadingModeOn = false, hideSearchButton = false, consolidateVersesForSharing = false, - defaultHighlightColor = Highlight.COLOR_NONE + keepScreenOn = true, + nightMode = NIGHT_MODE_OFF, + fontSizeScale = 1.0F, + simpleReadingModeOn = false, + hideSearchButton = false, + consolidateVersesForSharing = false, + defaultHighlightColor = Highlight.COLOR_NONE, ) } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/reading/ReadingActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/reading/ReadingActivity.kt index 9f0303a5..30ed1d00 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/reading/ReadingActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/reading/ReadingActivity.kt @@ -526,13 +526,15 @@ class ReadingActivity : BaseActivity() lifecycleScope.launch { val defaultHighlightColor = readingViewModel.settings().first().defaultHighlightColor if (Highlight.COLOR_NONE == defaultHighlightColor) { - listDialog(R.string.text_pick_highlight_color, - resources.getStringArray(R.array.text_highlight_colors), - max(0, Highlight.AVAILABLE_COLORS.indexOf(currentHighlightColor))) { dialog, which -> - saveHighlight(verseIndex, Highlight.AVAILABLE_COLORS[which]) - - dialog.dismiss() - } + listDialog( + title = R.string.text_pick_highlight_color, + items = resources.getStringArray(R.array.text_highlight_colors), + selected = max(0, Highlight.AVAILABLE_COLORS.indexOf(currentHighlightColor)), + onClicked = { dialog, which -> + saveHighlight(verseIndex, Highlight.AVAILABLE_COLORS[which]) + dialog.dismiss() + } + ) } else { saveHighlight(verseIndex, if (currentHighlightColor == Highlight.COLOR_NONE) defaultHighlightColor else Highlight.COLOR_NONE) } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/settings/SettingsActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/settings/SettingsActivity.kt index 1665d10e..3eb477ec 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/settings/SettingsActivity.kt @@ -16,215 +16,199 @@ package me.xizzhu.android.joshua.settings -import android.app.Activity import android.content.Intent -import android.os.Bundle import android.util.TypedValue import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.annotation.Px import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.launchIn +import kotlin.math.roundToInt import me.xizzhu.android.joshua.Navigator import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.databinding.ActivitySettingsBinding -import me.xizzhu.android.joshua.infra.onEach -import me.xizzhu.android.joshua.infra.BaseActivity -import me.xizzhu.android.joshua.infra.BaseViewModel -import me.xizzhu.android.joshua.infra.onFailure +import me.xizzhu.android.joshua.ui.toast +import me.xizzhu.android.joshua.infra.BaseActivityV2 import me.xizzhu.android.joshua.ui.indeterminateProgressDialog import me.xizzhu.android.joshua.ui.listDialog import me.xizzhu.android.joshua.ui.seekBarDialog -import me.xizzhu.android.joshua.ui.toast +import me.xizzhu.android.joshua.ui.setOnCheckedChangeByUserListener +import me.xizzhu.android.joshua.ui.setOnSingleClickListener import me.xizzhu.android.logger.Log -import kotlin.math.roundToInt @AndroidEntryPoint -class SettingsActivity : BaseActivity() { - private val settingsViewModel: SettingsViewModel by viewModels() +class SettingsActivity : BaseActivityV2() { private val createFileForBackupLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) backupRestore(settingsViewModel.backup(result.data?.data)) + if (result.resultCode == RESULT_OK) viewModel.backup(result.data?.data) } private val selectFileForRestoreLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) backupRestore(settingsViewModel.restore(result.data?.data)) + if (result.resultCode == RESULT_OK) viewModel.restore(result.data?.data) } private var indeterminateProgressDialog: AlertDialog? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - observeSettings() - initializeListeners() - } - - private fun observeSettings() { - settingsViewModel.settingsViewData() - .onEach( - onLoading = { /* Do nothing. */ }, - onSuccess = { - updateView(it) - }, - onFailure = { /* Do nothing. */ } - ) - .launchIn(lifecycleScope) - } - - private fun updateView(settingsViewData: SettingsViewData) { - with(viewBinding) { - fontSize.setDescription("${settingsViewData.currentFontSizeScale}x") - keepScreenOn.isChecked = settingsViewData.keepScreenOn - nightMode.setDescription(settingsViewData.nightMode.label) - simpleReadingMode.isChecked = settingsViewData.simpleReadingModeOn - hideSearchButton.isChecked = settingsViewData.hideSearchButton - consolidatedSharing.isChecked = settingsViewData.consolidateVersesForSharing - defaultHighlightColor.setDescription(settingsViewData.defaultHighlightColor.label) - version.setDescription(settingsViewData.version) - } - - setTextSize(settingsViewData.bodyTextSizeInPixel, settingsViewData.captionTextSizeInPixel) - } - - private fun setTextSize(bodyTextSizeInPixel: Float, captionTextSizeInPixel: Float) { - with(viewBinding) { - display.setTextSize(bodyTextSizeInPixel.roundToInt()) - fontSize.setTextSize(bodyTextSizeInPixel.roundToInt(), captionTextSizeInPixel.roundToInt()) - keepScreenOn.setTextSize(TypedValue.COMPLEX_UNIT_PX, bodyTextSizeInPixel) - nightMode.setTextSize(bodyTextSizeInPixel.roundToInt(), captionTextSizeInPixel.roundToInt()) - reading.setTextSize(bodyTextSizeInPixel.roundToInt()) - simpleReadingMode.setTextSize(TypedValue.COMPLEX_UNIT_PX, bodyTextSizeInPixel) - hideSearchButton.setTextSize(TypedValue.COMPLEX_UNIT_PX, bodyTextSizeInPixel) - consolidatedSharing.setTextSize(TypedValue.COMPLEX_UNIT_PX, bodyTextSizeInPixel) - defaultHighlightColor.setTextSize(bodyTextSizeInPixel.roundToInt(), captionTextSizeInPixel.roundToInt()) - backupRestore.setTextSize(bodyTextSizeInPixel.roundToInt()) - backup.setTextSize(bodyTextSizeInPixel.roundToInt(), captionTextSizeInPixel.roundToInt()) - restore.setTextSize(bodyTextSizeInPixel.roundToInt(), captionTextSizeInPixel.roundToInt()) - about.setTextSize(bodyTextSizeInPixel.roundToInt()) - rate.setTextSize(bodyTextSizeInPixel.roundToInt(), captionTextSizeInPixel.roundToInt()) - website.setTextSize(bodyTextSizeInPixel.roundToInt(), captionTextSizeInPixel.roundToInt()) - version.setTextSize(bodyTextSizeInPixel.roundToInt(), captionTextSizeInPixel.roundToInt()) - } + override val viewModel: SettingsViewModel by viewModels() + + override val viewBinding: ActivitySettingsBinding by lazy(LazyThreadSafetyMode.NONE) { ActivitySettingsBinding.inflate(layoutInflater) } + + override fun initializeView() = with(viewBinding) { + keepScreenOn.setOnCheckedChangeByUserListener(viewModel::saveKeepScreenOn) + simpleReadingMode.setOnCheckedChangeByUserListener(viewModel::saveSimpleReadingModeOn) + hideSearchButton.setOnCheckedChangeByUserListener(viewModel::saveHideSearchButton) + consolidatedSharing.setOnCheckedChangeByUserListener(viewModel::saveConsolidateVersesForSharing) + fontSize.setOnSingleClickListener(viewModel::selectFontSizeScale) + nightMode.setOnSingleClickListener(viewModel::selectNightMode) + defaultHighlightColor.setOnSingleClickListener(viewModel::selectHighlightColor) + backup.setOnSingleClickListener(viewModel::backup) + restore.setOnSingleClickListener(viewModel::restore) + rate.setOnSingleClickListener(viewModel::openRateMe) + website.setOnSingleClickListener(viewModel::openWebsite) } - private fun initializeListeners(): Unit = with(viewBinding) { - fontSize.setOnClickListener { - val settings = settingsViewModel.currentSettingsViewData() ?: return@setOnClickListener - seekBarDialog( - title = R.string.settings_title_font_size, - initialValue = settings.currentFontSizeScale, - minValue = 0.5F, - maxValue = 3.0F, - onValueChanged = { value -> - fontSize.setDescription("${value}x") - setTextSize( - bodyTextSizeInPixel = settings.bodyTextSizeInPixel * value / settings.currentFontSizeScale, - captionTextSizeInPixel = settings.captionTextSizeInPixel * value / settings.currentFontSizeScale - ) - }, - onPositive = { value -> - settingsViewModel.saveFontSizeScale(value) - .onFailure { - // reset the current view data - fontSize.setDescription("${settings.currentFontSizeScale}x") - setTextSize(settings.bodyTextSizeInPixel, settings.captionTextSizeInPixel) - - toast(R.string.toast_unknown_error) - }.launchIn(lifecycleScope) - }, - onNegative = { - fontSize.setDescription("${settings.currentFontSizeScale}x") - setTextSize(settings.bodyTextSizeInPixel, settings.captionTextSizeInPixel) - } - ) - } - keepScreenOn.setOnCheckedChangeListener { _, isChecked -> - settingsViewModel.saveKeepScreenOn(isChecked).onFailure { toast(R.string.toast_unknown_error) }.launchIn(lifecycleScope) - } - nightMode.setOnClickListener { - val nightMode = settingsViewModel.currentSettingsViewData()?.nightMode ?: return@setOnClickListener - listDialog(R.string.settings_title_pick_night_mode, resources.getStringArray(R.array.text_night_modes), nightMode.ordinal) { dialog, which -> - settingsViewModel.saveNightMode(SettingsViewData.NightMode.values()[which]) - .onFailure { toast(R.string.toast_unknown_error) } - .launchIn(lifecycleScope) - dialog.dismiss() - } - } - simpleReadingMode.setOnCheckedChangeListener { _, isChecked -> - settingsViewModel.saveSimpleReadingModeOn(isChecked).onFailure { toast(R.string.toast_unknown_error) }.launchIn(lifecycleScope) - } - hideSearchButton.setOnCheckedChangeListener { _, isChecked -> - settingsViewModel.saveHideSearchButton(isChecked).onFailure { toast(R.string.toast_unknown_error) }.launchIn(lifecycleScope) - } - consolidatedSharing.setOnCheckedChangeListener { _, isChecked -> - settingsViewModel.saveConsolidateVersesForSharing(isChecked).onFailure { toast(R.string.toast_unknown_error) }.launchIn(lifecycleScope) - } - defaultHighlightColor.setOnClickListener { - val defaultHighlightColor = settingsViewModel.currentSettingsViewData()?.defaultHighlightColor ?: return@setOnClickListener - listDialog(R.string.text_pick_highlight_color, resources.getStringArray(R.array.text_highlight_colors), defaultHighlightColor.ordinal) { dialog, which -> - settingsViewModel.saveDefaultHighlightColor(SettingsViewData.HighlightColor.values()[which]) - .onFailure { toast(R.string.toast_unknown_error) } - .launchIn(lifecycleScope) - dialog.dismiss() - } - } - backup.setOnClickListener { + override fun onViewActionEmitted(viewAction: SettingsViewModel.ViewAction) = when (viewAction) { + SettingsViewModel.ViewAction.OpenRateMe -> navigator.navigate(this, Navigator.SCREEN_RATE_ME) + SettingsViewModel.ViewAction.OpenWebsite -> navigator.navigate(this, Navigator.SCREEN_WEBSITE) + SettingsViewModel.ViewAction.RequestUriForBackup -> { try { - createFileForBackupLauncher.launch( - Intent(Intent.ACTION_CREATE_DOCUMENT).setType("application/json").addCategory(Intent.CATEGORY_OPENABLE) - ) + createFileForBackupLauncher.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).setType("application/json").addCategory(Intent.CATEGORY_OPENABLE)) } catch (e: Exception) { Log.e(tag, "Failed to start activity to create file for backup", e) toast(R.string.toast_unknown_error) } } - restore.setOnClickListener { - selectFileForRestoreLauncher.launch(Intent.createChooser( - Intent(Intent.ACTION_GET_CONTENT).setType("*/*").addCategory(Intent.CATEGORY_OPENABLE), getString(R.string.text_restore_from) - )) - } - rate.setOnClickListener { + SettingsViewModel.ViewAction.RequestUriForRestore -> { try { - navigator.navigate(this@SettingsActivity, Navigator.SCREEN_RATE_ME) - } catch (e: Exception) { - Log.e(tag, "Failed to start activity to rate app", e) - toast(R.string.toast_unknown_error) - } - } - website.setOnClickListener { - try { - navigator.navigate(this@SettingsActivity, Navigator.SCREEN_WEBSITE) + selectFileForRestoreLauncher.launch(Intent.createChooser( + Intent(Intent.ACTION_GET_CONTENT).setType("*/*").addCategory(Intent.CATEGORY_OPENABLE), getString(R.string.text_restore_from) + )) } catch (e: Exception) { - Log.e(tag, "Failed to start activity to visit website", e) + Log.e(tag, "Failed to start activity to choose file for restore", e) toast(R.string.toast_unknown_error) } } } - private fun backupRestore(op: Flow>) { - op.onEach( - onLoading = { - dismissIndeterminateProgressDialog() - indeterminateProgressDialog = indeterminateProgressDialog(R.string.dialog_title_wait) - }, - onSuccess = { - dismissIndeterminateProgressDialog() - toast(it) - }, - onFailure = { - dismissIndeterminateProgressDialog() - toast(R.string.toast_unknown_error) - } - ).launchIn(lifecycleScope) + override fun onViewStateUpdated(viewState: SettingsViewModel.ViewState): Unit = with(viewBinding) { + fontSize.setDescription("${viewState.fontSizeScale}x") + setTextSize(bodyTextSizePx = viewState.bodyTextSizePx, captionTextSizePx = viewState.captionTextSizePx) + + keepScreenOn.isChecked = viewState.keepScreenOn + simpleReadingMode.isChecked = viewState.simpleReadingModeOn + hideSearchButton.isChecked = viewState.hideSearchButton + consolidatedSharing.isChecked = viewState.consolidateVersesForSharing + + nightMode.setDescription(viewState.nightModeStringRes) + defaultHighlightColor.setDescription(viewState.defaultHighlightColorStringRes) + version.setDescription(viewState.version) + + viewState.backupState.handle(isBackup = true) + viewState.restoreState.handle(isBackup = false) + viewState.fontSizeScaleSelection?.handle() + viewState.nightModeSelection?.handle() + viewState.highlightColorSelection?.handle() + viewState.error?.handle() + } + + private fun setTextSize(@Px bodyTextSizePx: Int, @Px captionTextSizePx: Int) = with(viewBinding) { + display.setTextSize(bodyTextSizePx) + fontSize.setTextSize(bodyTextSizePx, captionTextSizePx) + keepScreenOn.setTextSize(TypedValue.COMPLEX_UNIT_PX, bodyTextSizePx.toFloat()) + nightMode.setTextSize(bodyTextSizePx, captionTextSizePx) + reading.setTextSize(bodyTextSizePx) + simpleReadingMode.setTextSize(TypedValue.COMPLEX_UNIT_PX, bodyTextSizePx.toFloat()) + hideSearchButton.setTextSize(TypedValue.COMPLEX_UNIT_PX, bodyTextSizePx.toFloat()) + consolidatedSharing.setTextSize(TypedValue.COMPLEX_UNIT_PX, bodyTextSizePx.toFloat()) + defaultHighlightColor.setTextSize(bodyTextSizePx, captionTextSizePx) + backupRestore.setTextSize(bodyTextSizePx) + backup.setTextSize(bodyTextSizePx, captionTextSizePx) + restore.setTextSize(bodyTextSizePx, captionTextSizePx) + about.setTextSize(bodyTextSizePx) + rate.setTextSize(bodyTextSizePx, captionTextSizePx) + website.setTextSize(bodyTextSizePx, captionTextSizePx) + version.setTextSize(bodyTextSizePx, captionTextSizePx) } - private fun dismissIndeterminateProgressDialog() { + private fun SettingsViewModel.ViewState.BackupRestoreState.handle(isBackup: Boolean) { indeterminateProgressDialog?.dismiss() indeterminateProgressDialog = null + + when (this) { + is SettingsViewModel.ViewState.BackupRestoreState.Idle -> { + // Do nothing + } + is SettingsViewModel.ViewState.BackupRestoreState.Ongoing -> { + indeterminateProgressDialog = indeterminateProgressDialog(R.string.dialog_title_wait) + } + is SettingsViewModel.ViewState.BackupRestoreState.Completed -> { + if (successful) { + toast(R.string.toast_backup_restore_succeeded) + } + if (isBackup) { + viewModel.markBackupStateAsIdle() + } else { + viewModel.markRestoreStateAsIdle() + } + } + } } - override fun inflateViewBinding(): ActivitySettingsBinding = ActivitySettingsBinding.inflate(layoutInflater) + private fun SettingsViewModel.ViewState.FontSizeScaleSelection.handle() { + seekBarDialog( + title = R.string.settings_title_font_size, + initialValue = currentScale, + minValue = minScale, + maxValue = maxScale, + onValueChanged = { value -> + viewBinding.fontSize.setDescription("${value}x") + setTextSize( + bodyTextSizePx = (currentBodyTextSizePx * value / currentScale).roundToInt(), + captionTextSizePx = (currentCaptionTextSizePx * value / currentScale).roundToInt(), + ) + }, + onPositive = viewModel::saveFontSizeScale, + onNegative = { + // resets to current value + viewBinding.fontSize.setDescription("${currentScale}x") + setTextSize(bodyTextSizePx = currentBodyTextSizePx, captionTextSizePx = currentCaptionTextSizePx) + }, + onDismiss = viewModel::markFontSizeSelectionAsDismissed + ) + } - override fun viewModel(): SettingsViewModel = settingsViewModel + private fun SettingsViewModel.ViewState.NightModeSelection.handle() { + listDialog( + title = R.string.settings_title_pick_night_mode, + items = availableModes.map { getString(it.stringRes) }.toTypedArray(), + selected = currentPosition, + onClicked = { dialog, which -> + viewModel.markNightModeSelectionAsDismissed() + viewModel.saveNightMode(availableModes[which].nightMode) + dialog.dismiss() + }, + onDismiss = { viewModel.markNightModeSelectionAsDismissed() } + ) + } + + private fun SettingsViewModel.ViewState.HighlightColorSelection.handle() { + listDialog( + title = R.string.text_pick_highlight_color, + items = availableColors.map { getString(it.stringRes) }.toTypedArray(), + selected = currentPosition, + onClicked = { dialog, which -> + viewModel.markHighlightColorSelectionAsDismissed() + viewModel.saveDefaultHighlightColor(availableColors[which].color) + dialog.dismiss() + }, + onDismiss = { viewModel.markHighlightColorSelectionAsDismissed() } + ) + } + + private fun SettingsViewModel.ViewState.Error.handle() = when (this) { + SettingsViewModel.ViewState.Error.BackupError, + SettingsViewModel.ViewState.Error.RestoreError, + SettingsViewModel.ViewState.Error.SettingsUpdatingError -> { + toast(R.string.toast_unknown_error) + viewModel.markErrorAsShown(this) + } + } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/settings/SettingsViewModel.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/settings/SettingsViewModel.kt index b0c7e779..38758429 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/settings/SettingsViewModel.kt @@ -18,16 +18,12 @@ package me.xizzhu.android.joshua.settings import android.app.Application import android.net.Uri +import androidx.annotation.Px import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatDelegate import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn +import java.io.IOException import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import me.xizzhu.android.joshua.R @@ -35,154 +31,348 @@ import me.xizzhu.android.joshua.core.BackupManager import me.xizzhu.android.joshua.core.Highlight import me.xizzhu.android.joshua.core.Settings import me.xizzhu.android.joshua.core.SettingsManager -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.ui.* -import me.xizzhu.android.logger.Log -import java.io.IOException import javax.inject.Inject +import kotlin.math.roundToInt +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider +import me.xizzhu.android.joshua.infra.BaseViewModelV2 +import me.xizzhu.android.joshua.ui.getPrimaryTextSize +import me.xizzhu.android.joshua.ui.getSecondaryTextSize +import me.xizzhu.android.joshua.utils.appVersionName +import me.xizzhu.android.logger.Log -data class SettingsViewData( - val currentFontSizeScale: Float, val bodyTextSizeInPixel: Float, val captionTextSizeInPixel: Float, - val keepScreenOn: Boolean, val nightMode: NightMode, val simpleReadingModeOn: Boolean, - val hideSearchButton: Boolean, val consolidateVersesForSharing: Boolean, - val defaultHighlightColor: HighlightColor, val version: String +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val backupManager: BackupManager, + private val settingsManager: SettingsManager, + private val application: Application, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider +) : BaseViewModelV2( + initialViewState = ViewState( + fontSizeScale = Settings.DEFAULT.fontSizeScale, + bodyTextSizePx = Settings.DEFAULT.getPrimaryTextSize(application.resources).roundToInt(), + captionTextSizePx = Settings.DEFAULT.getSecondaryTextSize(application.resources).roundToInt(), + keepScreenOn = Settings.DEFAULT.keepScreenOn, + nightModeStringRes = nightModeStringResource(Settings.DEFAULT.nightMode), + simpleReadingModeOn = Settings.DEFAULT.simpleReadingModeOn, + hideSearchButton = Settings.DEFAULT.hideSearchButton, + consolidateVersesForSharing = Settings.DEFAULT.consolidateVersesForSharing, + defaultHighlightColorStringRes = highlightColorStringResource(Settings.DEFAULT.defaultHighlightColor), + backupState = ViewState.BackupRestoreState.Idle, + restoreState = ViewState.BackupRestoreState.Idle, + version = "", + fontSizeScaleSelection = null, + nightModeSelection = null, + highlightColorSelection = null, + error = null, + ) ) { - enum class NightMode( - @AppCompatDelegate.NightMode val systemValue: Int, @Settings.Companion.NightMode val nightMode: Int, @StringRes val label: Int + sealed class ViewAction { + object OpenRateMe : ViewAction() + object OpenWebsite : ViewAction() + object RequestUriForBackup : ViewAction() + object RequestUriForRestore : ViewAction() + } + + data class ViewState( + val fontSizeScale: Float, + @Px val bodyTextSizePx: Int, + @Px val captionTextSizePx: Int, + val keepScreenOn: Boolean, + @StringRes val nightModeStringRes: Int, + val simpleReadingModeOn: Boolean, + val hideSearchButton: Boolean, + val consolidateVersesForSharing: Boolean, + @StringRes val defaultHighlightColorStringRes: Int, + val backupState: BackupRestoreState, + val restoreState: BackupRestoreState, + val version: String, + val fontSizeScaleSelection: FontSizeScaleSelection?, + val nightModeSelection: NightModeSelection?, + val highlightColorSelection: HighlightColorSelection?, + val error: Error?, ) { - ON(AppCompatDelegate.MODE_NIGHT_YES, Settings.NIGHT_MODE_ON, R.string.settings_text_night_mode_on), - OFF(AppCompatDelegate.MODE_NIGHT_NO, Settings.NIGHT_MODE_OFF, R.string.settings_text_night_mode_off), - SYSTEM(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, Settings.NIGHT_MODE_FOLLOW_SYSTEM, R.string.settings_text_night_mode_system); + sealed class BackupRestoreState { + object Idle : BackupRestoreState() + object Ongoing : BackupRestoreState() + data class Completed(val successful: Boolean) : BackupRestoreState() + } + + data class FontSizeScaleSelection( + @Px val currentBodyTextSizePx: Int, + @Px val currentCaptionTextSizePx: Int, + val currentScale: Float, + val minScale: Float, + val maxScale: Float, + ) - companion object { - fun fromNightMode(@Settings.Companion.NightMode nightMode: Int): NightMode = - values().firstOrNull { it.nightMode == nightMode } ?: SYSTEM + data class NightModeSelection(val currentPosition: Int, val availableModes: List) { + data class NightMode(@Settings.Companion.NightMode val nightMode: Int, @StringRes val stringRes: Int) } - } - enum class HighlightColor(@Highlight.Companion.AvailableColor val color: Int, @StringRes val label: Int) { - NONE(Highlight.COLOR_NONE, R.string.text_highlight_color_none), - YELLOW(Highlight.COLOR_YELLOW, R.string.text_highlight_color_yellow), - PINK(Highlight.COLOR_PINK, R.string.text_highlight_color_pink), - ORANGE(Highlight.COLOR_ORANGE, R.string.text_highlight_color_orange), - PURPLE(Highlight.COLOR_PURPLE, R.string.text_highlight_color_purple), - RED(Highlight.COLOR_RED, R.string.text_highlight_color_red), - GREEN(Highlight.COLOR_GREEN, R.string.text_highlight_color_green), - BLUE(Highlight.COLOR_BLUE, R.string.text_highlight_color_blue); + data class HighlightColorSelection(val currentPosition: Int, val availableColors: List) { + data class HighlightColor(@Highlight.Companion.AvailableColor val color: Int, @StringRes val stringRes: Int) + } - companion object { - fun fromHighlightColor(@Highlight.Companion.AvailableColor color: Int): HighlightColor = - values().first { it.color == color } + sealed class Error { + object BackupError : Error() + object RestoreError : Error() + object SettingsUpdatingError : Error() } } -} - -@HiltViewModel -class SettingsViewModel @Inject constructor( - private val backupManager: BackupManager, settingsManager: SettingsManager, application: Application -) : BaseViewModel(settingsManager, application) { - private val settingsViewData: MutableStateFlow> = MutableStateFlow(ViewData.Loading()) init { - val version = try { - application.packageManager.getPackageInfo(application.packageName, 0).versionName - } catch (e: Exception) { - Log.e(tag, "Failed to load app version", e) - "" - } + updateViewState { it.copy(version = runCatching { application.appVersionName }.getOrDefault("")) } - settings().onEach { settings -> - val resources = application.resources - settingsViewData.value = ViewData.Success(SettingsViewData( - currentFontSizeScale = settings.fontSizeScale, - bodyTextSizeInPixel = settings.getPrimaryTextSize(resources), - captionTextSizeInPixel = settings.getSecondaryTextSize(resources), + settingsManager.settings().onEach { settings -> + updateViewState { current -> + current.copy( + fontSizeScale = settings.fontSizeScale, + bodyTextSizePx = settings.getPrimaryTextSize(application.resources).roundToInt(), + captionTextSizePx = settings.getSecondaryTextSize(application.resources).roundToInt(), keepScreenOn = settings.keepScreenOn, - nightMode = SettingsViewData.NightMode.fromNightMode(settings.nightMode), + nightModeStringRes = nightModeStringResource(settings.nightMode), simpleReadingModeOn = settings.simpleReadingModeOn, hideSearchButton = settings.hideSearchButton, consolidateVersesForSharing = settings.consolidateVersesForSharing, - defaultHighlightColor = SettingsViewData.HighlightColor.fromHighlightColor(settings.defaultHighlightColor), - version = version - )) + defaultHighlightColorStringRes = highlightColorStringResource(settings.defaultHighlightColor), + ) + } }.launchIn(viewModelScope) } - fun settingsViewData(): Flow> = settingsViewData + fun saveFontSizeScale(fontSizeScale: Float) { + viewModelScope.launch { + updateViewState { it.copy(fontSizeScale = fontSizeScale, fontSizeScaleSelection = null) } - fun currentSettingsViewData(): SettingsViewData? = settingsViewData.value.let { if (it is ViewData.Success) it.data else null } + val current = settingsManager.settings().first() + runCatching { + if (current.fontSizeScale != fontSizeScale) { + settingsManager.saveSettings(current.copy(fontSizeScale = fontSizeScale)) + } + }.onFailure { e -> + Log.e(tag, "Failed to save settings", e) + updateViewState { it.copy(fontSizeScale = current.fontSizeScale, error = ViewState.Error.SettingsUpdatingError) } + } + } + } - fun saveFontSizeScale(fontSizeScale: Float): Flow> = updateSettings { it.copy(fontSizeScale = fontSizeScale) } + fun saveKeepScreenOn(keepScreenOn: Boolean) { + updateSettings { it.copy(keepScreenOn = keepScreenOn) } + } - private inline fun updateSettings(crossinline op: (current: Settings) -> Settings): Flow> = viewData { - val current = settings().first() - op(current).takeIf { it != current }?.let { settingsManager.saveSettings(it) } - }.onFailure { Log.e(tag, "Failed to save settings", it) } + private inline fun updateSettings(crossinline op: (current: Settings) -> Settings) { + viewModelScope.launch { + runCatching { + val current = settingsManager.settings().first() + op(current).takeIf { it != current }?.let { settingsManager.saveSettings(it) } + }.onFailure { e -> + Log.e(tag, "Failed to save settings", e) + updateViewState { it.copy(error = ViewState.Error.SettingsUpdatingError) } + } + } + } - fun saveKeepScreenOn(keepScreenOn: Boolean): Flow> = updateSettings { it.copy(keepScreenOn = keepScreenOn) } + fun saveNightMode(@Settings.Companion.NightMode nightMode: Int) { + viewModelScope.launch { + runCatching { + val current = settingsManager.settings().first() + if (current.nightMode != nightMode) { + settingsManager.saveSettings(current.copy(nightMode = nightMode)) + } - fun saveNightMode(nightMode: SettingsViewData.NightMode): Flow> = updateSettings { - it.copy(nightMode = nightMode.nightMode) - }.onSuccess { - // This will restart the activity, so do it AFTER the settings are successfully saved. - AppCompatDelegate.setDefaultNightMode(nightMode.systemValue) + // This will restart the activity, so do it AFTER the settings are successfully saved. + AppCompatDelegate.setDefaultNightMode( + when (nightMode) { + Settings.NIGHT_MODE_ON -> AppCompatDelegate.MODE_NIGHT_YES + Settings.NIGHT_MODE_OFF -> AppCompatDelegate.MODE_NIGHT_NO + Settings.NIGHT_MODE_FOLLOW_SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + else -> throw IllegalArgumentException("Unsupported night mode - $nightMode") + } + ) + }.onFailure { e -> + Log.e(tag, "Failed to save settings", e) + updateViewState { it.copy(error = ViewState.Error.SettingsUpdatingError) } + } + } } - fun saveSimpleReadingModeOn(simpleReadingModeOn: Boolean): Flow> = - updateSettings { it.copy(simpleReadingModeOn = simpleReadingModeOn) } + fun saveSimpleReadingModeOn(simpleReadingModeOn: Boolean) { + updateSettings { it.copy(simpleReadingModeOn = simpleReadingModeOn) } + } - fun saveHideSearchButton(hideSearchButton: Boolean): Flow> = updateSettings { it.copy(hideSearchButton = hideSearchButton) } + fun saveHideSearchButton(hideSearchButton: Boolean) { + updateSettings { it.copy(hideSearchButton = hideSearchButton) } + } - fun saveConsolidateVersesForSharing(consolidateVerses: Boolean): Flow> = - updateSettings { it.copy(consolidateVersesForSharing = consolidateVerses) } + fun saveConsolidateVersesForSharing(consolidateVersesForSharing: Boolean) { + updateSettings { it.copy(consolidateVersesForSharing = consolidateVersesForSharing) } + } - fun saveDefaultHighlightColor(highlightColor: SettingsViewData.HighlightColor): Flow> = - updateSettings { it.copy(defaultHighlightColor = highlightColor.color) } + fun saveDefaultHighlightColor(@Highlight.Companion.AvailableColor defaultHighlightColor: Int) { + updateSettings { it.copy(defaultHighlightColor = defaultHighlightColor) } + } - fun backup(uri: Uri?): Flow> = flow { + fun selectFontSizeScale() { + viewModelScope.launch { + val currentSettings = settingsManager.settings().first() + @Px val currentBodyTextSizePx = currentSettings.getPrimaryTextSize(application.resources).roundToInt() + @Px val currentCaptionTextSizePx = currentSettings.getSecondaryTextSize(application.resources).roundToInt() + val currentFontSizeScale = currentSettings.fontSizeScale + updateViewState { currentViewState -> + currentViewState.copy( + fontSizeScaleSelection = ViewState.FontSizeScaleSelection( + currentBodyTextSizePx = currentBodyTextSizePx, + currentCaptionTextSizePx = currentCaptionTextSizePx, + currentScale = currentFontSizeScale, + minScale = Settings.MIN_FONT_SIZE_SCALE, + maxScale = Settings.MAX_FONT_SIZE_SCALE, + ) + ) + } + } + } + + fun selectNightMode() { + viewModelScope.launch { + val currentNightMode = settingsManager.settings().first().nightMode + val availableModes = listOf( + ViewState.NightModeSelection.NightMode(Settings.NIGHT_MODE_ON, R.string.settings_text_night_mode_on), + ViewState.NightModeSelection.NightMode(Settings.NIGHT_MODE_OFF, R.string.settings_text_night_mode_off), + ViewState.NightModeSelection.NightMode(Settings.NIGHT_MODE_FOLLOW_SYSTEM, R.string.settings_text_night_mode_system), + ) + val currentPosition = availableModes.indexOfFirst { it.nightMode == currentNightMode } + updateViewState { it.copy(nightModeSelection = ViewState.NightModeSelection(currentPosition, availableModes)) } + } + } + + fun selectHighlightColor() { + viewModelScope.launch { + val currentDefaultHighlightColor = settingsManager.settings().first().defaultHighlightColor + val availableColors = listOf( + ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_NONE, R.string.text_highlight_color_none), + ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_YELLOW, R.string.text_highlight_color_yellow), + ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_PINK, R.string.text_highlight_color_pink), + ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_ORANGE, R.string.text_highlight_color_orange), + ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_PURPLE, R.string.text_highlight_color_purple), + ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_RED, R.string.text_highlight_color_red), + ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_GREEN, R.string.text_highlight_color_green), + ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_BLUE, R.string.text_highlight_color_blue), + ) + val currentPosition = availableColors.indexOfFirst { it.color == currentDefaultHighlightColor } + updateViewState { it.copy(highlightColorSelection = ViewState.HighlightColorSelection(currentPosition, availableColors)) } + } + } + + fun backup() { + emitViewAction(ViewAction.RequestUriForBackup) + } + + fun restore() { + emitViewAction(ViewAction.RequestUriForRestore) + } + + fun backup(uri: Uri?) { if (uri == null) { - emit(ViewData.Failure(IllegalArgumentException("Null URI"))) - return@flow + updateViewState { it.copy(error = ViewState.Error.BackupError) } + return } + updateViewState { it.copy(backupState = ViewState.BackupRestoreState.Ongoing) } - emit(ViewData.Loading()) - try { - application.contentResolver.openOutputStream(uri) + viewModelScope.launch(coroutineDispatcherProvider.io) { + runCatching { + application.contentResolver.openOutputStream(uri) ?.use { backupManager.backup(it) } ?: throw IOException("Failed to open Uri for backup - $uri") - emit(ViewData.Success(R.string.toast_backed_up)) - } catch (e: Exception) { - Log.e(tag, "Failed to backup data", e) - emit(ViewData.Failure(e)) + updateViewState { it.copy(backupState = ViewState.BackupRestoreState.Completed(successful = true)) } + }.onFailure { + Log.e(tag, "Failed to backup data", it) + updateViewState { current -> + current.copy( + backupState = ViewState.BackupRestoreState.Completed(successful = false), + error = ViewState.Error.BackupError, + ) + } + } } - }.flowOn(Dispatchers.IO) + } - fun restore(uri: Uri?): Flow> = flow { + fun restore(uri: Uri?) { if (uri == null) { - emit(ViewData.Failure(IllegalArgumentException("Null URI"))) - return@flow + updateViewState { it.copy(error = ViewState.Error.RestoreError) } + return } + updateViewState { it.copy(restoreState = ViewState.BackupRestoreState.Ongoing) } - emit(ViewData.Loading()) - try { - application.contentResolver.openInputStream(uri) + viewModelScope.launch(coroutineDispatcherProvider.io) { + runCatching { + application.contentResolver.openInputStream(uri) ?.use { backupManager.restore(it) } ?: throw IOException("Failed to open Uri for restore - $uri") - emit(ViewData.Success(R.string.toast_restored)) - } catch (t: Throwable) { - when (t) { - is Exception, is OutOfMemoryError -> { - // Catching OutOfMemoryError here, because there're cases when users try to - // open a huge file. - // See https://console.firebase.google.com/u/0/project/joshua-production/crashlytics/app/android:me.xizzhu.android.joshua/issues/e9339c69d6e1856856db88413614d3d3 - Log.e(tag, "Failed to restore data", t) - emit(ViewData.Failure(t)) + updateViewState { it.copy(restoreState = ViewState.BackupRestoreState.Completed(successful = true)) } + }.onFailure { + Log.e(tag, "Failed to restore data", it) + updateViewState { current -> + current.copy( + restoreState = ViewState.BackupRestoreState.Completed(successful = false), + error = ViewState.Error.RestoreError, + ) } - else -> throw t } } - }.flowOn(Dispatchers.IO) + } + + fun openRateMe() { + emitViewAction(ViewAction.OpenRateMe) + } + + fun openWebsite() { + emitViewAction(ViewAction.OpenWebsite) + } + + fun markBackupStateAsIdle() { + updateViewState { it.copy(backupState = ViewState.BackupRestoreState.Idle) } + } + + fun markRestoreStateAsIdle() { + updateViewState { it.copy(restoreState = ViewState.BackupRestoreState.Idle) } + } + + fun markFontSizeSelectionAsDismissed() { + updateViewState { it.copy(fontSizeScaleSelection = null) } + } + + fun markNightModeSelectionAsDismissed() { + updateViewState { it.copy(nightModeSelection = null) } + } + + fun markHighlightColorSelectionAsDismissed() { + updateViewState { it.copy(highlightColorSelection = null) } + } + + fun markErrorAsShown(error: ViewState.Error) { + updateViewState { current -> if (current.error == error) current.copy(error = null) else null } + } +} + +@StringRes +private fun nightModeStringResource(@Settings.Companion.NightMode nightMode: Int): Int = when (nightMode) { + Settings.NIGHT_MODE_ON -> R.string.settings_text_night_mode_on + Settings.NIGHT_MODE_OFF -> R.string.settings_text_night_mode_off + Settings.NIGHT_MODE_FOLLOW_SYSTEM -> R.string.settings_text_night_mode_system + else -> throw IllegalArgumentException("Unknown night mode - $nightMode") +} + +@StringRes +private fun highlightColorStringResource(@Highlight.Companion.AvailableColor highlightColor: Int): Int = when (highlightColor) { + Highlight.COLOR_NONE -> R.string.text_highlight_color_none + Highlight.COLOR_YELLOW -> R.string.text_highlight_color_yellow + Highlight.COLOR_PINK -> R.string.text_highlight_color_pink + Highlight.COLOR_ORANGE -> R.string.text_highlight_color_orange + Highlight.COLOR_PURPLE -> R.string.text_highlight_color_purple + Highlight.COLOR_RED -> R.string.text_highlight_color_red + Highlight.COLOR_GREEN -> R.string.text_highlight_color_green + Highlight.COLOR_BLUE -> R.string.text_highlight_color_blue + else -> throw IllegalArgumentException("Unknown highlight color - $highlightColor") } 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 15af5e88..d82571f9 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 @@ -122,7 +122,8 @@ fun Activity.seekBarDialog( maxValue: Float, onValueChanged: (Float) -> Unit, onPositive: ((Float) -> Unit)? = null, - onNegative: (() -> Unit)? = null + onNegative: (() -> Unit)? = null, + onDismiss: (() -> Unit)? = null, ): AlertDialog? { if (isDestroyed) return null @@ -143,6 +144,7 @@ fun Activity.seekBarDialog( .setView(viewBinding.root) .setPositiveButton(android.R.string.ok, onPositive?.let { { _, _ -> it(viewBinding.seekBar.calculateValue(minValue, maxValue)) } }) .setNegativeButton(android.R.string.cancel, onNegative?.let { { _, _ -> it() } }) + .setOnDismissListener { onDismiss?.invoke() } .show() } @@ -196,11 +198,18 @@ fun Activity.listDialog( .show() } -fun Activity.listDialog(@StringRes title: Int, items: Array, selected: Int, onClicked: DialogInterface.OnClickListener) { +fun Activity.listDialog( + @StringRes title: Int, + items: Array, + selected: Int, + onClicked: DialogInterface.OnClickListener, + onDismiss: DialogInterface.OnDismissListener? = null, +) { if (isDestroyed) return MaterialAlertDialogBuilder(this) .setCancelable(true) + .setOnDismissListener(onDismiss) .setSingleChoiceItems(items, selected, onClicked) .setTitle(title) .show() diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/ui/View.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/ui/View.kt index 99da64c7..0dd8904a 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/ui/View.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/ui/View.kt @@ -22,10 +22,12 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.res.TypedArray +import android.os.SystemClock import android.text.TextUtils import android.util.TypedValue import android.view.View import android.view.inputmethod.InputMethodManager +import android.widget.CompoundButton import android.widget.TextView import androidx.annotation.StyleableRes import androidx.viewpager2.widget.ViewPager2 @@ -62,12 +64,12 @@ fun View.fadeOut() { alpha = 1.0F animate().alpha(0.0F).setDuration(ANIMATION_DURATION) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - visibility = View.GONE - alpha = 1.0F - } - }) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + visibility = View.GONE + alpha = 1.0F + } + }) } fun View.setBackground(resId: Int) { @@ -79,7 +81,28 @@ fun View.setBackground(resId: Int) { fun View.hideKeyboard() { if (hasFocus()) { (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) - .hideSoftInputFromWindow(windowToken, InputMethodManager.HIDE_NOT_ALWAYS) + .hideSoftInputFromWindow(windowToken, InputMethodManager.HIDE_NOT_ALWAYS) + } +} + +fun View.setOnSingleClickListener(listener: () -> Unit) { + setOnClickListener(object : View.OnClickListener { + private var lastClicked = 0L + override fun onClick(v: View) { + val now = SystemClock.elapsedRealtime() + if (now - lastClicked > 500L) { + lastClicked = now + listener() + } + } + }) +} + +fun CompoundButton.setOnCheckedChangeByUserListener(listener: (isChecked: Boolean) -> Unit) { + setOnCheckedChangeListener { button, isChecked -> + if (button.isPressed) { + listener(isChecked) + } } } @@ -98,9 +121,9 @@ fun TextView.setText(a: TypedArray, @StyleableRes index: Int) { fun ViewPager2.makeLessSensitive() { try { val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView") - .apply { isAccessible = true } + .apply { isAccessible = true } val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop") - .apply { isAccessible = true } + .apply { isAccessible = true } val recyclerView = recyclerViewField.get(this) val touchSlop = touchSlopField.get(recyclerView) as Int diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/utils/Context.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/utils/Context.kt index d1b43220..002e8349 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/utils/Context.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/utils/Context.kt @@ -28,10 +28,17 @@ import androidx.annotation.VisibleForTesting val Context.application: Application get() = applicationContext as Application +val Context.appVersionName: String + get() = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + application.packageManager.getPackageInfo(application.packageName, 0).versionName + } else { + application.packageManager.getPackageInfo(application.packageName, PackageManager.PackageInfoFlags.of(0)).versionName + } + // On older devices, this only works on the threads with loopers. fun Context.copyToClipBoard(label: CharSequence, text: CharSequence) { (getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager) - .setPrimaryClip(ClipData.newPlainText(label, text)) + .setPrimaryClip(ClipData.newPlainText(label, text)) } fun Activity.shareToSystem(title: String, text: String) { @@ -63,9 +70,9 @@ fun PackageManager.chooserForSharing(packageToExclude: String, title: String, te if (packageToExclude != packageName) { val labeledIntent = LabeledIntent(packageName, resolveInfo.loadLabel(this), resolveInfo.iconResource) labeledIntent.setAction(Intent.ACTION_SEND).setPackage(packageName) - .setComponent(ComponentName(packageName, resolveInfo.activityInfo.name)) - .setType("text/plain") - .putExtra(Intent.EXTRA_TEXT, text) + .setComponent(ComponentName(packageName, resolveInfo.activityInfo.name)) + .setType("text/plain") + .putExtra(Intent.EXTRA_TEXT, text) filteredIntents.add(labeledIntent) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3929a8fe..a4f84b50 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,8 +23,7 @@ Translation deleted Copied to clipboard. %1$d result(s) found. - Successfully backed-up. - Successfully restored. + Successfully finished. Unknown error More diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/settings/SettingsViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/settings/SettingsViewModelTest.kt index b0d7f07d..dcff0040 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/settings/SettingsViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/settings/SettingsViewModelTest.kt @@ -18,32 +18,42 @@ package me.xizzhu.android.joshua.settings import android.app.Application import android.content.ContentResolver +import android.content.pm.PackageInfo +import android.content.pm.PackageManager import android.content.res.Resources import android.net.Uri +import androidx.appcompat.app.AppCompatDelegate import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest -import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.BackupManager -import me.xizzhu.android.joshua.core.Highlight -import me.xizzhu.android.joshua.core.Settings import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.infra.BaseViewModel import me.xizzhu.android.joshua.tests.BaseUnitTest import java.io.InputStream import java.io.OutputStream +import kotlin.math.roundToInt import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flowOf +import me.xizzhu.android.joshua.R +import me.xizzhu.android.joshua.core.Highlight +import me.xizzhu.android.joshua.core.Settings +import me.xizzhu.android.joshua.ui.getPrimaryTextSize +import me.xizzhu.android.joshua.ui.getSecondaryTextSize +import me.xizzhu.android.joshua.utils.application +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class SettingsViewModelTest : BaseUnitTest() { private lateinit var uri: Uri private lateinit var inputStream: InputStream @@ -52,7 +62,7 @@ class SettingsViewModelTest : BaseUnitTest() { private lateinit var backupManager: BackupManager private lateinit var settingsManager: SettingsManager private lateinit var resources: Resources - private lateinit var application: Application + private lateinit var app: Application private lateinit var settingsViewModel: SettingsViewModel @BeforeTest @@ -72,235 +82,471 @@ class SettingsViewModelTest : BaseUnitTest() { } settingsManager = mockk() every { settingsManager.settings() } returns emptyFlow() - resources = mockk().apply { every { getDimension(any()) } returns 12.0F } - application = mockk().apply { + resources = mockk().apply { + every { getDimension(R.dimen.text_primary) } returns 18.0F + every { getDimension(R.dimen.text_secondary) } returns 16.0F + } + app = mockk().apply { + every { application } returns this every { contentResolver } returns resolver every { resources } returns this@SettingsViewModelTest.resources } - settingsViewModel = SettingsViewModel(backupManager, settingsManager, application) + settingsViewModel = SettingsViewModel(backupManager, settingsManager, app, testCoroutineDispatcherProvider) } @Test - fun `test settingsViewData`() = runTest { + fun `test viewState(), from constructor`() = runTest { + val packageInfo = PackageInfo().apply { versionName = "version_name" } + val packageManager = mockk().apply { + every { getPackageInfo(any(), any()) } returns packageInfo + every { getPackageInfo(any(), any()) } returns packageInfo + } + every { app.packageManager } returns packageManager + every { app.packageName } returns "package_name" + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) - settingsViewModel = SettingsViewModel(backupManager, settingsManager, application) + settingsViewModel = SettingsViewModel(backupManager, settingsManager, app, testCoroutineDispatcherProvider) - assertEquals( - SettingsViewData( - currentFontSizeScale = 1.0F, - bodyTextSizeInPixel = 12.0F, - captionTextSizeInPixel = 12.0F, - keepScreenOn = true, - nightMode = SettingsViewData.NightMode.OFF, - simpleReadingModeOn = false, - hideSearchButton = false, - consolidateVersesForSharing = false, - defaultHighlightColor = SettingsViewData.HighlightColor.NONE, - version = "" - ), - (settingsViewModel.settingsViewData().take(1).first() as BaseViewModel.ViewData.Success).data - ) + assertEquals(createDefaultViewState().copy(version = "version_name"), settingsViewModel.viewState().first()) } + private fun createDefaultViewState(): SettingsViewModel.ViewState = SettingsViewModel.ViewState( + fontSizeScale = Settings.DEFAULT.fontSizeScale, + bodyTextSizePx = Settings.DEFAULT.getPrimaryTextSize(app.resources).roundToInt(), + captionTextSizePx = Settings.DEFAULT.getSecondaryTextSize(app.resources).roundToInt(), + keepScreenOn = Settings.DEFAULT.keepScreenOn, + nightModeStringRes = R.string.settings_text_night_mode_off, + simpleReadingModeOn = Settings.DEFAULT.simpleReadingModeOn, + hideSearchButton = Settings.DEFAULT.hideSearchButton, + consolidateVersesForSharing = Settings.DEFAULT.consolidateVersesForSharing, + defaultHighlightColorStringRes = R.string.text_highlight_color_none, + backupState = SettingsViewModel.ViewState.BackupRestoreState.Idle, + restoreState = SettingsViewModel.ViewState.BackupRestoreState.Idle, + version = "", + fontSizeScaleSelection = null, + nightModeSelection = null, + highlightColorSelection = null, + error = null, + ) + @Test - fun `test saveFontSizeScale`() = runTest { + fun `test saveFontSizeScale()`() = runTest { every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) - coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(fontSizeScale = 7.0F)) } returns Unit + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(fontSizeScale = 2.0F)) } returns Unit - with(settingsViewModel.saveFontSizeScale(Settings.DEFAULT.fontSizeScale).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } - with(settingsViewModel.saveFontSizeScale(7.0F).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } - coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(fontSizeScale = 7.0F)) } + settingsViewModel.saveFontSizeScale(1.0F) + settingsViewModel.saveFontSizeScale(2.0F) + + coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(fontSizeScale = 2.0F)) } + assertEquals(createDefaultViewState().copy(fontSizeScale = 2.0F), settingsViewModel.viewState().first()) + + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(fontSizeScale = 3.0F)) } throws RuntimeException("random exception") + + settingsViewModel.saveFontSizeScale(3.0F) + + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.SettingsUpdatingError), + settingsViewModel.viewState().first() + ) } @Test - fun `test saveKeepScreenOn`() = runTest { + fun `test saveKeepScreenOn()`() = runTest { every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(keepScreenOn = false)) } returns Unit - with(settingsViewModel.saveKeepScreenOn(Settings.DEFAULT.keepScreenOn).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } - with(settingsViewModel.saveKeepScreenOn(false).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } + settingsViewModel.saveKeepScreenOn(keepScreenOn = true) + settingsViewModel.saveKeepScreenOn(keepScreenOn = false) + coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(keepScreenOn = false)) } + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(keepScreenOn = false)) } throws RuntimeException("random exception") + + settingsViewModel.saveKeepScreenOn(keepScreenOn = false) + + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.SettingsUpdatingError), + settingsViewModel.viewState().first() + ) } @Test - fun `test saveNightMode`() = runTest { + fun `test saveNightMode()`() = runTest { + mockkStatic(AppCompatDelegate::class) every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) - coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(nightMode = SettingsViewData.NightMode.ON.nightMode)) } returns Unit + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(nightMode = Settings.NIGHT_MODE_ON)) } returns Unit - with(settingsViewModel.saveNightMode(SettingsViewData.NightMode.fromNightMode(Settings.DEFAULT.nightMode)).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } - with(settingsViewModel.saveNightMode(SettingsViewData.NightMode.ON).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } - coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(nightMode = SettingsViewData.NightMode.ON.nightMode)) } + settingsViewModel.saveNightMode(nightMode = Settings.NIGHT_MODE_OFF) + settingsViewModel.saveNightMode(nightMode = Settings.NIGHT_MODE_ON) + + coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(nightMode = Settings.NIGHT_MODE_ON)) } + verify(exactly = 1) { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) } + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(nightMode = Settings.NIGHT_MODE_FOLLOW_SYSTEM)) } returns Unit + settingsViewModel.saveNightMode(nightMode = Settings.NIGHT_MODE_FOLLOW_SYSTEM) + + coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(nightMode = Settings.NIGHT_MODE_FOLLOW_SYSTEM)) } + verify(exactly = 1) { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) } + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(nightMode = -1)) } returns Unit + settingsViewModel.saveNightMode(nightMode = -1) + + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.SettingsUpdatingError), + settingsViewModel.viewState().first() + ) } @Test - fun `test saveSimpleReadingModeOn`() = runTest { + fun `test saveSimpleReadingModeOn()`() = runTest { every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(simpleReadingModeOn = true)) } returns Unit - with(settingsViewModel.saveSimpleReadingModeOn(Settings.DEFAULT.simpleReadingModeOn).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } - with(settingsViewModel.saveSimpleReadingModeOn(true).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } + settingsViewModel.saveSimpleReadingModeOn(simpleReadingModeOn = false) + settingsViewModel.saveSimpleReadingModeOn(simpleReadingModeOn = true) + coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(simpleReadingModeOn = true)) } + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(simpleReadingModeOn = true)) } throws RuntimeException("random exception") + + settingsViewModel.saveSimpleReadingModeOn(simpleReadingModeOn = true) + + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.SettingsUpdatingError), + settingsViewModel.viewState().first() + ) } @Test - fun `test saveHideSearchButton`() = runTest { + fun `test saveHideSearchButton()`() = runTest { every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(hideSearchButton = true)) } returns Unit - with(settingsViewModel.saveHideSearchButton(Settings.DEFAULT.hideSearchButton).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } - with(settingsViewModel.saveHideSearchButton(true).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } + settingsViewModel.saveHideSearchButton(hideSearchButton = false) + settingsViewModel.saveHideSearchButton(hideSearchButton = true) + coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(hideSearchButton = true)) } + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(hideSearchButton = true)) } throws RuntimeException("random exception") + + settingsViewModel.saveHideSearchButton(hideSearchButton = true) + + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.SettingsUpdatingError), + settingsViewModel.viewState().first() + ) } @Test - fun `test saveConsolidateVersesForSharing`() = runTest { + fun `test saveConsolidateVersesForSharing()`() = runTest { every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(consolidateVersesForSharing = true)) } returns Unit - with(settingsViewModel.saveConsolidateVersesForSharing(Settings.DEFAULT.consolidateVersesForSharing).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } - with(settingsViewModel.saveConsolidateVersesForSharing(true).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } + settingsViewModel.saveConsolidateVersesForSharing(consolidateVersesForSharing = false) + settingsViewModel.saveConsolidateVersesForSharing(consolidateVersesForSharing = true) + coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(consolidateVersesForSharing = true)) } + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(consolidateVersesForSharing = true)) } throws RuntimeException("random exception") + + settingsViewModel.saveConsolidateVersesForSharing(consolidateVersesForSharing = true) + + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.SettingsUpdatingError), + settingsViewModel.viewState().first() + ) } @Test - fun `test saveDefaultHighlightColor`() = runTest { + fun `test saveDefaultHighlightColor()`() = runTest { every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(defaultHighlightColor = Highlight.COLOR_BLUE)) } returns Unit - with(settingsViewModel.saveDefaultHighlightColor(SettingsViewData.HighlightColor.fromHighlightColor(Settings.DEFAULT.defaultHighlightColor)).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } - with(settingsViewModel.saveDefaultHighlightColor(SettingsViewData.HighlightColor.BLUE).toList()) { - assertEquals(2, size) - assertTrue(this[0] is BaseViewModel.ViewData.Loading) - assertTrue(this[1] is BaseViewModel.ViewData.Success) - } + settingsViewModel.saveDefaultHighlightColor(defaultHighlightColor = Highlight.COLOR_NONE) + settingsViewModel.saveDefaultHighlightColor(defaultHighlightColor = Highlight.COLOR_BLUE) + coVerify(exactly = 1) { settingsManager.saveSettings(Settings.DEFAULT.copy(defaultHighlightColor = Highlight.COLOR_BLUE)) } + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + + coEvery { settingsManager.saveSettings(Settings.DEFAULT.copy(defaultHighlightColor = Highlight.COLOR_BLUE)) } throws RuntimeException("random exception") + + settingsViewModel.saveDefaultHighlightColor(defaultHighlightColor = Highlight.COLOR_BLUE) + + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.SettingsUpdatingError), + settingsViewModel.viewState().first() + ) + } + + @Test + fun `test selectFontSizeScale()`() = runTest { + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) + + settingsViewModel.selectFontSizeScale() + assertEquals( + createDefaultViewState().copy( + fontSizeScaleSelection = SettingsViewModel.ViewState.FontSizeScaleSelection( + currentBodyTextSizePx = 18, + currentCaptionTextSizePx = 16, + currentScale = 1.0F, + minScale = 0.5F, + maxScale = 3.0F, + ) + ), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markFontSizeSelectionAsDismissed() + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + } + + @Test + fun `test selectNightMode()`() = runTest { + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) + + settingsViewModel.selectNightMode() + assertEquals( + createDefaultViewState().copy( + nightModeSelection = SettingsViewModel.ViewState.NightModeSelection( + currentPosition = 1, + availableModes = listOf( + SettingsViewModel.ViewState.NightModeSelection.NightMode(Settings.NIGHT_MODE_ON, R.string.settings_text_night_mode_on), + SettingsViewModel.ViewState.NightModeSelection.NightMode(Settings.NIGHT_MODE_OFF, R.string.settings_text_night_mode_off), + SettingsViewModel.ViewState.NightModeSelection.NightMode(Settings.NIGHT_MODE_FOLLOW_SYSTEM, R.string.settings_text_night_mode_system), + ), + ) + ), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markNightModeSelectionAsDismissed() + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + } + + @Test + fun `test selectHighlightColor()`() = runTest { + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) + + settingsViewModel.selectHighlightColor() + assertEquals( + createDefaultViewState().copy( + highlightColorSelection = SettingsViewModel.ViewState.HighlightColorSelection( + currentPosition = 0, + availableColors = listOf( + SettingsViewModel.ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_NONE, R.string.text_highlight_color_none), + SettingsViewModel.ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_YELLOW, R.string.text_highlight_color_yellow), + SettingsViewModel.ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_PINK, R.string.text_highlight_color_pink), + SettingsViewModel.ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_ORANGE, R.string.text_highlight_color_orange), + SettingsViewModel.ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_PURPLE, R.string.text_highlight_color_purple), + SettingsViewModel.ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_RED, R.string.text_highlight_color_red), + SettingsViewModel.ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_GREEN, R.string.text_highlight_color_green), + SettingsViewModel.ViewState.HighlightColorSelection.HighlightColor(Highlight.COLOR_BLUE, R.string.text_highlight_color_blue), + ), + ) + ), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markHighlightColorSelectionAsDismissed() + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + } + + @Test + fun `test backup()`() = runTest { + val viewAction = async(Dispatchers.Unconfined) { settingsViewModel.viewAction().first() } + + settingsViewModel.backup() + + assertEquals(SettingsViewModel.ViewAction.RequestUriForBackup, viewAction.await()) + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + } + + @Test + fun `test backup(), with null uri`() = runTest { + settingsViewModel.backup(null) + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.BackupError), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markErrorAsShown(SettingsViewModel.ViewState.Error.BackupError) + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + } + + @Test + fun `test backup(), with exception`() = runTest { + every { app.contentResolver } throws RuntimeException("random exception") + + settingsViewModel.backup(Uri.EMPTY) + assertEquals( + createDefaultViewState().copy( + backupState = SettingsViewModel.ViewState.BackupRestoreState.Completed(successful = false), + error = SettingsViewModel.ViewState.Error.BackupError + ), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markBackupStateAsIdle() + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.BackupError), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markErrorAsShown(SettingsViewModel.ViewState.Error.RestoreError) + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.BackupError), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markErrorAsShown(SettingsViewModel.ViewState.Error.BackupError) + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) + } + + @Test + fun `test backup(), with openOutputStream returning null`() = runTest { + every { resolver.openOutputStream(Uri.EMPTY) } returns null + + settingsViewModel.backup(Uri.EMPTY) + assertEquals( + createDefaultViewState().copy( + backupState = SettingsViewModel.ViewState.BackupRestoreState.Completed(successful = false), + error = SettingsViewModel.ViewState.Error.BackupError + ), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markBackupStateAsIdle() + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.BackupError), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markErrorAsShown(SettingsViewModel.ViewState.Error.BackupError) + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) } @Test - fun `test backup with null uri`() = runTest { - val actual = settingsViewModel.backup(null).toList() - assertEquals(1, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Failure) + fun `test backup(), success`() = runTest { + coEvery { backupManager.backup(outputStream) } returns Unit + + settingsViewModel.backup(uri) + assertEquals( + createDefaultViewState().copy(backupState = SettingsViewModel.ViewState.BackupRestoreState.Completed(successful = true)), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markBackupStateAsIdle() + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) } @Test - fun `test backup with error`() = runTest { - val ex = RuntimeException("Random") - every { application.contentResolver } throws ex - - val actual = settingsViewModel.backup(mockk()).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertEquals(ex, (actual[1] as BaseViewModel.ViewData.Failure).throwable) + fun `test restore()`() = runTest { + val viewAction = async(Dispatchers.Unconfined) { settingsViewModel.viewAction().first() } + + settingsViewModel.restore() + assertEquals(SettingsViewModel.ViewAction.RequestUriForRestore, viewAction.await()) + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) } @Test - fun `test backup with error from interactor`() = runTest { - val ex = RuntimeException("Random") - coEvery { backupManager.backup(outputStream) } throws ex - - val actual = settingsViewModel.backup(uri).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertEquals(ex, (actual[1] as BaseViewModel.ViewData.Failure).throwable) + fun `test restore(), with null uri`() = runTest { + settingsViewModel.restore(null) + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.RestoreError), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markErrorAsShown(SettingsViewModel.ViewState.Error.RestoreError) + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) } @Test - fun `test backup`() = runTest { - val actual = settingsViewModel.backup(uri).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertEquals(R.string.toast_backed_up, (actual[1] as BaseViewModel.ViewData.Success).data) + fun `test restore(), with exception`() = runTest { + every { app.contentResolver } throws RuntimeException("random exception") + + settingsViewModel.restore(Uri.EMPTY) + assertEquals( + createDefaultViewState().copy( + restoreState = SettingsViewModel.ViewState.BackupRestoreState.Completed(successful = false), + error = SettingsViewModel.ViewState.Error.RestoreError + ), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markRestoreStateAsIdle() + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.RestoreError), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markErrorAsShown(SettingsViewModel.ViewState.Error.BackupError) + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.RestoreError), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markErrorAsShown(SettingsViewModel.ViewState.Error.RestoreError) + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) } @Test - fun `test restore with null uri`() = runTest { - val actual = settingsViewModel.restore(null).toList() - assertEquals(1, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Failure) + fun `test restore(), with openInputStream returning null`() = runTest { + every { resolver.openInputStream(Uri.EMPTY) } returns null + + settingsViewModel.restore(Uri.EMPTY) + assertEquals( + createDefaultViewState().copy( + restoreState = SettingsViewModel.ViewState.BackupRestoreState.Completed(successful = false), + error = SettingsViewModel.ViewState.Error.RestoreError + ), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markRestoreStateAsIdle() + assertEquals( + createDefaultViewState().copy(error = SettingsViewModel.ViewState.Error.RestoreError), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markErrorAsShown(SettingsViewModel.ViewState.Error.RestoreError) + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) } @Test - fun `test restore with error`() = runTest { - val ex = RuntimeException("Random") - every { application.contentResolver } throws ex - - val actual = settingsViewModel.restore(mockk()).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertEquals(ex, (actual[1] as BaseViewModel.ViewData.Failure).throwable) + fun `test restore(), success`() = runTest { + coEvery { backupManager.restore(inputStream) } returns Unit + + settingsViewModel.restore(uri) + assertEquals( + createDefaultViewState().copy(restoreState = SettingsViewModel.ViewState.BackupRestoreState.Completed(successful = true)), + settingsViewModel.viewState().first() + ) + + settingsViewModel.markRestoreStateAsIdle() + assertEquals(createDefaultViewState(), settingsViewModel.viewState().first()) } @Test - fun `test restore with error from interactor`() = runTest { - val ex = RuntimeException("Random") - coEvery { backupManager.restore(inputStream) } throws ex - - val actual = settingsViewModel.restore(uri).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertEquals(ex, (actual[1] as BaseViewModel.ViewData.Failure).throwable) + fun `test openRateMe()`() = runTest { + val viewAction = async(Dispatchers.Unconfined) { settingsViewModel.viewAction().first() } + + settingsViewModel.openRateMe() + assertEquals(SettingsViewModel.ViewAction.OpenRateMe, viewAction.await()) } @Test - fun `test restore`() = runTest { - val actual = settingsViewModel.restore(uri).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertEquals(R.string.toast_restored, (actual[1] as BaseViewModel.ViewData.Success).data) + fun `test openWebsite()`() = runTest { + val viewAction = async(Dispatchers.Unconfined) { settingsViewModel.viewAction().first() } + + settingsViewModel.openWebsite() + assertEquals(SettingsViewModel.ViewAction.OpenWebsite, viewAction.await()) } } From cd2d5752acab0f8df48ac2c0e5af44e8a93a2b9f Mon Sep 17 00:00:00 2001 From: Xizhi Zhu Date: Mon, 26 Dec 2022 07:47:37 +0900 Subject: [PATCH 2/2] Refactor listDialog to accept custom callback --- .../annotated/AnnotatedVerseActivity.kt | 2 +- .../android/joshua/reading/ReadingActivity.kt | 5 +--- .../android/joshua/search/SearchActivity.kt | 2 +- .../joshua/settings/SettingsActivity.kt | 16 ++++--------- .../strongnumber/StrongNumberActivity.kt | 2 +- .../me/xizzhu/android/joshua/ui/Dialog.kt | 23 +++++++++++-------- 6 files changed, 21 insertions(+), 29 deletions(-) diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVerseActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVerseActivity.kt index 83045eed..6f513d9b 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVerseActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVerseActivity.kt @@ -88,7 +88,7 @@ abstract class AnnotatedVerseActivity() title = R.string.text_pick_highlight_color, items = resources.getStringArray(R.array.text_highlight_colors), selected = max(0, Highlight.AVAILABLE_COLORS.indexOf(currentHighlightColor)), - onClicked = { dialog, which -> - saveHighlight(verseIndex, Highlight.AVAILABLE_COLORS[which]) - dialog.dismiss() - } + onSelected = { which -> saveHighlight(verseIndex, Highlight.AVAILABLE_COLORS[which]) } ) } else { saveHighlight(verseIndex, if (currentHighlightColor == Highlight.COLOR_NONE) defaultHighlightColor else Highlight.COLOR_NONE) 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 24057f99..2651391b 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 @@ -142,7 +142,7 @@ class SearchActivity : BaseActivityV2 - viewModel.markNightModeSelectionAsDismissed() - viewModel.saveNightMode(availableModes[which].nightMode) - dialog.dismiss() - }, - onDismiss = { viewModel.markNightModeSelectionAsDismissed() } + onSelected = { which -> viewModel.saveNightMode(availableModes[which].nightMode) }, + onDismiss = viewModel::markNightModeSelectionAsDismissed ) } @@ -194,12 +190,8 @@ class SettingsActivity : BaseActivityV2 - viewModel.markHighlightColorSelectionAsDismissed() - viewModel.saveDefaultHighlightColor(availableColors[which].color) - dialog.dismiss() - }, - onDismiss = { viewModel.markHighlightColorSelectionAsDismissed() } + onSelected = { which -> viewModel.saveDefaultHighlightColor(availableColors[which].color) }, + onDismiss = viewModel::markHighlightColorSelectionAsDismissed ) } 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 6f99fd3f..f67a58b9 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 @@ -93,7 +93,7 @@ class StrongNumberActivity : BaseActivityV2, scrollToPosition: Int, - onDismiss: DialogInterface.OnDismissListener? = null + onDismiss: (() -> Unit)? = null, ): AlertDialog? { if (isDestroyed) return null @@ -169,12 +169,12 @@ fun Activity.listDialog( scrollToPosition(scrollToPosition) } } - return MaterialAlertDialogBuilder(this) + val builder = MaterialAlertDialogBuilder(this) .setCancelable(true) .setTitle(title) .setView(recyclerView) - .setOnDismissListener(onDismiss) - .show() + onDismiss?.let { builder.setOnDismissListener { onDismiss() } } + return builder.show() } fun Activity.listDialog( @@ -202,15 +202,18 @@ fun Activity.listDialog( @StringRes title: Int, items: Array, selected: Int, - onClicked: DialogInterface.OnClickListener, - onDismiss: DialogInterface.OnDismissListener? = null, + onSelected: (which: Int) -> Unit, + onDismiss: (() -> Unit)? = null, ) { if (isDestroyed) return - MaterialAlertDialogBuilder(this) + val builder = MaterialAlertDialogBuilder(this) .setCancelable(true) - .setOnDismissListener(onDismiss) - .setSingleChoiceItems(items, selected, onClicked) + .setSingleChoiceItems(items, selected) { dialog, which -> + dialog.dismiss() + onSelected(which) + } .setTitle(title) - .show() + onDismiss?.let { builder.setOnDismissListener { onDismiss() } } + builder.show() }