From 91a04088d6ecdf72f6bd692715d43096fd47d17a Mon Sep 17 00:00:00 2001 From: Bliss Pisit Wetcha Date: Fri, 17 Feb 2023 11:19:25 +0700 Subject: [PATCH 1/5] [#41] Migrate states to viewmodel --- .../SurveyDetailView+DataSource.swift | 46 +++++++--- .../SurveyDetail/SurveyDetailView.swift | 30 ++----- .../SurveyQuestion/QuestionEmojiView.swift | 4 +- .../QuestionMultiChoiceView.swift | 4 +- .../QuestionMultiFormView.swift | 6 +- .../SurveyQuestion/QuestionPickerView.swift | 4 +- .../QuestionRangePickerView.swift | 4 +- .../SurveyQuestion/QuestionTextAreaView.swift | 5 +- .../SurveyQuestion/SurveyQuestionView.swift | 9 +- .../di/koin/modules/ViewModelModule.kt | 2 +- .../model/SurveySubmissionUiModel.kt | 28 ++++++ .../surveydetail/SurveyDetailViewModel.kt | 89 ++++++++++++++++++- 12 files changed, 179 insertions(+), 52 deletions(-) create mode 100644 shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/model/SurveySubmissionUiModel.kt diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView+DataSource.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView+DataSource.swift index 44f3a601..aa927bba 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView+DataSource.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView+DataSource.swift @@ -22,6 +22,9 @@ extension SurveyDetailView { @Published var isLoading = false @Published var isShowingTitle = true @Published var isShowingTitleNavigationBar = true + @Published var questionIndex = 0 + @Published var isShowingSuccess = false + @Published var isShowingSubmit = false private var cancellables = Set() @@ -33,29 +36,48 @@ extension SurveyDetailView { viewModel.setSurveyId(id: id) viewModel.getDetail() - createPublisher(for: viewModel.viewStateNative) - .catch { error -> Just in - let surveyDetailViewState = SurveyDetailViewState( - error: error.localizedDescription - ) - return Just(surveyDetailViewState) - } + let viewState = createGuaranteedPublisher( + for: viewModel.viewStateNative, + fallback: SurveyDetailViewState() + ) + let questionViewState = createGuaranteedPublisher( + for: viewModel.questionViewStateNative, + fallback: SurveyQuestionViewState() + ) + viewState + .combineLatest(questionViewState) .receive(on: DispatchQueue.main) - .sink { [weak self] value in + .sink { [weak self] viewState, questionViewState in guard let self else { return } - self.updateStates(value) + self.updateStates(viewState, questionViewState) } .store(in: &cancellables) } - func didPressNext() { + func didPressStart() { viewModel.showQuestion() } - private func updateStates(_ state: SurveyDetailViewState) { + func didPressNext() { + viewModel.addAnswer(value: SurveySubmissionUiModel(id: "", answers: [])) + } + + func didPressSubmit() { + viewModel.submitAnswer() + } + + private func updateStates( + _ state: SurveyDetailViewState, + _ questionState: SurveyQuestionViewState + ) { viewState = state isShowingErrorAlert = !state.error.string.isEmpty - isLoading = state.isLoading && state.isShowingQuestion + isLoading = (state.isLoading || questionState.isLoading) && (state.isShowingQuestion) + isShowingSuccess = questionState.isShowingSuccess + withAnimation(.easeIn(duration: .viewTransition)) { + isShowingSubmit = questionState.isShowingSubmit + questionIndex = Int(questionState.currentQuestionIndex) + } if state.surveyDetail != nil, state.isShowingQuestion { isShowingTitleNavigationBar = false DispatchQueue.main.asyncAfter(deadline: .now() + .instant) { diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView.swift index 76684388..4bea5acb 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView.swift @@ -23,11 +23,9 @@ struct SurveyDetailView: View { @StateObject var dataSource: DataSource @State var isAnimating = true - @State var questionIndex = 0 - // TODO: Replace with real answer object - @State var currentAnswers = [String]() @State var isShowingQuitPrompt = false @State var isShowingSuccessConfirmation = false + @State var currentAnswers = [SurveyAnswer]() var body: some View { ZStack { @@ -87,7 +85,7 @@ struct SurveyDetailView: View { .transition(.move(edge: .leading).combined(with: .opacity)) } else if let surveyDetail = dataSource.viewState.surveyDetail { animatedSurveyQuestionView(surveyDetail: surveyDetail) - .id(questionIndex) + .id(dataSource.questionIndex) } else { Spacer() } @@ -117,8 +115,7 @@ struct SurveyDetailView: View { Spacer() if dataSource.isShowingTitle { startButton - } else if (dataSource.viewState.surveyDetail?.questions.count ?? 0) == questionIndex + 1 { - // TODO: Use ViewModel's State + } else if dataSource.isShowingSubmit { submitButton } else { nextButton @@ -128,7 +125,7 @@ struct SurveyDetailView: View { var startButton: some View { Button { - dataSource.didPressNext() + dataSource.didPressStart() } label: { Text(String.localizeId.survey_detail_start_button()) .primaryButton() @@ -139,13 +136,7 @@ struct SurveyDetailView: View { var nextButton: some View { Button { - // TODO: Submit button logics - let totalItem = (dataSource.viewState.surveyDetail?.questions.count ?? 0) - 1 - guard questionIndex < totalItem else { return } - currentAnswers = [] - withAnimation(.easeIn(duration: .viewTransition)) { - questionIndex += 1 - } + dataSource.didPressNext() } label: { Assets.nextButton .image @@ -158,14 +149,7 @@ struct SurveyDetailView: View { var submitButton: some View { Button { - // TODO: Submit action - dataSource.isLoading = true - DispatchQueue.main.asyncAfter(deadline: .now()) { - withAnimation(.easeInViewTransition) { - dataSource.isLoading = false - isShowingSuccessConfirmation = true - } - } + dataSource.didPressSubmit() } label: { Text(String.localizeId.survey_submit_button()) .primaryButton() @@ -216,7 +200,7 @@ struct SurveyDetailView: View { private func animatedSurveyQuestionView(surveyDetail: SurveyDetailUiModel) -> some View { return SurveyQuestionView( detail: surveyDetail, - questionIndex: $questionIndex, + questionIndex: $dataSource.questionIndex, answers: $currentAnswers ) .transition( diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionEmojiView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionEmojiView.swift index 6edfd604..bc239afe 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionEmojiView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionEmojiView.swift @@ -34,7 +34,7 @@ struct QuestionEmojiView: View { } @State var rating: Int = 0 - @Binding var answers: [String] + @Binding var answers: [SurveyAnswer] let type: EmojiType let options: [Answer] @@ -56,7 +56,7 @@ struct QuestionEmojiView: View { Spacer() } .onChange(of: rating) { - answers = [options[$0].id] + answers = [.init(id: options[$0].id, answer: nil)] } } diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionMultiChoiceView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionMultiChoiceView.swift index 34506895..5bbe88e0 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionMultiChoiceView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionMultiChoiceView.swift @@ -13,7 +13,7 @@ struct QuestionMultiChoiceView: View { let options: [Answer] @State var inputs = Set() - @Binding var answers: [String] + @Binding var answers: [SurveyAnswer] var body: some View { VStack { @@ -56,6 +56,6 @@ struct QuestionMultiChoiceView: View { } else { inputs.insert(id) } - answers = Array(inputs) + answers = inputs.map { .init(id: $0, answer: nil) } } } diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionMultiFormView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionMultiFormView.swift index c39c1fae..054e5254 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionMultiFormView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionMultiFormView.swift @@ -17,7 +17,7 @@ struct QuestionMultiFormView: View { } @State var multiFormAnswers = [MultiFormAnswer]() - @Binding var answers: [String] + @Binding var answers: [SurveyAnswer] var body: some View { VStack(spacing: .lineSpacing) { @@ -33,11 +33,11 @@ struct QuestionMultiFormView: View { } } .onChange(of: multiFormAnswers) { - answers = $0.map { $0.input } + answers = $0.map { .init(id: $0.question.id, answer: $0.input) } } } - init(answers: [Answer], currentAnswers: Binding<[String]>) { + init(answers: [Answer], currentAnswers: Binding<[SurveyAnswer]>) { _multiFormAnswers = .init( initialValue: answers.map { MultiFormAnswer(question: $0) diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionPickerView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionPickerView.swift index 51497908..101405fb 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionPickerView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionPickerView.swift @@ -11,7 +11,7 @@ import SwiftUI struct QuestionPickerView: View { @State private var selectedId = "" - @Binding var answers: [String] + @Binding var answers: [SurveyAnswer] let options: [Answer] @@ -31,7 +31,7 @@ struct QuestionPickerView: View { } .pickerStyle(.wheel) .onChange(of: selectedId) { - answers = [$0] + answers = [.init(id: $0, answer: nil)] } } } diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionRangePickerView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionRangePickerView.swift index 62bab1c3..7da3224c 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionRangePickerView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionRangePickerView.swift @@ -11,7 +11,7 @@ import SwiftUI struct QuestionRangePickerView: View { @State var selectedId: String = "" - @Binding var answers: [String] + @Binding var answers: [SurveyAnswer] let options: [Answer] let helpText: String @@ -59,7 +59,7 @@ struct QuestionRangePickerView: View { } } .onChange(of: selectedId) { - answers = [$0] + answers = [.init(id: $0, answer: nil)] } } diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionTextAreaView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionTextAreaView.swift index be6f5384..eedd3ee3 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionTextAreaView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionTextAreaView.swift @@ -10,10 +10,11 @@ import SwiftUI struct QuestionTextAreaView: View { + let id: String let placeholder: String? @State var text = "" - @Binding var answers: [String] + @Binding var answers: [SurveyAnswer] var body: some View { ZStack(alignment: .topLeading) { @@ -34,7 +35,7 @@ struct QuestionTextAreaView: View { } } .onChange(of: text) { - answers = [$0] + answers = [.init(id: id, answer: $0)] } .onAppear { UITextView.appearance().backgroundColor = .clear diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/SurveyQuestionView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/SurveyQuestionView.swift index 0d292a55..36f64a14 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/SurveyQuestionView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/SurveyQuestionView.swift @@ -10,13 +10,14 @@ import Shared import SwiftUI typealias Answer = SurveyDetailUiModel.SurveyAnswer +typealias SurveyAnswer = SurveySubmissionUiModel.Answer struct SurveyQuestionView: View { let detail: SurveyDetailUiModel @Binding var questionIndex: Int - @Binding var answers: [String] + @Binding var answers: [SurveyAnswer] var body: some View { VStack(alignment: .leading) { @@ -72,7 +73,11 @@ struct SurveyQuestionView: View { case .textfield: QuestionMultiFormView(answers: question.answers, currentAnswers: $answers) case .textarea: - QuestionTextAreaView(placeholder: question.answers.first?.text, answers: $answers) + QuestionTextAreaView( + id: question.id, + placeholder: question.answers.first?.text, + answers: $answers + ) default: VStack {} } } diff --git a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/di/koin/modules/ViewModelModule.kt b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/di/koin/modules/ViewModelModule.kt index 46d4d75a..0d66195a 100644 --- a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/di/koin/modules/ViewModelModule.kt +++ b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/di/koin/modules/ViewModelModule.kt @@ -14,5 +14,5 @@ val viewModelModule = module { factoryOf(::ResetPasswordViewModel) factoryOf(::SplashViewModel) factoryOf(::SurveySelectionViewModel) - factory { SurveyDetailViewModel(get()) } + factory { SurveyDetailViewModel(get(), get()) } } diff --git a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/model/SurveySubmissionUiModel.kt b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/model/SurveySubmissionUiModel.kt new file mode 100644 index 00000000..ce397c0e --- /dev/null +++ b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/model/SurveySubmissionUiModel.kt @@ -0,0 +1,28 @@ +package co.nimblehq.blisskmmic.presentation.model + +import co.nimblehq.blisskmmic.domain.model.SurveySubmission + +data class SurveySubmissionUiModel( + val id: String, + val answers: List +) { + + data class Answer( + val id: String, + val answer: String? = null + ) +} + +fun SurveySubmissionUiModel.Answer.toSurveySubmission() = SurveySubmission.Answer( + id, + answer +) + +fun List.toSurveySubmission(surveyId: String) = SurveySubmission( + surveyId, + map { model -> + SurveySubmission.Question( + model.id, + model.answers.map { it.toSurveySubmission() }) + } +) diff --git a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt index a3e5be60..56187a4e 100644 --- a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt +++ b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt @@ -3,8 +3,11 @@ package co.nimblehq.blisskmmic.presentation.modules.surveydetail import co.nimblehq.blisskmmic.data.network.helpers.toErrorMessage import co.nimblehq.blisskmmic.domain.model.SurveyDetail import co.nimblehq.blisskmmic.domain.usecase.GetSurveyDetailUseCase +import co.nimblehq.blisskmmic.domain.usecase.SubmitSurveyUseCase import co.nimblehq.blisskmmic.presentation.model.SurveyDetailUiModel +import co.nimblehq.blisskmmic.presentation.model.SurveySubmissionUiModel import co.nimblehq.blisskmmic.presentation.model.toSurveyDetailUiModel +import co.nimblehq.blisskmmic.presentation.model.toSurveySubmission import co.nimblehq.blisskmmic.presentation.modules.BaseViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,15 +25,29 @@ data class SurveyDetailViewState( constructor(error: String?) : this(null, false, false, error) } +data class SurveyQuestionViewState( + val isShowingSubmit: Boolean = false, + val isLoading: Boolean = false, + val currentQuestionIndex: Int = 0, + val isShowingSuccess: Boolean = false +) { + constructor() : this(currentQuestionIndex = 0) +} + class SurveyDetailViewModel( private val getSurveyDetailUseCase: GetSurveyDetailUseCase, - private var surveyId: String? = null + private val submitSurveyUseCase: SubmitSurveyUseCase, + private var surveyId: String? = null, + private var answers: MutableList = mutableListOf() ): BaseViewModel() { private val mutableViewState: MutableStateFlow = MutableStateFlow(SurveyDetailViewState()) + private val questionMutableViewState: MutableStateFlow = + MutableStateFlow(SurveyQuestionViewState()) val viewState: StateFlow = mutableViewState + val questionViewState: StateFlow = questionMutableViewState fun setSurveyId(id: String) { surveyId = id @@ -48,6 +65,7 @@ class SurveyDetailViewModel( } fun showQuestion() { + answers = mutableListOf() val currentState = viewState.value mutableViewState.update { SurveyDetailViewState( @@ -62,6 +80,37 @@ class SurveyDetailViewModel( } } + fun addAnswer(value: SurveySubmissionUiModel?) { + value?.let { answers.add(it) } + setNextQuestionState() + } + + fun submitAnswer() { + val currentState = questionViewState.value + if(!currentState.isLoading) { + performSubmitAnswer() + } + val newState = SurveyQuestionViewState( + currentState.isShowingSubmit, + true, + currentState.currentQuestionIndex + ) + questionMutableViewState.update { + newState + } + } + + private fun performSubmitAnswer() { + surveyId?.let { + val submission = answers.toSurveySubmission(it) + viewModelScope.launch { + submitSurveyUseCase(submission) + .catch { handleSubmitError() } + .collect { handleSubmitSuccess() } + } + } + } + private fun setStateLoading() { val currentState = viewState.value mutableViewState.update { @@ -86,4 +135,42 @@ class SurveyDetailViewModel( ) } } + + private fun setNextQuestionState() { + val questionSize = viewState.value.surveyDetail?.questions?.size ?: 0 + val currentState = questionViewState.value + var nextQuestionIndex = currentState.currentQuestionIndex + 1 + val isFinalQuestion = nextQuestionIndex >= (questionSize - 1) + val newState = SurveyQuestionViewState( + isFinalQuestion, + currentState.isLoading, + nextQuestionIndex + ) + questionMutableViewState.update { + newState + } + } + + private fun handleSubmitError() { + val currentState = questionViewState.value + val newState = SurveyQuestionViewState( + currentQuestionIndex = currentState.currentQuestionIndex + ) + questionMutableViewState.update { + newState + } + } + + private fun handleSubmitSuccess() { + val currentState = questionViewState.value + val newState = SurveyQuestionViewState( + isShowingSubmit = true, + isLoading = false, + currentState.currentQuestionIndex, + true + ) + questionMutableViewState.update { + newState + } + } } From a8e7cb44441c643237f007f5e665ae590a05cf49 Mon Sep 17 00:00:00 2001 From: Bliss Pisit Wetcha Date: Fri, 17 Feb 2023 15:01:43 +0700 Subject: [PATCH 2/5] [#41] Integrate backend with ui --- .../SurveyDetail/SurveyDetailView+DataSource.swift | 8 ++++---- .../Modules/SurveyDetail/SurveyDetailView.swift | 10 +++++----- .../modules/surveydetail/SurveyDetailViewModel.kt | 7 +++++-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView+DataSource.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView+DataSource.swift index aa927bba..08deb120 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView+DataSource.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView+DataSource.swift @@ -23,7 +23,7 @@ extension SurveyDetailView { @Published var isShowingTitle = true @Published var isShowingTitleNavigationBar = true @Published var questionIndex = 0 - @Published var isShowingSuccess = false + @Published var isShowingSuccessConfirmation = false @Published var isShowingSubmit = false private var cancellables = Set() @@ -58,8 +58,8 @@ extension SurveyDetailView { viewModel.showQuestion() } - func didPressNext() { - viewModel.addAnswer(value: SurveySubmissionUiModel(id: "", answers: [])) + func didPressNext(answers: [SurveyAnswer]) { + viewModel.addAnswer(values: answers) } func didPressSubmit() { @@ -73,7 +73,7 @@ extension SurveyDetailView { viewState = state isShowingErrorAlert = !state.error.string.isEmpty isLoading = (state.isLoading || questionState.isLoading) && (state.isShowingQuestion) - isShowingSuccess = questionState.isShowingSuccess + isShowingSuccessConfirmation = questionState.isShowingSuccess withAnimation(.easeIn(duration: .viewTransition)) { isShowingSubmit = questionState.isShowingSubmit questionIndex = Int(questionState.currentQuestionIndex) diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView.swift index 4bea5acb..604d1ef1 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyDetailView.swift @@ -24,7 +24,6 @@ struct SurveyDetailView: View { @State var isAnimating = true @State var isShowingQuitPrompt = false - @State var isShowingSuccessConfirmation = false @State var currentAnswers = [SurveyAnswer]() var body: some View { @@ -33,10 +32,10 @@ struct SurveyDetailView: View { .if(!dataSource.isShowingTitleNavigationBar) { view in view.navigationBarItems(trailing: closeButton) } - if isShowingSuccessConfirmation { + if dataSource.isShowingSuccessConfirmation { SubmissionSuccessView( coordinator: coordinator, - isShowing: $isShowingSuccessConfirmation + isShowing: $dataSource.isShowingSuccessConfirmation ) .ignoresSafeArea() } @@ -136,7 +135,8 @@ struct SurveyDetailView: View { var nextButton: some View { Button { - dataSource.didPressNext() + dataSource.didPressNext(answers: currentAnswers) + currentAnswers = [] } label: { Assets.nextButton .image @@ -167,7 +167,7 @@ struct SurveyDetailView: View { .resizable() .frame(width: 28.0, height: 28.0) .accessibility(.surveyQuestion(.closeButton)) - .opacity(isShowingSuccessConfirmation ? 0.0 : 1.0) + .opacity(dataSource.isShowingSuccessConfirmation ? 0.0 : 1.0) } .disabled(isAnimating) } diff --git a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt index 56187a4e..e29131f7 100644 --- a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt +++ b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt @@ -80,8 +80,11 @@ class SurveyDetailViewModel( } } - fun addAnswer(value: SurveySubmissionUiModel?) { - value?.let { answers.add(it) } + fun addAnswer(values: List) { + viewState.value.surveyDetail?.questions?.get(questionViewState.value.currentQuestionIndex)?.let { + val submission = SurveySubmissionUiModel(it.id, values) + answers.add(submission) + } setNextQuestionState() } From 4d63e9b37e60f95f17316b00060c4ccae9e8aa49 Mon Sep 17 00:00:00 2001 From: Bliss Pisit Wetcha Date: Fri, 17 Feb 2023 16:42:19 +0700 Subject: [PATCH 3/5] [#41] Add viewmodel unit test --- .../surveydetail/SurveyDetailViewModel.kt | 16 +-- .../presentation/models/SurveyDetailDummy.kt | 20 ++++ .../surveydetail/SurveyDetailViewModelTest.kt | 99 ++++++++++++++++++- 3 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 shared/src/commonTest/kotlin/co/nimblehq/blisskmmic/presentation/models/SurveyDetailDummy.kt diff --git a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt index e29131f7..06c607c6 100644 --- a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt +++ b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt @@ -91,16 +91,16 @@ class SurveyDetailViewModel( fun submitAnswer() { val currentState = questionViewState.value if(!currentState.isLoading) { + val newState = SurveyQuestionViewState( + currentState.isShowingSubmit, + true, + currentState.currentQuestionIndex + ) + questionMutableViewState.update { + newState + } performSubmitAnswer() } - val newState = SurveyQuestionViewState( - currentState.isShowingSubmit, - true, - currentState.currentQuestionIndex - ) - questionMutableViewState.update { - newState - } } private fun performSubmitAnswer() { diff --git a/shared/src/commonTest/kotlin/co/nimblehq/blisskmmic/presentation/models/SurveyDetailDummy.kt b/shared/src/commonTest/kotlin/co/nimblehq/blisskmmic/presentation/models/SurveyDetailDummy.kt new file mode 100644 index 00000000..a0878aef --- /dev/null +++ b/shared/src/commonTest/kotlin/co/nimblehq/blisskmmic/presentation/models/SurveyDetailDummy.kt @@ -0,0 +1,20 @@ +package co.nimblehq.blisskmmic.presentation.models + +import co.nimblehq.blisskmmic.domain.model.SurveyDetail + +val surveyIncludedDummy = SurveyDetail.SurveyIncluded( + id = "", + text = "", + helpText = "", + displayType = "", + displayOrder = 1, + answers = emptyList() +) + +fun surveyDetailDummy(items: Int) = SurveyDetail( + title = "", + description = "", + isActive = true, + coverImageUrl = "", + questions = (0..items).map { surveyIncludedDummy } +) diff --git a/shared/src/commonTest/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModelTest.kt b/shared/src/commonTest/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModelTest.kt index 1a999b33..34b6c9c6 100644 --- a/shared/src/commonTest/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModelTest.kt +++ b/shared/src/commonTest/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModelTest.kt @@ -1,9 +1,15 @@ +@file:Suppress("MagicNumber") package co.nimblehq.blisskmmic.presentation.modules.surveydetail import app.cash.turbine.test +import app.cash.turbine.testIn import co.nimblehq.blisskmmic.domain.model.SurveyDetail import co.nimblehq.blisskmmic.domain.usecase.GetSurveyDetailUseCase +import co.nimblehq.blisskmmic.domain.usecase.SubmitSurveyUseCase import co.nimblehq.blisskmmic.helpers.flow.delayFlowOf +import co.nimblehq.blisskmmic.presentation.model.SurveySubmissionUiModel +import co.nimblehq.blisskmmic.presentation.model.toSurveySubmission +import co.nimblehq.blisskmmic.presentation.models.surveyDetailDummy import io.kotest.matchers.shouldBe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -24,12 +30,18 @@ class SurveyDetailViewModelTest: TestsWithMocks() { @Mock lateinit var getSurveyDetailUseCase: GetSurveyDetailUseCase + @Mock + lateinit var submitSurveyUseCase: SubmitSurveyUseCase @Fake lateinit var survey: SurveyDetail + @Fake + lateinit var answer: SurveySubmissionUiModel.Answer private val mainThreadSurrogate = newSingleThreadContext("UI thread") - private val surveyDetailViewModel by withMocks { SurveyDetailViewModel(getSurveyDetailUseCase) } + private val surveyDetailViewModel by withMocks { + SurveyDetailViewModel(getSurveyDetailUseCase, submitSurveyUseCase) + } override fun setUpMocks() = injectMocks(mocker) @@ -123,7 +135,7 @@ class SurveyDetailViewModelTest: TestsWithMocks() { } @Test - fun `When calling getDetail with faliure response - it changes viewState to error`() = runTest { + fun `When calling getDetail with failure response - it changes viewState to error`() = runTest { surveyDetailViewModel.setSurveyId("") val errorMessage = "Test Error" @@ -143,4 +155,87 @@ class SurveyDetailViewModelTest: TestsWithMocks() { cancel() } } + + @Test + fun `When calling addAnswer - it changes questionViewState to the correct item`() = runTest { + surveyDetailViewModel.setSurveyId("") + + mocker.every { + getSurveyDetailUseCase(isAny()) + } returns delayFlowOf(surveyDetailDummy(3)) + + surveyDetailViewModel.getDetail() + + val viewState = surveyDetailViewModel.viewState.testIn(backgroundScope) + val questionViewState = surveyDetailViewModel.questionViewState.testIn(backgroundScope) + viewState.skipItems(1) + viewState.awaitItem() + surveyDetailViewModel.addAnswer(listOf(answer)) + questionViewState.skipItems(1) + questionViewState.awaitItem().currentQuestionIndex shouldBe 1 + surveyDetailViewModel.addAnswer(listOf(answer)) + questionViewState.awaitItem().currentQuestionIndex shouldBe 2 + surveyDetailViewModel.addAnswer(listOf(answer)) + val lastState = questionViewState.awaitItem() + lastState.currentQuestionIndex shouldBe 3 + lastState.isShowingSubmit shouldBe true + viewState.cancelAndIgnoreRemainingEvents() + questionViewState.cancelAndIgnoreRemainingEvents() + } + + @Test + fun `When calling submitAnswer with success response - it calls submitSurveyUseCase with correct arguments and returns correct values`() = runTest { + surveyDetailViewModel.setSurveyId("") + + val submissions = listOf(SurveySubmissionUiModel("", listOf(answer))) + + mocker.every { + getSurveyDetailUseCase(isAny()) + } returns delayFlowOf(surveyDetailDummy(3)) + mocker.every { + submitSurveyUseCase(submissions.toSurveySubmission("")) + } returns delayFlowOf(Unit) + + surveyDetailViewModel.getDetail() + + val viewState = surveyDetailViewModel.viewState.testIn(backgroundScope) + val questionViewState = surveyDetailViewModel.questionViewState.testIn(backgroundScope) + viewState.skipItems(1) + viewState.awaitItem() + surveyDetailViewModel.addAnswer(listOf(answer)) + questionViewState.skipItems(2) + surveyDetailViewModel.submitAnswer() + questionViewState.awaitItem().isLoading shouldBe true + questionViewState.awaitItem().isShowingSuccess shouldBe true + viewState.cancelAndIgnoreRemainingEvents() + questionViewState.cancelAndIgnoreRemainingEvents() + } + + @Test + fun `When calling submitAnswer with error response - it returns correct error`() = runTest { + surveyDetailViewModel.setSurveyId("") + + val errorMessage = "Test Error" + + mocker.every { + getSurveyDetailUseCase(isAny()) + } returns delayFlowOf(surveyDetailDummy(3)) + mocker.every { + submitSurveyUseCase(isAny()) + } returns delayFlowOf(errorMessage) + + surveyDetailViewModel.getDetail() + + val viewState = surveyDetailViewModel.viewState.testIn(backgroundScope) + val questionViewState = surveyDetailViewModel.questionViewState.testIn(backgroundScope) + viewState.skipItems(1) + viewState.awaitItem() + surveyDetailViewModel.addAnswer(listOf(answer)) + questionViewState.skipItems(2) + surveyDetailViewModel.submitAnswer() + questionViewState.awaitItem().isLoading shouldBe true + questionViewState.awaitItem().isShowingSuccess shouldBe false + viewState.cancelAndIgnoreRemainingEvents() + questionViewState.cancelAndIgnoreRemainingEvents() + } } From 33ea5b346c2d35ab2f788a57afffbd98f7d661a3 Mon Sep 17 00:00:00 2001 From: Bliss Pisit Wetcha Date: Wed, 22 Feb 2023 09:45:34 +0700 Subject: [PATCH 4/5] [#41] Add suppress lints --- .../Modules/SurveyDetail/SurveyQuestion/SurveyQuestionView.swift | 1 - .../presentation/modules/surveydetail/SurveyDetailViewModel.kt | 1 + .../modules/surveyselection/SurveySelectionViewModel.kt | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/SurveyQuestionView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/SurveyQuestionView.swift index 36f64a14..a7f8c0fe 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/SurveyQuestionView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/SurveyQuestionView.swift @@ -52,7 +52,6 @@ struct SurveyQuestionView: View { @ViewBuilder func questionView(with question: SurveyDetailUiModel.SurveyIncluded) -> some View { - // TODO: Show real questions switch question.displayType { case .dropdown: QuestionPickerView(answers: $answers, options: question.answers) diff --git a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt index 06c607c6..0aa65b9b 100644 --- a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt +++ b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt @@ -34,6 +34,7 @@ data class SurveyQuestionViewState( constructor() : this(currentQuestionIndex = 0) } +@Suppress("TooManyFunctions") class SurveyDetailViewModel( private val getSurveyDetailUseCase: GetSurveyDetailUseCase, private val submitSurveyUseCase: SubmitSurveyUseCase, diff --git a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveyselection/SurveySelectionViewModel.kt b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveyselection/SurveySelectionViewModel.kt index b7182110..2d965c42 100644 --- a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveyselection/SurveySelectionViewModel.kt +++ b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveyselection/SurveySelectionViewModel.kt @@ -28,6 +28,7 @@ data class SurveySelectionViewState( constructor() : this(true) } +@Suppress("TooManyFunctions", "LongParameterList") class SurveySelectionViewModel( private val getCurrentDateUseCase: GetCurrentDateUseCase, private val getProfileUseCase: GetProfileUseCase, From bd8b68eb874c4fb3cc857bff06838775b6e2b0b9 Mon Sep 17 00:00:00 2001 From: Bliss Pisit Wetcha Date: Tue, 7 Mar 2023 12:22:39 +0700 Subject: [PATCH 5/5] [#41] Fix comments on viewstate var naming --- .../SurveyQuestion/QuestionEmojiView.swift | 3 +- .../surveydetail/SurveyDetailViewModel.kt | 62 +++++++------------ .../surveydetail/SurveyQuestionViewState.kt | 10 +++ 3 files changed, 36 insertions(+), 39 deletions(-) create mode 100644 shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyQuestionViewState.kt diff --git a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionEmojiView.swift b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionEmojiView.swift index bc239afe..26f1d07b 100644 --- a/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionEmojiView.swift +++ b/iosApp/Survey/Sources/Presentation/Modules/SurveyDetail/SurveyQuestion/QuestionEmojiView.swift @@ -56,7 +56,8 @@ struct QuestionEmojiView: View { Spacer() } .onChange(of: rating) { - answers = [.init(id: options[$0].id, answer: nil)] + guard let option = options[safe: $0] else { return } + answers = [.init(id: option.id, answer: nil)] } } diff --git a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt index 0aa65b9b..9d19b018 100644 --- a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt +++ b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyDetailViewModel.kt @@ -25,15 +25,6 @@ data class SurveyDetailViewState( constructor(error: String?) : this(null, false, false, error) } -data class SurveyQuestionViewState( - val isShowingSubmit: Boolean = false, - val isLoading: Boolean = false, - val currentQuestionIndex: Int = 0, - val isShowingSuccess: Boolean = false -) { - constructor() : this(currentQuestionIndex = 0) -} - @Suppress("TooManyFunctions") class SurveyDetailViewModel( private val getSurveyDetailUseCase: GetSurveyDetailUseCase, @@ -90,17 +81,17 @@ class SurveyDetailViewModel( } fun submitAnswer() { - val currentState = questionViewState.value - if(!currentState.isLoading) { - val newState = SurveyQuestionViewState( - currentState.isShowingSubmit, - true, - currentState.currentQuestionIndex - ) - questionMutableViewState.update { - newState + with(questionViewState.value) { + if (!isLoading) { + questionMutableViewState.update { + SurveyQuestionViewState( + isShowingSubmit = isShowingSubmit, + isLoading = true, + currentQuestionIndex = currentQuestionIndex + ) + } + performSubmitAnswer() } - performSubmitAnswer() } } @@ -145,36 +136,31 @@ class SurveyDetailViewModel( val currentState = questionViewState.value var nextQuestionIndex = currentState.currentQuestionIndex + 1 val isFinalQuestion = nextQuestionIndex >= (questionSize - 1) - val newState = SurveyQuestionViewState( - isFinalQuestion, - currentState.isLoading, - nextQuestionIndex - ) questionMutableViewState.update { - newState + SurveyQuestionViewState( + isShowingSubmit = isFinalQuestion, + isLoading = currentState.isLoading, + currentQuestionIndex = nextQuestionIndex + ) } } private fun handleSubmitError() { - val currentState = questionViewState.value - val newState = SurveyQuestionViewState( - currentQuestionIndex = currentState.currentQuestionIndex - ) questionMutableViewState.update { - newState + SurveyQuestionViewState( + currentQuestionIndex = questionViewState.value.currentQuestionIndex + ) } } private fun handleSubmitSuccess() { - val currentState = questionViewState.value - val newState = SurveyQuestionViewState( - isShowingSubmit = true, - isLoading = false, - currentState.currentQuestionIndex, - true - ) questionMutableViewState.update { - newState + SurveyQuestionViewState( + isShowingSubmit = true, + isLoading = false, + currentQuestionIndex = questionViewState.value.currentQuestionIndex, + isShowingSuccess = true + ) } } } diff --git a/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyQuestionViewState.kt b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyQuestionViewState.kt new file mode 100644 index 00000000..6c371ed1 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/nimblehq/blisskmmic/presentation/modules/surveydetail/SurveyQuestionViewState.kt @@ -0,0 +1,10 @@ +package co.nimblehq.blisskmmic.presentation.modules.surveydetail + +data class SurveyQuestionViewState( + val isShowingSubmit: Boolean = false, + val isLoading: Boolean = false, + val currentQuestionIndex: Int = 0, + val isShowingSuccess: Boolean = false +) { + constructor() : this(currentQuestionIndex = 0) +}