diff --git a/.gitignore b/.gitignore index ba93fc4c842..b346d89f63b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ proguard/ .idea/*.xml .idea/codeStyles .idea/gradle.properties +.idea/inspectionProfiles/Project_Default.xml .navigation/ captures/ *.iml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 86ba5ab9795..c63f3ec9704 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,42 @@ - + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 11efa583fb5..da24e72ac49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Next Release +### Features + +- Show data protection consent screen during onboarding + ### Internal - Migrate alert facility change sheet to use Mobius loop diff --git a/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentEffect.kt b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentEffect.kt new file mode 100644 index 00000000000..0cfa76ea0fe --- /dev/null +++ b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentEffect.kt @@ -0,0 +1,13 @@ +package org.simple.clinic.consent.onboarding + +sealed interface OnboardingConsentEffect { + + data object MarkDataProtectionConsent : OnboardingConsentEffect + + data object CompleteOnboardingEffect : OnboardingConsentEffect +} + +sealed interface OnboardingConsentViewEffect : OnboardingConsentEffect { + + data object MoveToRegistrationActivity : OnboardingConsentViewEffect +} diff --git a/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentEffectHandler.kt b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentEffectHandler.kt new file mode 100644 index 00000000000..e4c98029829 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentEffectHandler.kt @@ -0,0 +1,56 @@ +package org.simple.clinic.consent.onboarding + +import com.f2prateek.rx.preferences2.Preference +import com.spotify.mobius.functions.Consumer +import com.spotify.mobius.rx2.RxMobius +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.reactivex.ObservableTransformer +import org.simple.clinic.consent.onboarding.OnboardingConsentEffect.CompleteOnboardingEffect +import org.simple.clinic.consent.onboarding.OnboardingConsentEffect.MarkDataProtectionConsent +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.FinishedMarkingDataProtectionConsent +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.OnboardingCompleted +import org.simple.clinic.main.TypedPreference +import org.simple.clinic.main.TypedPreference.Type.DataProtectionConsent +import org.simple.clinic.util.scheduler.SchedulersProvider + +class OnboardingConsentEffectHandler @AssistedInject constructor( + @TypedPreference(DataProtectionConsent) private val hasUserConsentedToDataProtection: Preference, + @TypedPreference(TypedPreference.Type.OnboardingComplete) private val hasUserCompletedOnboarding: Preference, + private val schedulersProvider: SchedulersProvider, + @Assisted private val viewEffectsConsumer: Consumer +) { + + @AssistedFactory + interface Factory { + fun create(viewEffectsConsumer: Consumer): OnboardingConsentEffectHandler + } + + fun build(): ObservableTransformer { + return RxMobius + .subtypeEffectHandler() + .addTransformer(MarkDataProtectionConsent::class.java, markDataProtectionConsent()) + .addConsumer(OnboardingConsentViewEffect::class.java, viewEffectsConsumer::accept) + .addTransformer(CompleteOnboardingEffect::class.java, completeOnboardingEffect()) + .build() + } + + private fun completeOnboardingEffect(): ObservableTransformer { + return ObservableTransformer { effects -> + effects + .observeOn(schedulersProvider.io()) + .doOnNext { hasUserCompletedOnboarding.set(true) } + .map { OnboardingCompleted } + } + } + + private fun markDataProtectionConsent(): ObservableTransformer { + return ObservableTransformer { effects -> + effects + .observeOn(schedulersProvider.io()) + .doOnNext { hasUserConsentedToDataProtection.set(true) } + .map { FinishedMarkingDataProtectionConsent } + } + } +} diff --git a/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentEvent.kt b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentEvent.kt new file mode 100644 index 00000000000..344fd34f4bd --- /dev/null +++ b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentEvent.kt @@ -0,0 +1,14 @@ +package org.simple.clinic.consent.onboarding + +import org.simple.clinic.widgets.UiEvent + +sealed interface OnboardingConsentEvent : UiEvent { + + data object FinishedMarkingDataProtectionConsent : OnboardingConsentEvent + + data object AgreeButtonClicked : OnboardingConsentEvent { + override val analyticsName: String = "Onboarding Consent Screen:Agree Button Clicked" + } + + data object OnboardingCompleted : OnboardingConsentEvent +} diff --git a/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentModel.kt b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentModel.kt new file mode 100644 index 00000000000..0d9e115d6fd --- /dev/null +++ b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentModel.kt @@ -0,0 +1,7 @@ +package org.simple.clinic.consent.onboarding + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data object OnboardingConsentModel : Parcelable diff --git a/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentScreenFragment.kt b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentScreenFragment.kt new file mode 100644 index 00000000000..8b6bc5b4cfb --- /dev/null +++ b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentScreenFragment.kt @@ -0,0 +1,208 @@ +package org.simple.clinic.consent.onboarding + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.fragment.app.Fragment +import kotlinx.parcelize.Parcelize +import org.simple.clinic.R +import org.simple.clinic.common.ui.components.FilledButton +import org.simple.clinic.common.ui.theme.SimpleTheme +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.AgreeButtonClicked +import org.simple.clinic.di.injector +import org.simple.clinic.navigation.v2.ScreenKey +import org.simple.clinic.registerorlogin.AuthenticationActivity +import org.simple.clinic.util.disableAnimations +import org.simple.clinic.util.finishWithoutAnimations +import org.simple.clinic.util.mobiusViewModels +import org.simple.clinic.util.unsafeLazy +import javax.inject.Inject + +class OnboardingConsentScreenFragment : Fragment(), UiActions { + + @Inject + lateinit var effectHandlerFactory: OnboardingConsentEffectHandler.Factory + + private val viewEffectHandler by unsafeLazy { OnboardingConsentViewEffectHandler(this) } + private val viewModel by mobiusViewModels( + defaultModel = { OnboardingConsentModel }, + update = { OnboardingConsentUpdate() }, + effectHandler = { viewEffectsConsumer -> effectHandlerFactory.create(viewEffectsConsumer).build() } + ) + + override fun onAttach(context: Context) { + super.onAttach(context) + context.injector().inject(this) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + SimpleTheme { + OnboardingConsentScreen( + onAccept = { viewModel.dispatchEvent(AgreeButtonClicked) } + ) + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.viewEffects.setObserver( + viewLifecycleOwner, + { viewEffect -> viewEffectHandler.handle(viewEffect) }, + { pausedViewEffects -> pausedViewEffects.forEach { viewEffectHandler.handle(it) } } + ) + } + + override fun moveToRegistrationActivity() { + // This navigation should not be done here, we need a way to publish + // an event to the parent activity (maybe via the screen router's + // event bus?) and handle the navigation there. + // TODO(vs): 2019-11-07 Move this to an event that is subscribed in the parent activity + val intent = AuthenticationActivity + .forNewLogin(requireActivity()) + .disableAnimations() + + activity?.startActivity(intent) + activity?.finishWithoutAnimations() + } + + interface Injector { + fun inject(target: OnboardingConsentScreenFragment) + } + + @Parcelize + data class Key( + override val analyticsName: String = "Onboarding Consent Screen" + ) : ScreenKey() { + + override fun instantiateFragment() = OnboardingConsentScreenFragment() + } +} + +@Composable +private fun OnboardingConsentScreen( + modifier: Modifier = Modifier, + onAccept: () -> Unit +) { + Scaffold( + modifier = modifier, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .background(SimpleTheme.colors.material.primaryVariant) + .padding(dimensionResource(id = R.dimen.spacing_12)) + ) { + FilledButton( + onClick = onAccept, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.screen_onboarding_concent_accept_button)) + } + } + } + ) { paddingValues -> + val toolbarColor = SimpleTheme.colors.toolbarPrimary + val toolbarHeight = dimensionResource(id = R.dimen.spacing_192) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .drawWithCache { + onDrawWithContent { + drawRect( + color = toolbarColor, + size = size.copy(height = toolbarHeight.toPx()) + ) + drawContent() + } + } + ) { + Image( + modifier = Modifier + .padding( + top = dimensionResource(id = R.dimen.spacing_40), + bottom = dimensionResource(id = R.dimen.spacing_44) + ) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.logo_large), + contentDescription = null + ) + + ConsentCard() + } + } +} + +@Composable +private fun ConsentCard(modifier: Modifier = Modifier) { + val spacing24 = dimensionResource(id = R.dimen.spacing_24) + + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = spacing24), + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(spacing24), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.screen_onboarding_consent_title), + style = SimpleTheme.typography.material.h6 + ) + + Spacer(modifier = Modifier.requiredHeight(spacing24)) + + Text( + text = stringResource(id = R.string.screen_onboarding_consent_subtitle), + style = SimpleTheme.typography.material.body1 + ) + } + } +} + +@Preview +@Composable +private fun OnboardingConsentScreenPreview() { + SimpleTheme { + OnboardingConsentScreen( + onAccept = { + // no-op + } + ) + } +} diff --git a/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentUpdate.kt b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentUpdate.kt new file mode 100644 index 00000000000..e633bc34680 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentUpdate.kt @@ -0,0 +1,22 @@ +package org.simple.clinic.consent.onboarding + +import com.spotify.mobius.Next +import com.spotify.mobius.Update +import org.simple.clinic.consent.onboarding.OnboardingConsentEffect.CompleteOnboardingEffect +import org.simple.clinic.consent.onboarding.OnboardingConsentEffect.MarkDataProtectionConsent +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.AgreeButtonClicked +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.FinishedMarkingDataProtectionConsent +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.OnboardingCompleted +import org.simple.clinic.consent.onboarding.OnboardingConsentViewEffect.MoveToRegistrationActivity +import org.simple.clinic.mobius.dispatch + +class OnboardingConsentUpdate : Update { + + override fun update(model: OnboardingConsentModel, event: OnboardingConsentEvent): Next { + return when (event) { + FinishedMarkingDataProtectionConsent -> dispatch(CompleteOnboardingEffect) + AgreeButtonClicked -> dispatch(MarkDataProtectionConsent) + OnboardingCompleted -> dispatch(MoveToRegistrationActivity) + } + } +} diff --git a/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentViewEffectHandler.kt b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentViewEffectHandler.kt new file mode 100644 index 00000000000..a73ef632c8a --- /dev/null +++ b/app/src/main/java/org/simple/clinic/consent/onboarding/OnboardingConsentViewEffectHandler.kt @@ -0,0 +1,16 @@ +package org.simple.clinic.consent.onboarding + +import org.simple.clinic.mobius.ViewEffectsHandler + +class OnboardingConsentViewEffectHandler( + private val uiActions: UiActions +) : ViewEffectsHandler { + + override fun handle(viewEffect: OnboardingConsentViewEffect) { + when (viewEffect) { + is OnboardingConsentViewEffect.MoveToRegistrationActivity -> { + uiActions.moveToRegistrationActivity() + } + } + } +} diff --git a/app/src/main/java/org/simple/clinic/consent/onboarding/UiActions.kt b/app/src/main/java/org/simple/clinic/consent/onboarding/UiActions.kt new file mode 100644 index 00000000000..db6b1ba7ee8 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/consent/onboarding/UiActions.kt @@ -0,0 +1,5 @@ +package org.simple.clinic.consent.onboarding + +interface UiActions { + fun moveToRegistrationActivity() +} diff --git a/app/src/main/java/org/simple/clinic/di/AppModule.kt b/app/src/main/java/org/simple/clinic/di/AppModule.kt index 666053a7177..fde991e1887 100644 --- a/app/src/main/java/org/simple/clinic/di/AppModule.kt +++ b/app/src/main/java/org/simple/clinic/di/AppModule.kt @@ -7,6 +7,7 @@ import android.content.res.Resources import android.os.Vibrator import androidx.work.WorkManager import com.f2prateek.rx.preferences2.Preference +import com.f2prateek.rx.preferences2.RxSharedPreferences import com.google.android.gms.common.GoogleApiAvailability import dagger.Module import dagger.Provides @@ -27,6 +28,8 @@ import org.simple.clinic.home.overdue.search.OverdueSearchModule import org.simple.clinic.instantsearch.InstantSearchConfigModule import org.simple.clinic.login.LoginModule import org.simple.clinic.login.LoginOtpSmsListenerModule +import org.simple.clinic.main.TypedPreference +import org.simple.clinic.main.TypedPreference.Type.DataProtectionConsent import org.simple.clinic.onboarding.OnboardingModule import org.simple.clinic.patient.PatientModule import org.simple.clinic.plumbing.infrastructure.InfrastructureModule @@ -160,4 +163,9 @@ class AppModule(private val appContext: Application) { appContext: Application, configReader: ConfigReader ): MinimumMemoryChecker = RealMinimumMemoryChecker(appContext) + + @Provides + @TypedPreference(DataProtectionConsent) + fun hasUserConsentedToDataProtection(rxSharedPreferences: RxSharedPreferences): Preference = + rxSharedPreferences.getBoolean("data_protection_consent") } diff --git a/app/src/main/java/org/simple/clinic/main/TypedPreference.kt b/app/src/main/java/org/simple/clinic/main/TypedPreference.kt index 2afdffa8ef7..270c547949d 100644 --- a/app/src/main/java/org/simple/clinic/main/TypedPreference.kt +++ b/app/src/main/java/org/simple/clinic/main/TypedPreference.kt @@ -21,6 +21,7 @@ annotation class TypedPreference(val value: Type) { OverdueSearchHistory, LastCallResultPullToken, LastQuestionnairePullToken, - LastQuestionnaireResponsePullToken + LastQuestionnaireResponsePullToken, + DataProtectionConsent, } } diff --git a/app/src/main/java/org/simple/clinic/onboarding/OnboardingEffect.kt b/app/src/main/java/org/simple/clinic/onboarding/OnboardingEffect.kt index ef5b6652c00..a10da04ab77 100644 --- a/app/src/main/java/org/simple/clinic/onboarding/OnboardingEffect.kt +++ b/app/src/main/java/org/simple/clinic/onboarding/OnboardingEffect.kt @@ -2,8 +2,6 @@ package org.simple.clinic.onboarding sealed class OnboardingEffect -object CompleteOnboardingEffect : OnboardingEffect() - sealed class OnboardingViewEffect : OnboardingEffect() -object MoveToRegistrationEffect : OnboardingViewEffect() +data object OpenOnboardingConsentScreen : OnboardingViewEffect() diff --git a/app/src/main/java/org/simple/clinic/onboarding/OnboardingEffectHandler.kt b/app/src/main/java/org/simple/clinic/onboarding/OnboardingEffectHandler.kt index e76edf165e0..5eb77eeeda7 100644 --- a/app/src/main/java/org/simple/clinic/onboarding/OnboardingEffectHandler.kt +++ b/app/src/main/java/org/simple/clinic/onboarding/OnboardingEffectHandler.kt @@ -1,17 +1,13 @@ package org.simple.clinic.onboarding -import com.f2prateek.rx.preferences2.Preference import com.spotify.mobius.functions.Consumer import com.spotify.mobius.rx2.RxMobius import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.reactivex.ObservableTransformer -import org.simple.clinic.main.TypedPreference -import org.simple.clinic.main.TypedPreference.Type.OnboardingComplete class OnboardingEffectHandler @AssistedInject constructor( - @TypedPreference(OnboardingComplete) private val hasUserCompletedOnboarding: Preference, @Assisted private val viewEffectsConsumer: Consumer ) { @@ -25,16 +21,7 @@ class OnboardingEffectHandler @AssistedInject constructor( fun build(): ObservableTransformer { return RxMobius .subtypeEffectHandler() - .addTransformer(CompleteOnboardingEffect::class.java, completeOnboardingTransformer()) .addConsumer(OnboardingViewEffect::class.java, viewEffectsConsumer::accept) .build() } - - private fun completeOnboardingTransformer(): ObservableTransformer { - return ObservableTransformer { completeOnboardingEffect -> - completeOnboardingEffect - .doOnNext { hasUserCompletedOnboarding.set(true) } - .map { OnboardingCompleted } - } - } } diff --git a/app/src/main/java/org/simple/clinic/onboarding/OnboardingEvent.kt b/app/src/main/java/org/simple/clinic/onboarding/OnboardingEvent.kt index 80698f1da9d..f90aaa70be1 100644 --- a/app/src/main/java/org/simple/clinic/onboarding/OnboardingEvent.kt +++ b/app/src/main/java/org/simple/clinic/onboarding/OnboardingEvent.kt @@ -7,7 +7,3 @@ sealed class OnboardingEvent : UiEvent object GetStartedClicked : OnboardingEvent() { override val analyticsName = "Onboarding:Get Started Clicked" } - -object OnboardingCompleted : OnboardingEvent() { - override val analyticsName = "Onboarding:Onboarding Completed" -} diff --git a/app/src/main/java/org/simple/clinic/onboarding/OnboardingScreen.kt b/app/src/main/java/org/simple/clinic/onboarding/OnboardingScreen.kt index e99a42d8c6d..65e7a1caa6e 100644 --- a/app/src/main/java/org/simple/clinic/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/org/simple/clinic/onboarding/OnboardingScreen.kt @@ -17,13 +17,12 @@ import io.reactivex.rxkotlin.cast import kotlinx.parcelize.Parcelize import org.simple.clinic.R import org.simple.clinic.ReportAnalyticsEvents +import org.simple.clinic.consent.onboarding.OnboardingConsentScreenFragment import org.simple.clinic.databinding.ScreenOnboardingBinding import org.simple.clinic.di.injector +import org.simple.clinic.navigation.v2.Router import org.simple.clinic.navigation.v2.ScreenKey import org.simple.clinic.navigation.v2.fragments.BaseScreen -import org.simple.clinic.registerorlogin.AuthenticationActivity -import org.simple.clinic.util.disableAnimations -import org.simple.clinic.util.finishWithoutAnimations import org.simple.clinic.util.resolveColor import javax.inject.Inject @@ -39,7 +38,7 @@ class OnboardingScreen : BaseScreen< lateinit var onboardingEffectHandler: OnboardingEffectHandler.Factory @Inject - lateinit var activity: AppCompatActivity + lateinit var router: Router private val getStartedButton get() = binding.getStartedButton @@ -86,17 +85,8 @@ class OnboardingScreen : BaseScreen< return getStartedButton.clicks().map { GetStartedClicked } } - override fun moveToRegistrationScreen() { - // This navigation should not be done here, we need a way to publish - // an event to the parent activity (maybe via the screen router's - // event bus?) and handle the navigation there. - // TODO(vs): 2019-11-07 Move this to an event that is subscribed in the parent activity - val intent = AuthenticationActivity - .forNewLogin(activity) - .disableAnimations() - - activity.startActivity(intent) - activity.finishWithoutAnimations() + override fun openOnboardingConsentScreen() { + router.clearHistoryAndPush(OnboardingConsentScreenFragment.Key()) } private fun setIntroOneTextView() { diff --git a/app/src/main/java/org/simple/clinic/onboarding/OnboardingUi.kt b/app/src/main/java/org/simple/clinic/onboarding/OnboardingUi.kt index 9075a87e0ba..313ce5d1899 100644 --- a/app/src/main/java/org/simple/clinic/onboarding/OnboardingUi.kt +++ b/app/src/main/java/org/simple/clinic/onboarding/OnboardingUi.kt @@ -1,5 +1,5 @@ package org.simple.clinic.onboarding interface OnboardingUi { - fun moveToRegistrationScreen() + fun openOnboardingConsentScreen() } diff --git a/app/src/main/java/org/simple/clinic/onboarding/OnboardingUpdate.kt b/app/src/main/java/org/simple/clinic/onboarding/OnboardingUpdate.kt index 120709bcd86..daa2a730c57 100644 --- a/app/src/main/java/org/simple/clinic/onboarding/OnboardingUpdate.kt +++ b/app/src/main/java/org/simple/clinic/onboarding/OnboardingUpdate.kt @@ -10,8 +10,7 @@ class OnboardingUpdate : Update { return when (event) { - GetStartedClicked -> dispatch(CompleteOnboardingEffect) - OnboardingCompleted -> dispatch(MoveToRegistrationEffect) + GetStartedClicked -> dispatch(OpenOnboardingConsentScreen) } } } diff --git a/app/src/main/java/org/simple/clinic/onboarding/OnboardingViewEffectHandler.kt b/app/src/main/java/org/simple/clinic/onboarding/OnboardingViewEffectHandler.kt index b7e3f220e03..8cb5c43b5d0 100644 --- a/app/src/main/java/org/simple/clinic/onboarding/OnboardingViewEffectHandler.kt +++ b/app/src/main/java/org/simple/clinic/onboarding/OnboardingViewEffectHandler.kt @@ -9,7 +9,7 @@ class OnboardingViewEffectHandler( override fun handle(viewEffect: OnboardingViewEffect) { when (viewEffect) { - MoveToRegistrationEffect -> uiActions.moveToRegistrationScreen() + OpenOnboardingConsentScreen -> uiActions.openOnboardingConsentScreen() }.exhaustive() } } diff --git a/app/src/main/java/org/simple/clinic/setup/SetupActivityComponent.kt b/app/src/main/java/org/simple/clinic/setup/SetupActivityComponent.kt index e085b8527e9..c66961ec79a 100644 --- a/app/src/main/java/org/simple/clinic/setup/SetupActivityComponent.kt +++ b/app/src/main/java/org/simple/clinic/setup/SetupActivityComponent.kt @@ -3,12 +3,13 @@ package org.simple.clinic.setup import androidx.appcompat.app.AppCompatActivity import dagger.BindsInstance import dagger.Subcomponent +import org.simple.clinic.consent.onboarding.OnboardingConsentScreenFragment import org.simple.clinic.navigation.v2.Router import org.simple.clinic.onboarding.OnboardingScreen import org.simple.clinic.splash.SplashScreen @Subcomponent(modules = [SetupActivityModule::class]) -interface SetupActivityComponent : OnboardingScreen.Injector, SplashScreen.Injector { +interface SetupActivityComponent : OnboardingScreen.Injector, SplashScreen.Injector, OnboardingConsentScreenFragment.Injector { fun inject(target: SetupActivity) diff --git a/app/src/main/java/org/simple/clinic/util/ViewModelExt.kt b/app/src/main/java/org/simple/clinic/util/ViewModelExt.kt index ff6464185b4..75c7f385059 100644 --- a/app/src/main/java/org/simple/clinic/util/ViewModelExt.kt +++ b/app/src/main/java/org/simple/clinic/util/ViewModelExt.kt @@ -12,12 +12,13 @@ import com.spotify.mobius.functions.Consumer import com.spotify.mobius.rx2.RxMobius import io.reactivex.ObservableTransformer import org.simple.clinic.mobius.MobiusBaseViewModel +import org.simple.clinic.mobius.first fun Fragment.mobiusViewModels( defaultModel: () -> M, - init: () -> Init, update: () -> Update, - effectHandler: (viewEffectsConsumer: Consumer) -> ObservableTransformer + effectHandler: (viewEffectsConsumer: Consumer) -> ObservableTransformer, + init: () -> Init = { Init { model -> first(model) } }, ) = viewModels> { viewModelFactory { fun loop(viewEffectsConsumer: Consumer) = RxMobius diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62ebf2093ce..1c75fb2b6f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1051,4 +1051,17 @@ Invalid date, date must be within the range %1$s - %2$s + + + Terms of Use + Before using the Simple app, you must read and agree to the following terms by clicking “AGREE AND CONTINUE” below.\n\n +The Simple app contains private health information of patients (“Data”).\n\n +• You may only access Data as necessary to perform your job duties related to patient care within the healthcare facility in which you work. Accessing or viewing Data for any other purpose is prohibited.\n\n +• You must keep Data confidential. Sharing Data with others is prohibited.\n\n +• You must keep the security code you use to access the Simple app private.\n\n +• You must promptly log out of the Simple app when not in use. Allowing others to use your login credentials to access the Simple app or Data is prohibited.\n\n +• You must perform periodic syncing of data, capturing of mandatory records, and other maintenance as required.\n\n +• You understand that the Simple app automatically creates a record of your access to Data. Unauthorized access or other violations of the Terms of Use may result in your access to the Simple app being revoked or other disciplinary measures.\n\n + + AGREE AND CONTINUE diff --git a/app/src/test/java/org/simple/clinic/consent/onboarding/OnboardingConsentEffectHandlerTest.kt b/app/src/test/java/org/simple/clinic/consent/onboarding/OnboardingConsentEffectHandlerTest.kt new file mode 100644 index 00000000000..5419a354511 --- /dev/null +++ b/app/src/test/java/org/simple/clinic/consent/onboarding/OnboardingConsentEffectHandlerTest.kt @@ -0,0 +1,66 @@ +package org.simple.clinic.consent.onboarding + +import com.f2prateek.rx.preferences2.Preference +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.simple.clinic.consent.onboarding.OnboardingConsentEffect.CompleteOnboardingEffect +import org.simple.clinic.consent.onboarding.OnboardingConsentEffect.MarkDataProtectionConsent +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.FinishedMarkingDataProtectionConsent +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.OnboardingCompleted +import org.simple.clinic.consent.onboarding.OnboardingConsentViewEffect.MoveToRegistrationActivity +import org.simple.clinic.mobius.EffectHandlerTestCase +import org.simple.clinic.util.scheduler.TestSchedulersProvider + +class OnboardingConsentEffectHandlerTest { + + private val hasUserConsentedToDataProtection = mock>() + private val hasUserCompletedOnboarding = mock>() + private val uiActions = mock() + + private val effectHandlerTestCase = EffectHandlerTestCase( + OnboardingConsentEffectHandler( + hasUserConsentedToDataProtection = hasUserConsentedToDataProtection, + hasUserCompletedOnboarding = hasUserCompletedOnboarding, + schedulersProvider = TestSchedulersProvider.trampoline(), + viewEffectsConsumer = OnboardingConsentViewEffectHandler(uiActions)::handle + ).build() + ) + + @Test + fun `when mark consent effect is received, then mark data protection consent`() { + // when + effectHandlerTestCase.dispatch(MarkDataProtectionConsent) + + // then + verify(hasUserConsentedToDataProtection).set(true) + verifyNoMoreInteractions(hasUserConsentedToDataProtection) + + effectHandlerTestCase.assertOutgoingEvents(FinishedMarkingDataProtectionConsent) + } + + @Test + fun `when move to registration activity view effect is received, then move to registration activity`() { + // when + effectHandlerTestCase.dispatch(MoveToRegistrationActivity) + + // then + effectHandlerTestCase.assertNoOutgoingEvents() + + verify(uiActions).moveToRegistrationActivity() + verifyNoMoreInteractions(uiActions) + } + + @Test + fun `when complete onboarding effect is received, then complete onboarding`() { + // when + effectHandlerTestCase.dispatch(CompleteOnboardingEffect) + + // then + verify(hasUserCompletedOnboarding).set(true) + verifyNoMoreInteractions(hasUserCompletedOnboarding) + + effectHandlerTestCase.assertOutgoingEvents(OnboardingCompleted) + } +} diff --git a/app/src/test/java/org/simple/clinic/consent/onboarding/OnboardingConsentUpdateTest.kt b/app/src/test/java/org/simple/clinic/consent/onboarding/OnboardingConsentUpdateTest.kt new file mode 100644 index 00000000000..4fe41347a7c --- /dev/null +++ b/app/src/test/java/org/simple/clinic/consent/onboarding/OnboardingConsentUpdateTest.kt @@ -0,0 +1,51 @@ +package org.simple.clinic.consent.onboarding + +import com.spotify.mobius.test.NextMatchers.hasEffects +import com.spotify.mobius.test.NextMatchers.hasNoModel +import com.spotify.mobius.test.UpdateSpec +import com.spotify.mobius.test.UpdateSpec.assertThatNext +import org.junit.Test +import org.simple.clinic.consent.onboarding.OnboardingConsentEffect.CompleteOnboardingEffect +import org.simple.clinic.consent.onboarding.OnboardingConsentEffect.MarkDataProtectionConsent +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.AgreeButtonClicked +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.FinishedMarkingDataProtectionConsent +import org.simple.clinic.consent.onboarding.OnboardingConsentEvent.OnboardingCompleted +import org.simple.clinic.consent.onboarding.OnboardingConsentViewEffect.MoveToRegistrationActivity + +class OnboardingConsentUpdateTest { + + private val updateSpec = UpdateSpec(OnboardingConsentUpdate()) + + @Test + fun `when data protection consent is marked, then complete onboarding`() { + updateSpec + .given(OnboardingConsentModel) + .whenEvent(FinishedMarkingDataProtectionConsent) + .then(assertThatNext( + hasNoModel(), + hasEffects(CompleteOnboardingEffect) + )) + } + + @Test + fun `when agree button is clicked, then mark data protection consent`() { + updateSpec + .given(OnboardingConsentModel) + .whenEvent(AgreeButtonClicked) + .then(assertThatNext( + hasNoModel(), + hasEffects(MarkDataProtectionConsent) + )) + } + + @Test + fun `when onboarding is completed, then move to registration activity`() { + updateSpec + .given(OnboardingConsentModel) + .whenEvent(OnboardingCompleted) + .then(assertThatNext( + hasNoModel(), + hasEffects(MoveToRegistrationActivity) + )) + } +} diff --git a/app/src/test/java/org/simple/clinic/onboarding/OnboardingEffectHandlerTest.kt b/app/src/test/java/org/simple/clinic/onboarding/OnboardingEffectHandlerTest.kt index 5437fa24e80..3a4a68943be 100644 --- a/app/src/test/java/org/simple/clinic/onboarding/OnboardingEffectHandlerTest.kt +++ b/app/src/test/java/org/simple/clinic/onboarding/OnboardingEffectHandlerTest.kt @@ -1,21 +1,17 @@ package org.simple.clinic.onboarding -import com.f2prateek.rx.preferences2.Preference -import org.mockito.kotlin.eq +import org.junit.After +import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions -import org.junit.After -import org.junit.Test import org.simple.clinic.mobius.EffectHandlerTestCase class OnboardingEffectHandlerTest { private val uiActions = mock() - private val hasUserCompletedOnboarding = mock>() private val effectHandler = OnboardingEffectHandler( - hasUserCompletedOnboarding = hasUserCompletedOnboarding, viewEffectsConsumer = OnboardingViewEffectHandler(uiActions)::handle ).build() private val testCase = EffectHandlerTestCase(effectHandler) @@ -26,22 +22,12 @@ class OnboardingEffectHandlerTest { } @Test - fun `when complete onboarding effect is received, then mark user has completed onboarding`() { - // when - testCase.dispatch(CompleteOnboardingEffect) - - // then - verify(hasUserCompletedOnboarding).set(eq(true)) - testCase.assertOutgoingEvents(OnboardingCompleted) - } - - @Test - fun `when move to registration effect is received, then move to registration screen`() { + fun `when open onboarding consent screen effect is received, then open onboarding consent screen`() { // when - testCase.dispatch(MoveToRegistrationEffect) + testCase.dispatch(OpenOnboardingConsentScreen) // then - verify(uiActions).moveToRegistrationScreen() + verify(uiActions).openOnboardingConsentScreen() verifyNoMoreInteractions(uiActions) testCase.assertNoOutgoingEvents() diff --git a/app/src/test/java/org/simple/clinic/onboarding/OnboardingUpdateTest.kt b/app/src/test/java/org/simple/clinic/onboarding/OnboardingUpdateTest.kt index 3a83554c8fc..be86d1e12f2 100644 --- a/app/src/test/java/org/simple/clinic/onboarding/OnboardingUpdateTest.kt +++ b/app/src/test/java/org/simple/clinic/onboarding/OnboardingUpdateTest.kt @@ -12,24 +12,13 @@ class OnboardingUpdateTest { private val defaultModel = OnboardingModel @Test - fun `when get started button is clicked, then set onboarding as completed`() { + fun `when get started button is clicked, then open onboarding consent screen`() { updateSpec .given(defaultModel) .whenEvent(GetStartedClicked) .then(assertThatNext( hasNoModel(), - hasEffects(CompleteOnboardingEffect) - )) - } - - @Test - fun `when onboarding is completed, then move to registration screen`() { - updateSpec - .given(defaultModel) - .whenEvent(OnboardingCompleted) - .then(assertThatNext( - hasNoModel(), - hasEffects(MoveToRegistrationEffect) + hasEffects(OpenOnboardingConsentScreen) )) } } diff --git a/common-ui/src/main/java/org/simple/clinic/common/ui/components/OutlinedButton.kt b/common-ui/src/main/java/org/simple/clinic/common/ui/components/Button.kt similarity index 59% rename from common-ui/src/main/java/org/simple/clinic/common/ui/components/OutlinedButton.kt rename to common-ui/src/main/java/org/simple/clinic/common/ui/components/Button.kt index 8e76f6ebde4..17722f95b2f 100644 --- a/common-ui/src/main/java/org/simple/clinic/common/ui/components/OutlinedButton.kt +++ b/common-ui/src/main/java/org/simple/clinic/common/ui/components/Button.kt @@ -56,12 +56,46 @@ fun OutlinedButton( } } + +@Composable +fun FilledButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + enabled: Boolean = true, + buttonSize: ButtonSize = ButtonSize.Default, + content: @Composable RowScope.() -> Unit +) { + val minHeight = when (buttonSize) { + ButtonSize.Big -> 56.dp + ButtonSize.Default -> 48.dp + } + + androidx.compose.material.Button( + modifier = modifier.defaultMinSize( + minWidth = 64.dp, + minHeight = minHeight + ), + onClick = onClick, + enabled = enabled, + border = null + ) { + icon?.let { + it() + Spacer(modifier = Modifier.requiredWidth(8.dp)) + } + ProvideTextStyle(value = SimpleTheme.typography.buttonBig) { + content() + } + } +} + sealed interface ButtonSize { data object Default : ButtonSize data object Big : ButtonSize } -@Preview +@Preview(group = "OutlinedButton") @Composable private fun OutlinedButtonPreview() { SimpleTheme { @@ -74,7 +108,7 @@ private fun OutlinedButtonPreview() { } } -@Preview +@Preview(group = "OutlinedButton") @Composable private fun OutlinedButtonBigPreview() { SimpleTheme { @@ -88,7 +122,7 @@ private fun OutlinedButtonBigPreview() { } } -@Preview +@Preview(group = "OutlinedButton") @Composable private fun OutlinedButtonWithDifferentThemePreview() { SimpleRedTheme { @@ -101,7 +135,7 @@ private fun OutlinedButtonWithDifferentThemePreview() { } } -@Preview +@Preview(group = "OutlinedButton") @Composable private fun OutlinedButtonWithIconPreview() { SimpleTheme { @@ -116,3 +150,59 @@ private fun OutlinedButtonWithIconPreview() { } } } + +@Preview(group = "FilledButton") +@Composable +private fun FilledButtonPreview() { + SimpleTheme { + FilledButton( + modifier = Modifier.fillMaxWidth(), + onClick = { /*no-op*/ } + ) { + Text(text = "BUTTON") + } + } +} + +@Preview(group = "FilledButton") +@Composable +private fun FilledButtonBigPreview() { + SimpleTheme { + FilledButton( + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Big, + onClick = { /*no-op*/ } + ) { + Text(text = "BUTTON") + } + } +} + +@Preview(group = "FilledButton") +@Composable +private fun FilledButtonWithDifferentThemePreview() { + SimpleRedTheme { + FilledButton( + modifier = Modifier.fillMaxWidth(), + onClick = { /*no-op*/ } + ) { + Text(text = "BUTTON") + } + } +} + +@Preview(group = "FilledButton") +@Composable +private fun FilledButtonWithIconPreview() { + SimpleTheme { + FilledButton( + modifier = Modifier.fillMaxWidth(), + icon = { + Icon(imageVector = Icons.Filled.Add, contentDescription = null) + }, + onClick = { /*no-op*/ } + ) { + Text(text = "BUTTON") + } + } +} diff --git a/maestroUiFlows/login_flow.yaml b/maestroUiFlows/login_flow.yaml index 534e329b55e..1d64456ed5b 100644 --- a/maestroUiFlows/login_flow.yaml +++ b/maestroUiFlows/login_flow.yaml @@ -4,6 +4,7 @@ appId: org.simple.clinic.staging - launchApp - tapOn: "Next" - tapOn: "Get started" +- tapOn: "AGREE AND CONTINUE" - tapOn: below: id: "select_country_title"