Skip to content

Commit

Permalink
MBL-1077: Coroutines in ChangePasswordViewModel + Tests (#1923)
Browse files Browse the repository at this point in the history
* - ChangePasswordViewModel is now using Coroutines and fully tested

* - linter

* - Adopted UIStates types of architecture

* - Stable versions, rather than release candidate

---------

Co-authored-by: mtgriego <matthew.t.griego@gmail.com>
  • Loading branch information
Arkariang and mtgriego committed Jan 10, 2024
1 parent 69c7220 commit 8045fef
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 222 deletions.
9 changes: 8 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ android {
useLegacyPackaging = true
}
resources {
excludes += ['META-INF/LICENSE.txt', 'LICENSE.txt', 'META-INF/AL2.0', 'META-INF/LGPL2.1']
excludes += ['META-INF/LICENSE.txt', 'LICENSE.txt', 'META-INF/AL2.0', 'META-INF/LGPL2.1', 'DebugProbesKt.bin']
}
}

Expand Down Expand Up @@ -288,6 +288,13 @@ dependencies {
implementation 'androidx.activity:activity-compose:1.7.2'
implementation 'androidx.constraintlayout:constraintlayout-compose:1.0.1'
implementation "com.google.accompanist:accompanist-systemuicontroller:0.30.1"
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.2"

// Coroutines
def coroutines = '1.7.3'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$coroutines"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines"

// Zoom on Images
implementation 'io.github.aghajari:ZoomHelper:1.1.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,28 @@ import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rxjava2.subscribeAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.kickstarter.libs.Logout
import com.kickstarter.libs.featureflag.FlagKey
import com.kickstarter.libs.rx.transformers.Transformers
import com.kickstarter.libs.utils.ApplicationUtils
import com.kickstarter.libs.utils.extensions.addToDisposable
import com.kickstarter.libs.utils.extensions.getEnvironment
import com.kickstarter.ui.IntentKey
import com.kickstarter.ui.SharedPreferenceKey
import com.kickstarter.ui.activities.compose.ChangePasswordScreen
import com.kickstarter.ui.compose.designsystem.KickstarterApp
import com.kickstarter.ui.data.LoginReason
import com.kickstarter.viewmodels.ChangePasswordViewModel
import com.kickstarter.viewmodels.ChangePasswordViewModelFactory
import io.reactivex.disposables.CompositeDisposable

class ChangePasswordActivity : ComponentActivity() {

private var logout: Logout? = null
private lateinit var disposables: CompositeDisposable
private var theme = AppThemes.MATCH_SYSTEM.ordinal
private lateinit var viewModelFactory: ChangePasswordViewModel.Factory
private val viewModel: ChangePasswordViewModel.ChangePasswordViewModel by viewModels {
private lateinit var viewModelFactory: ChangePasswordViewModelFactory
private val viewModel: ChangePasswordViewModel by viewModels {
viewModelFactory
}

Expand All @@ -39,20 +39,22 @@ class ChangePasswordActivity : ComponentActivity() {
var darkModeEnabled = false

this.getEnvironment()?.let { env ->
viewModelFactory = ChangePasswordViewModel.Factory(env)
viewModelFactory = ChangePasswordViewModelFactory(env)

darkModeEnabled = env.featureFlagClient()?.getBoolean(FlagKey.ANDROID_DARK_MODE_ENABLED) ?: false
theme = env.sharedPreferences()
?.getInt(SharedPreferenceKey.APP_THEME, AppThemes.MATCH_SYSTEM.ordinal)
?: AppThemes.MATCH_SYSTEM.ordinal
}

setContent {
var showProgressBar =
viewModel.outputs.progressBarIsVisible().subscribeAsState(initial = false).value
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

var error = viewModel.outputs.error().subscribeAsState(initial = "").value
val showProgressBar = uiState.isLoading
val error = uiState.errorMessage
val email = uiState.email

var scaffoldState = rememberScaffoldState()
val scaffoldState = rememberScaffoldState()

KickstarterApp(
useDarkTheme =
Expand All @@ -68,30 +70,26 @@ class ChangePasswordActivity : ComponentActivity() {
ChangePasswordScreen(
onBackClicked = { onBackPressedDispatcher.onBackPressed() },
onAcceptButtonClicked = { current, new ->
viewModel.updatePasswordData(current, new)
viewModel.inputs.changePasswordClicked()
viewModel.updatePassword(current, new)
},
showProgressBar = showProgressBar,
scaffoldState = scaffoldState
)
}

when {
error.isNotEmpty() -> {
LaunchedEffect(scaffoldState) {
scaffoldState.snackbarHostState.showSnackbar(error)
viewModel.resetError()
}
error?.let {
LaunchedEffect(scaffoldState) {
scaffoldState.snackbarHostState.showSnackbar(it)
viewModel.resetError()
}
}
}

this.logout = getEnvironment()?.logout()
this.logout = getEnvironment()?.logout()

this.viewModel.outputs.success()
.compose(Transformers.observeForUIV2())
.subscribe { logout(it) }
.addToDisposable(disposables)
email?.let {
if (it.isNotEmpty()) logout(it)
}
}
}

private fun logout(email: String) {
Expand Down
206 changes: 55 additions & 151 deletions app/src/main/java/com/kickstarter/viewmodels/ChangePasswordViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,165 +1,69 @@
package com.kickstarter.viewmodels

import UpdateUserPasswordMutation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.kickstarter.libs.Environment
import com.kickstarter.libs.rx.transformers.Transformers.errorsV2
import com.kickstarter.libs.rx.transformers.Transformers.takeWhenV2
import com.kickstarter.libs.rx.transformers.Transformers.valuesV2
import com.kickstarter.libs.utils.extensions.addToDisposable
import com.kickstarter.libs.utils.extensions.newPasswordValidationWarnings
import com.kickstarter.libs.utils.extensions.validPassword
import io.reactivex.Observable
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.subjects.BehaviorSubject
import io.reactivex.subjects.PublishSubject

interface ChangePasswordViewModel {

interface Inputs {
/** Call when the user clicks the change password button. */
fun changePasswordClicked()

/** Call when the current password field changes. */
fun confirmPassword(confirmPassword: String)

/** Call when the current password field changes. */
fun currentPassword(currentPassword: String)

/** Call when the new password field changes. */
fun newPassword(newPassword: String)
}

interface Outputs {
/** Emits when the password update was unsuccessful. */
fun error(): Observable<String>

/** Emits when the progress bar should be visible. */
fun progressBarIsVisible(): Observable<Boolean>

/** Emits when the password update was successful. */
fun success(): Observable<String>
}

class ChangePasswordViewModel(val environment: Environment) : ViewModel(), Inputs, Outputs {

private val changePasswordClicked = PublishSubject.create<Unit>()
private val confirmPassword = PublishSubject.create<String>()
private val currentPassword = PublishSubject.create<String>()
private val newPassword = PublishSubject.create<String>()

private val error = BehaviorSubject.create<String>()
private val progressBarIsVisible = BehaviorSubject.create<Boolean>()
private val success = BehaviorSubject.create<String>()

val inputs: Inputs = this
val outputs: Outputs = this

private val apolloClient = requireNotNull(this.environment.apolloClientV2())
private val analytics = this.environment.analytics()

private val disposables = CompositeDisposable()

init {

val changePassword = Observable.combineLatest(
this.currentPassword.startWith(""),
this.newPassword.startWith(""),
this.confirmPassword.startWith("")
) { current, new, confirm -> ChangePassword(current, new, confirm) }

val changePasswordNotification = changePassword
.compose(takeWhenV2(this.changePasswordClicked))
.switchMap { cp -> submit(cp).materialize() }
.share()

changePasswordNotification
.compose(errorsV2())
.subscribe { error ->
error?.localizedMessage?.let { message ->
this.error.onNext(message)
}
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx2.asFlow

data class UpdatePasswordUIState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val email: String? = null
)
class ChangePasswordViewModel(val environment: Environment) : ViewModel() {

private val apolloClient = requireNotNull(this.environment.apolloClientV2())
private val analytics = requireNotNull(this.environment.analytics())

private val mutableUIState = MutableStateFlow(UpdatePasswordUIState())
val uiState: StateFlow<UpdatePasswordUIState> get() =
mutableUIState.asStateFlow()
.stateIn(
scope = viewModelScope,
started = WhileSubscribed(),
initialValue = UpdatePasswordUIState(isLoading = true)
)

fun updatePassword(oldPassword: String, newPassword: String) {
viewModelScope.launch {
// TODO: Avoid using GraphQL generated types such as UpdateUserPasswordMutation.Data, return data model defined within the app.
apolloClient.updateUserPassword(oldPassword, newPassword, newPassword)
.asFlow()
.onStart {
mutableUIState.emit(UpdatePasswordUIState(isLoading = true))
}
.addToDisposable(disposables)

changePasswordNotification
.compose(valuesV2())
.map { it.updateUserAccount()?.user()?.email() }
.subscribe { email ->
this.analytics?.reset()
email?.let {
this.success.onNext(it)
}
.map {
analytics.reset()
mutableUIState.emit(UpdatePasswordUIState(isLoading = false, email = it.updateUserAccount()?.user()?.email() ?: ""))
}
.addToDisposable(disposables)
}

private fun submit(changePassword: ChangePassword): Observable<UpdateUserPasswordMutation.Data> {
return this.apolloClient.updateUserPassword(changePassword.currentPassword, changePassword.newPassword, changePassword.confirmPassword)
.doOnSubscribe { this.progressBarIsVisible.onNext(true) }
.doAfterTerminate { this.progressBarIsVisible.onNext(false) }
}

fun updatePasswordData(oldPassword: String, newPassword: String) {
this.currentPassword.onNext(oldPassword)
this.newPassword.onNext(newPassword)
this.confirmPassword.onNext(newPassword)
}

fun resetError() {
this.error.onNext("")
}

override fun changePasswordClicked() {
this.changePasswordClicked.onNext(Unit)
}

override fun confirmPassword(confirmPassword: String) {
this.confirmPassword.onNext(confirmPassword)
}

override fun currentPassword(currentPassword: String) {
this.currentPassword.onNext(currentPassword)
}

override fun newPassword(newPassword: String) {
this.newPassword.onNext(newPassword)
}

override fun error(): Observable<String> {
return this.error
}

override fun progressBarIsVisible(): Observable<Boolean> {
return this.progressBarIsVisible
}

override fun success(): Observable<String> {
return this.success
}

data class ChangePassword(val currentPassword: String, val newPassword: String, val confirmPassword: String) {
fun isValid(): Boolean {
return this.currentPassword.validPassword() &&
this.newPassword.validPassword() &&
this.confirmPassword.validPassword() &&
this.confirmPassword == this.newPassword
}

fun warning(): Int =
newPassword.newPasswordValidationWarnings(confirmPassword) ?: 0
.catch {
mutableUIState.emit(UpdatePasswordUIState(errorMessage = it.message ?: "", isLoading = false))
}
.collect()
}
}

override fun onCleared() {
disposables.clear()
super.onCleared()
fun resetError() {
viewModelScope.launch {
mutableUIState.emit(UpdatePasswordUIState(errorMessage = null))
}
}
}

class Factory(private val environment: Environment) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ChangePasswordViewModel(environment) as T
}
class ChangePasswordViewModelFactory(private val environment: Environment) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ChangePasswordViewModel(environment) as T
}
}
Loading

0 comments on commit 8045fef

Please sign in to comment.