Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CU-862k43hfa - Add circuit and decouple viewmodel from view #127

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions android/app-newm/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ dependencies {
implementation(project(Modules.login))
implementation(project(Modules.shared))

implementation(Circuit.foundation)
implementation(Circuit.retained)

// implementation(Airbnb.showkase)
implementation(Google.activityCompose)
implementation(Google.androidxCore)
Expand Down
47 changes: 39 additions & 8 deletions android/app-newm/src/main/java/io/newm/LoginActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,64 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.slack.circuit.foundation.CircuitCompositionLocals
import com.slack.circuit.foundation.CircuitConfig
import com.slack.circuit.foundation.CircuitContent
import com.slack.circuit.retained.LocalRetainedStateRegistry
import com.slack.circuit.retained.continuityRetainedStateRegistry
import io.newm.core.theme.NewmTheme
import io.newm.feature.login.screen.*
import io.newm.feature.login.screen.createaccount.CreateAccountScreen
import io.newm.feature.login.screen.createaccount.CreateAccountPresenter
import io.newm.feature.login.screen.createaccount.CreateAccountUi
import io.newm.feature.login.screen.createaccount.CreateAccountViewModel
import io.newm.feature.login.screen.createaccount.EnterVerificationCodeScreen
import io.newm.feature.login.screen.createaccount.WhatShouldWeCallYouScreen
import io.newm.screens.Screen

class LoginActivity : ComponentActivity() {

// TODO inject
private val circuitConfig: CircuitConfig = CircuitConfig.Builder()
.addPresenterFactory { screen, navigator, _ ->
when (screen) {
is CreateAccountScreen -> CreateAccountPresenter()

else -> null
}
}
.addUiFactory { screen, _ ->
when (screen) {
is CreateAccountScreen -> CreateAccountUi()
else -> null
}
}.build()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
setContent {
NewmTheme(darkTheme = true) {
WelcomeToNewm(::launchHomeActivity)
CircuitDependencies {
WelcomeToNewm(::launchHomeActivity)
}
}
}
}

@Composable
private fun CircuitDependencies(
content: @Composable () -> Unit
) {
CircuitCompositionLocals(circuitConfig) {
CompositionLocalProvider(LocalRetainedStateRegistry provides continuityRetainedStateRegistry()) {
content()
}
}
}
Expand Down Expand Up @@ -60,13 +97,7 @@ fun WelcomeToNewm(
)
}
composable(Screen.Signup.route) {
CreateAccountScreen(
viewModel = signupViewModel,
onUserLoggedIn = onStartHomeActivity,
onNext = {
navController.navigate(Screen.WhatShouldWeCallYou.route)
},
)
CircuitContent(screen = CreateAccountScreen)
}
composable(Screen.WhatShouldWeCallYou.route) {
WhatShouldWeCallYouScreen(
Expand Down
4 changes: 4 additions & 0 deletions android/feature-login/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
id(Plugins.androidLibrary)
kotlin(Plugins.android)
id(Plugins.parcelize)
id(Plugins.paparazzi)
}

Expand Down Expand Up @@ -40,6 +41,9 @@ dependencies {
implementation(project(Modules.coreUiUtils))
implementation(project(Modules.coreResources))

implementation(Circuit.foundation)
implementation(Circuit.retained)

implementation(Google.androidxCore)
implementation(Google.composeMaterial)
implementation(Google.composeUi)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.newm.feature.login.screen.createaccount

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.slack.circuit.retained.rememberRetained
import com.slack.circuit.runtime.Navigator
import com.slack.circuit.runtime.presenter.Presenter
import io.newm.feature.login.screen.TextFieldState
import io.newm.feature.login.screen.createaccount.signupform.SignupFormUiEvent
import io.newm.feature.login.screen.password.ConfirmPasswordState
import io.newm.feature.login.screen.password.PasswordState

class CreateAccountPresenter : Presenter<CreateAccountUiState> {
Copy link
Contributor

@wlara wlara Jul 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never heard about this Circuit library before. They should have used a different name instead of Presenter, because of all the old MVP architecture baggage. Or is Circuit really Mvp?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's MVP with unidirectional data flow

@Composable
override fun present(): CreateAccountUiState {
val userEmail = rememberRetained { TextFieldState() }
val password = rememberRetained { PasswordState() }
val passwordConfirmation = rememberRetained { ConfirmPasswordState(password) }

return CreateAccountUiState.SignupForm(
emailState = userEmail,
passwordState = password,
passwordConfirmationState = passwordConfirmation,
) { event ->
when (event) {
SignupFormUiEvent.Next -> TODO()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,99 +1,7 @@
package io.newm.feature.login.screen.createaccount

import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.newm.feature.login.screen.email.Email
import io.newm.feature.login.screen.email.EmailState
import io.newm.feature.login.screen.password.Password
import io.newm.feature.login.screen.password.PasswordState
import io.newm.core.resources.R
import io.newm.core.ui.buttons.SecondaryButton
import io.newm.feature.login.screen.PreLoginArtistBackgroundContentTemplate
import com.slack.circuit.runtime.Screen
import kotlinx.parcelize.Parcelize

@Composable
fun CreateAccountScreen(
onUserLoggedIn: () -> Unit,
onNext: () -> Unit,
viewModel: CreateAccountViewModel
) {
val userState by viewModel.state.collectAsState()

CreateAccountScreen(
onUserLoggedIn = onUserLoggedIn,
onNext = onNext,
userState = userState,
setUserEmail = viewModel::setUserEmail,
setUserPassword = viewModel::setUserPassword,
setUserPasswordConfirmation = viewModel::setUserPasswordConfirmation,
requestCode = viewModel::requestCode,
)
}

@Composable
internal fun CreateAccountScreen(
onUserLoggedIn: () -> Unit,
onNext: () -> Unit,
userState: CreateAccountViewModel.SignupUserState,
setUserEmail: (String) -> Unit,
setUserPassword: (String) -> Unit,
setUserPasswordConfirmation: (String) -> Unit,
requestCode: () -> Unit,
) {
PreLoginArtistBackgroundContentTemplate {

LaunchedEffect(
key1 = userState.verificationRequested,
key2 = userState.verificationRequested
) {
if (userState.verificationRequested) {
onNext()
}
if (userState.isUserRegistered) {
onUserLoggedIn()
}
}

val focusRequester = remember { FocusRequester() }

val emailState = remember { EmailState() }
Email(emailState = emailState, onImeAction = { focusRequester.requestFocus() })

val passwordState1 = remember { PasswordState() }
Password(
label = stringResource(id = R.string.password),
passwordState = passwordState1,
onImeAction = {},
modifier = Modifier.focusRequester(focusRequester),
)

val passwordState2 = remember { PasswordState() }
Password(
label = stringResource(id = R.string.password),
passwordState = passwordState2,
onImeAction = {},
modifier = Modifier.focusRequester(focusRequester),
)

Spacer(modifier = Modifier.height(16.dp))

SecondaryButton(text = "Next") {
if (passwordState1.isValid && passwordState2.isValid
&& passwordState1.text == passwordState2.text || true
) {
setUserEmail("[email protected]")
setUserPassword("Password18")
setUserPasswordConfirmation("Password18")
requestCode()
}
}
}
}
@Parcelize
object CreateAccountScreen : Screen
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.newm.feature.login.screen.createaccount

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.slack.circuit.runtime.ui.Ui
import io.newm.feature.login.screen.createaccount.CreateAccountUiState.SignupForm
import io.newm.feature.login.screen.createaccount.signupform.SignUpFormUi

class CreateAccountUi : Ui<CreateAccountUiState> {
@Composable
override fun Content(state: CreateAccountUiState, modifier: Modifier) {
when (state) {
is SignupForm -> {
SignUpFormUi(state = state)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.newm.feature.login.screen.createaccount

import com.slack.circuit.runtime.CircuitUiState
import io.newm.feature.login.screen.TextFieldState
import io.newm.feature.login.screen.createaccount.signupform.SignupFormUiEvent

sealed interface CreateAccountUiState : CircuitUiState {
data class SignupForm(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great, curious with this approach will we be able to reuse states like Loading, Error or will those states have to exist within CreateAccountUiState seal class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could technically reuse them, but the overhead is not worth it

val passwordConfirmationState: TextFieldState,
val passwordState: TextFieldState,
val emailState: TextFieldState,
val eventSink: (SignupFormUiEvent) -> Unit,
) : CreateAccountUiState
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.newm.feature.login.screen.createaccount.signupform

import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.newm.feature.login.screen.email.Email
import io.newm.feature.login.screen.password.Password
import io.newm.core.resources.R
import io.newm.core.ui.buttons.SecondaryButton
import io.newm.feature.login.screen.PreLoginArtistBackgroundContentTemplate
import io.newm.feature.login.screen.createaccount.CreateAccountUiState.SignupForm

@Composable
fun SignUpFormUi(
state: SignupForm,
) {
val onEvent = state.eventSink
val focusRequester = remember { FocusRequester() }

PreLoginArtistBackgroundContentTemplate {
Email(
emailState = state.emailState,
onImeAction = { focusRequester.requestFocus() }
)

Password(
label = stringResource(id = R.string.password),
passwordState = state.passwordState,
onImeAction = {},
modifier = Modifier.focusRequester(focusRequester),
)

Password(
label = stringResource(id = R.string.password),
passwordState = state.passwordConfirmationState,
onImeAction = {},
modifier = Modifier.focusRequester(focusRequester),
)

Spacer(modifier = Modifier.height(16.dp))

SecondaryButton(text = "Next") {
onEvent(SignupFormUiEvent.Next)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.newm.feature.login.screen.createaccount.signupform

import com.slack.circuit.runtime.CircuitUiEvent

sealed interface SignupFormUiEvent : CircuitUiEvent {
object Next : SignupFormUiEvent
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,26 @@ import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import io.newm.core.test.utils.SnapshotTest
import io.newm.core.test.utils.SnapshotTestConfiguration
import io.newm.feature.login.screen.createaccount.CreateAccountScreen
import io.newm.feature.login.screen.createaccount.CreateAccountViewModel
import io.newm.feature.login.screen.createaccount.CreateAccountUiState
import io.newm.feature.login.screen.createaccount.signupform.SignUpFormUi
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(TestParameterInjector::class)
class CreateAccountScreenTest(
class CreateAccountUiTest(
@TestParameter private val testConfiguration: SnapshotTestConfiguration,
) : SnapshotTest(testConfiguration) {

@Test
fun default() {
fun `default sign up form`() {
snapshot {
CreateAccountScreen(
userState = CreateAccountViewModel.SignupUserState(),
onNext = {},
onUserLoggedIn = {},
setUserPasswordConfirmation = {},
setUserPassword = {},
requestCode = {},
setUserEmail = {}
SignUpFormUi(
state = CreateAccountUiState.SignupForm(
passwordConfirmationState = TextFieldState(),
passwordState = TextFieldState(),
emailState = TextFieldState(),
eventSink = {},
)
)
}
}
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading