dataclassViewState(valisLoading:Boolean=false,valisError:Boolean=false,valisEmailVisible:Boolean=false,valuserInfo:UserInfo?=null){dataclassUserInfo(valdisplayEmail:String,valavatarUrl:String?,valshowPremiumBadge:Boolean,valmemberSince:String?)sealedclassIntents{dataobjectRefresh:Intents()dataclassToggleEmailVisibility(valisEmailVisible:Boolean):Intents()}sealedclassStateParameters{dataclassEmailVisibilityChanged(valisEmailVisible:Boolean):StateParameters()dataobjectRefresh:StateParameters()}}privatevalrefreshListener=MutableSharedFlow<ViewState.StateParameters>()valuserDetails:Flow<ViewState>=flow{emit(getUserDetailsState())refreshListener.collect{refreshParams->when(refreshParams){isViewState.StateParameters.EmailVisibilityChanged->{//do some changes here}ViewState.StateParameters.Refresh->{emit(ViewState(isLoading=true,isError=false))emit(getUserDetailsState())}}}}.distinctUntilChanged().stateIn(viewModelScope,SharingStarted.WhileSubscribed(5_000),ViewState(isLoading=true,isError=false))
internalclassUserAccountDetailsViewModelprivateconstructor(privatevalgetUserDetailsUseCase:GetUserDetailsUseCase=GetUserDetailsUseCase.create(),):ViewModel(),IntentAware<UserAccountDetailsViewModel.ViewState.Intents>{dataclassViewState(valisLoading:Boolean=false,valisError:Boolean=false,valisEmailVisible:Boolean=false,valuserInfo:UserInfo?=null){valisDataLoadedget()=userInfo!=nulldataclassUserInfo(valdisplayEmail:String,valavatarUrl:String?,valshowPremiumBadge:Boolean,valmemberSince:String?)sealedclassIntents{dataobjectRefresh:Intents()dataclassToggleEmailVisibility(valisEmailVisible:Boolean):Intents()}sealedclassStateTriggers{dataclassEmailVisibilityChanged(valisEmailVisible:Boolean):StateTriggers()dataobjectRefresh:StateTriggers()}}privatevarcurrentState=ViewState(isLoading=true,isError=false)privatevalrefreshListener=MutableSharedFlow<ViewState.StateTriggers>()valuserDetails:Flow<ViewState>=flow{emit(currentState)//i added error check just because this is for demonstration of this edge caseif(currentState.isDataLoaded.not()||currentState.isError){emit(getUserDetailsState())}refreshListener.collect{refreshParams->when(refreshParams){isViewState.StateTriggers.EmailVisibilityChanged->{emit(currentState.copy(isEmailVisible=refreshParams.isEmailVisible))}ViewState.StateTriggers.Refresh->{emit(ViewState(isLoading=true,isError=false))emit(getUserDetailsState())}}}}.distinctUntilChanged().onEach{currentState=it}.stateIn(viewModelScope,SharingStarted.WhileSubscribed(5_000),currentState)privatesuspendfungetUserDetailsState():ViewState=getUserDetailsUseCase.execute().fold(onSuccess={ViewState(isLoading=false,isError=false,userInfo=ViewState.UserInfo(displayEmail=it.email,avatarUrl=it.avatarUrl,showPremiumBadge=it.isPremium,memberSince=it.creationDate?.toString()))},onFailure={ViewState(isLoading=false,isError=true)})overridefunonIntent(intent:ViewState.Intents){when(intent){ViewState.Intents.Refresh->{viewModelScope.launch{refreshListener.emit(ViewState.StateTriggers.Refresh)}}isViewState.Intents.ToggleEmailVisibility->{viewModelScope.launch{refreshListener.emit(ViewState.StateTriggers.EmailVisibilityChanged(intent.isEmailVisible))}}}}companionobject{funfactory()=provideFactory{UserAccountDetailsViewModel()}}}
fun<T,R>ViewModel.loadData(initialState:T,loadData:suspendFlowCollector<T>.(currentState:T)->Unit,refreshMechanism:SharedFlow<R>?=null,timeout:Long=5_000,refreshData:(suspendFlowCollector<T>.(currentState:T,refreshParams:R)->Unit)?=null,):StateFlow<T>{if(refreshMechanism!=null){requireNotNull(refreshData){"You've provided a refresh mechanism but no way to refresh the data"}}if(refreshData!=null){requireNotNull(refreshMechanism){"You've provided a refresh data but no mechanism to refresh the data"}}varlatestValue=initialStatereturnflow{emit(latestValue)loadData(latestValue)refreshMechanism?.collect{refreshParams->if(refreshData!=null){refreshData(latestValue,refreshParams)}}}.distinctUntilChanged().onEach{latestValue=it}.stateIn(scope=viewModelScope,started=SharingStarted.WhileSubscribed(timeout),initialValue=initialState)}fun<T>ViewModel.loadData(initialState:T,loadData:suspendFlowCollector<T>.(currentState:T)->Unit,timeout:Long=5_000,):StateFlow<T>{varlatestValue=initialStatereturnflow{emit(latestValue)loadData(latestValue)}.onEach{latestValue=it}.distinctUntilChanged().stateIn(scope=viewModelScope,started=SharingStarted.WhileSubscribed(timeout),initialValue=initialState)}
abstractclassViewModelLoader<State:Any,Intent:Any,Trigger:Any>:ViewModel(){privateval_triggerbylazy{MutableSharedFlow<Trigger>()}fun<T>loadData(initialState:T,loadData:suspendFlowCollector<T>.(currentState:T)->Unit,triggerData:(suspendFlowCollector<T>.(currentState:T,triggerParams:Trigger)->Unit)?=null,timeout:Long=5000L,//matching ANR timeout in Android):StateFlow<T>{varlatestValue=initialStatereturnflow{emit(latestValue)loadData(latestValue)if(triggerData!=null){_trigger.collect{triggerParams->triggerData(this,latestValue,triggerParams)}}}.distinctUntilChanged().onEach{latestValue=it}.stateIn(scope=viewModelScope,started=SharingStarted.WhileSubscribed(timeout),initialValue=initialState)}abstractvalstate:StateFlow<State>valcurrentStateget()=state.valueopenfunonIntent(intent:Intent){}protectedfunsendTrigger(trigger:Trigger){viewModelScope.launch{_trigger.emit(trigger)}}}
@ParameterizedTest@ValueSource(booleans=[true,false])fun`shouldhandlebothsuccessanderrorscenarios`(shouldSucceed:Boolean)=runTest{// Givenif(shouldSucceed){fakeGetUserDetailsUseCase.setSuccessResponse(UserDetails(email="success@example.com",avatarUrl="https://avatar.url",isPremium=true,creationDate="2023-01-01"))}else{fakeGetUserDetailsUseCase.setErrorResponse()}// WhenviewModel.state.test{advanceUntilIdle()// Then Focus on behavior, not implementation detailsif(shouldSucceed){awaitItem()// Loading statevalsuccessState=awaitItem()assertThat(successState.isLoading).isFalse()assertThat(successState.isError).isFalse()assertThat(successState.userInfo?.displayEmail).isEqualTo("success@example.com")assertThat(successState.userInfo?.showPremiumBadge).isTrue()}else{awaitItem()// Loading statevalerrorState=awaitItem()assertThat(errorState.isLoading).isFalse()assertThat(errorState.isError).isTrue()assertThat(errorState.userInfo).isNull()}}}@Testfun`shouldrefreshdatawhenrefreshintentistriggered`()=runTest{// Given Initial successful loadfakeGetUserDetailsUseCase.setSuccessResponse(UserDetails(email="initial@example.com",avatarUrl=null,isPremium=false,creationDate="2022-01-01"))viewModel.state.test{advanceUntilIdle()awaitItem()// LoadingvalinitialState=awaitItem()// SuccessassertThat(initialState.userInfo?.displayEmail).isEqualTo("initial@example.com")// Change response and trigger refreshfakeGetUserDetailsUseCase.setSuccessResponse(UserDetails(email="refreshed@example.com",avatarUrl="https://new-avatar.url",isPremium=true,creationDate="2023-01-01"))viewModel.onIntent(ViewState.Intents.Refresh)advanceUntilIdle()// ThenawaitItem()// Loading during refreshvalrefreshedState=awaitItem()// New SuccessassertThat(refreshedState.isLoading).isFalse()assertThat(refreshedState.userInfo?.displayEmail).isEqualTo("refreshed@example.com")assertThat(refreshedState.userInfo?.showPremiumBadge).isTrue()// Verify both initial load and refresh were calledassertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(2)}}
@Testfun`shouldusecacheddatawhenreturningtoscreenquickly`()=runTest{// GivenfakeGetUserDetailsUseCase.setSuccessResponse(UserDetails(email="cached@example.com",avatarUrl=null,isPremium=true,creationDate="2023-01-01"))// When First collectionviewModel.state.test{advanceUntilIdle()awaitItem()// LoadingvalfirstState=awaitItem()// SuccessassertThat(firstState.userInfo?.displayEmail).isEqualTo("cached@example.com")cancel()// Simulate leaving screen}// When Quick return (simulating navigation back within timeout)viewModel.state.test{advanceUntilIdle()// Then Should have cached data immediately (no Loadin)valcachedState=awaitItem()assertThat(cachedState.isLoading).isFalse()assertThat(cachedState.userInfo?.displayEmail).isEqualTo("cached@example.com")expectNoEvents()}assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(1)}
@Testfun`shouldrecoverfromErroronsuccessfulrefresh`()=runTest{// Given Initial errorfakeGetUserDetailsUseCase.setErrorResponse()viewModel.state.test{advanceUntilIdle()awaitItem()// LoadingvalerrorState=awaitItem()// ErrorassertThat(errorState.isError).isTrue()// When Fix the response and refreshfakeGetUserDetailsUseCase.setSuccessResponse(UserDetails(email="recovered@example.com",avatarUrl=null,isPremium=false,creationDate="2023-01-01"))viewModel.onIntent(ViewState.Intents.Refresh)advanceUntilIdle()// Then Should recover successfullyawaitItem()// Loading during refreshvalrecoveredState=awaitItem()// SuccessassertThat(recoveredState.isError).isFalse()assertThat(recoveredState.userInfo?.displayEmail).isEqualTo("recovered@example.com")assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(2)}}