Coverage Summary for Class: SignInEvent (com.georgeci.moneysurfer.feature.login)

Class Method, % Branch, % Line, % Instruction, %
SignInEvent$OnAnonymousLoginClick 100% (1/1) 100% (1/1) 100% (2/2)
SignInEvent$OnEmailChanged 100% (1/1) 100% (1/1) 100% (7/7)
SignInEvent$OnErrorDismiss 100% (1/1) 100% (1/1) 100% (2/2)
SignInEvent$OnLoginClick 100% (1/1) 100% (1/1) 100% (2/2)
SignInEvent$OnPasswordChanged 100% (1/1) 100% (1/1) 100% (7/7)
SignInEvent$OnSubmitClick 100% (1/1) 100% (1/1) 100% (2/2)
SignInEvent$OnTermsClick 100% (1/1) 100% (1/1) 100% (2/2)
SignInEvent$OnToggleModeClick 100% (1/1) 100% (1/1) 100% (2/2)
Total 100% (8/8) 100% (8/8) 100% (26/26)


 package com.georgeci.moneysurfer.feature.login
 
 import arrow.core.Either
 import co.touchlab.kermit.Logger
 import com.georgeci.moneysurfer.domain.auth.AuthError
 import com.georgeci.moneysurfer.domain.config.HostCapabilities
 import com.georgeci.moneysurfer.domain.usecase.AnonymousLoginUseCase
 import com.georgeci.moneysurfer.domain.usecase.DemoLoginUseCase
 import com.georgeci.moneysurfer.domain.usecase.LoginUseCase
 import com.georgeci.moneysurfer.domain.usecase.PostAuthBootstrapUseCase
 import com.georgeci.moneysurfer.domain.usecase.SignupUseCase
 import com.georgeci.moneysurfer.utils.MviViewModel
 import org.koin.core.annotation.KoinViewModel
 
 @KoinViewModel
 class SignInViewModel(
     private val login: LoginUseCase,
     private val signup: SignupUseCase,
     private val anonymousLogin: AnonymousLoginUseCase,
     private val demoLogin: DemoLoginUseCase,
     hostCapabilities: HostCapabilities,
 ) : MviViewModel<SignInState, SignInEvent, SignInEffect>(
     initialState = SignInState(
         emailPasswordEnabled = hostCapabilities.signInEmailPassword,
         anonymousEnabled = hostCapabilities.signInAnonymous,
         demoEnabled = hostCapabilities.signInDemo,
     ),
 ) {
 
     private val log = Logger.withTag(TAG)
 
     override fun onEvent(event: SignInEvent) {
         when (event) {
             is SignInEvent.OnEmailChanged ->
                 updateState { copy(email = event.email, error = null) }
             is SignInEvent.OnPasswordChanged ->
                 updateState { copy(password = event.password, error = null) }
             SignInEvent.OnToggleModeClick -> updateState {
                 copy(
                     mode = when (mode) {
                         AuthMode.SignIn -> AuthMode.SignUp
                         AuthMode.SignUp -> AuthMode.SignIn
                     },
                     error = null,
                 )
             }
             SignInEvent.OnSubmitClick -> submit()
             SignInEvent.OnAnonymousLoginClick -> runAuth("anon") { anonymousLogin() }
             SignInEvent.OnLoginClick -> runAuth("demo") { demoLogin() }
             SignInEvent.OnErrorDismiss -> updateState { copy(error = null) }
             SignInEvent.OnTermsClick -> postSideEffect(SignInEffect.NavigateToLegal)
         }
     }
 
     private fun submit() {
         val state = currentState
         if (state.isLoading) return
         val invalid = state.validate()
         if (invalid != null) {
             log.i { "[submit] rejected: $invalid (mode=${state.mode})" }
             updateState { copy(error = invalid, errorPresentation = SignInErrorPresentation.Inline) }
             return
         }
         val email = state.email
         val password = state.password
         val label = when (state.mode) {
             AuthMode.SignIn -> "login"
             AuthMode.SignUp -> "signup"
         }
         runAuth(label) {
             when (state.mode) {
                 AuthMode.SignIn -> login(email, password)
                 AuthMode.SignUp -> signup(email, password)
             }
         }
     }
 
     // `Any?` rather than a star projection: the success value is inspected, and a star-projected
     // right side is only usable as `Nothing`.
     private fun runAuth(label: String, block: suspend () -> Either<AuthError, Any?>) {
         if (currentState.isLoading) {
             log.d { "[$label] ignored: already loading" }
             return
         }
         log.i { "[$label] start" }
         launch(
             onError = { err ->
                 log.w(err) { "[$label] failed with exception" }
                 updateState {
                     copy(
                         isLoading = false,
                         error = SignInError.Unknown,
                         errorPresentation = SignInErrorPresentation.Dialog,
                     )
                 }
             },
         ) {
             updateState { copy(isLoading = true, error = null) }
             block().fold(
                 ifLeft = { err ->
                     val mapped = err.toSignInError()
                     log.w(AuthNonFatalException(label, err)) {
                         "[$label] failed -> $mapped (${err.message})"
                     }
                     updateState {
                         copy(
                             isLoading = false,
                             error = mapped,
                             errorPresentation = SignInErrorPresentation.Dialog,
                             // The account already exists, so put the user one tap from the action
                             // the message advises. This is also the way out of a half-finished
                             // sign-up: `SignupUseCase` creates the Firebase user before the local
                             // and Firestore bootstrap steps, so if one of those fails the address
                             // is taken from then on and re-trying sign-up can never succeed.
                             mode = if (mapped == SignInError.EmailAlreadyInUse) AuthMode.SignIn else mode,
                         )
                     }
                 },
                 ifRight = { result ->
                     // Auth succeeded either way; the bootstrap just could not find the account's
                     // workspaces locally, and the selector has to say so rather than render an
                     // empty list that reads as "you have no workspaces" (issue #342).
                     val cloudDataUnavailable =
                         result is PostAuthBootstrapUseCase.Result.CloudDataUnavailable
                     log.i { "[$label] ok -> navigate (cloudDataUnavailable=$cloudDataUnavailable)" }
                     postSideEffect(SignInEffect.NavigateToWorkspaceSelector(cloudDataUnavailable))
                     updateState { copy(isLoading = false) }
                 },
             )
         }
     }
 
     private companion object {
         const val TAG = "SignInVM"
     }
 }
 
 private fun AuthError.toSignInError(): SignInError = when (type) {
     AuthError.Type.WeakPassword -> SignInError.WeakPassword
     AuthError.Type.EmailAlreadyInUse -> SignInError.EmailAlreadyInUse
     AuthError.Type.InvalidEmail -> SignInError.EmailInvalid
     AuthError.Type.InvalidCredentials -> SignInError.InvalidCredentials
     AuthError.Type.PermissionDenied -> SignInError.PermissionDenied
     // Only the account-deletion flow can produce this; a sign-in never does.
     AuthError.Type.RequiresRecentLogin -> SignInError.Unknown
     AuthError.Type.Unknown -> SignInError.Unknown
 }
 
 enum class AuthMode { SignIn, SignUp }
 
 /** Which control an error belongs under, so the UI can anchor it to the offending input. */
 enum class SignInField { Email, Password, Form }
 
 enum class SignInErrorPresentation { Inline, Dialog }
 
 enum class SignInError(val field: SignInField) {
     EmailRequired(SignInField.Email),
     EmailInvalid(SignInField.Email),
     EmailAlreadyInUse(SignInField.Email),
     PasswordRequired(SignInField.Password),
     PasswordTooShort(SignInField.Password),
     WeakPassword(SignInField.Password),
     InvalidCredentials(SignInField.Form),
     PermissionDenied(SignInField.Form),
     Unknown(SignInField.Form),
 }
 
 /**
  * Firebase's own floor for `createUserWithEmailAndPassword`. Enforced locally so sign-up shows a
  * concrete hint under the field instead of relying on a server round-trip.
  */
 const val MIN_PASSWORD_LENGTH = 6
 
 // Deliberately permissive — the only job is to catch typos the provider would reject anyway
 // (missing @, missing dot, stray whitespace). Firebase remains the authority.
 private val EMAIL_PATTERN = Regex("""^[^@\s]+@[^@\s]+\.[^@\s]{2,}$""")
 
 data class SignInState(
     val email: String = "",
     val password: String = "",
     val mode: AuthMode = AuthMode.SignIn,
     val isLoading: Boolean = false,
     val error: SignInError? = null,
     val errorPresentation: SignInErrorPresentation = SignInErrorPresentation.Inline,
     /**
      * Which auth entry points this build offers, from `HostCapabilities`. Flat booleans on the state
      * rather than a config object: the screen only ever asks "is this button there?", and the host
      * is the single place that answers it.
      */
     val emailPasswordEnabled: Boolean = true,
     val anonymousEnabled: Boolean = true,
     val demoEnabled: Boolean = true,
 ) {
     /** True when the screen should render only the demo CTA — the offline build's shape. */
     val demoOnly: Boolean
         get() = demoEnabled && !emailPasswordEnabled && !anonymousEnabled
 
     /**
      * Only gated on [isLoading]. Blank or malformed input no longer disables the button — a dead
      * grey button explains nothing, whereas submitting surfaces a message under the offending
      * field (that silence was the original "I can't sign up" report).
      */
     val canSubmit: Boolean
         get() = !isLoading
 
     val emailError: SignInError?
         get() = error?.takeIf {
             errorPresentation == SignInErrorPresentation.Inline && it.field == SignInField.Email
         }
     val passwordError: SignInError?
         get() = error?.takeIf {
             errorPresentation == SignInErrorPresentation.Inline && it.field == SignInField.Password
         }
     val formError: SignInError?
         get() = error?.takeIf {
             errorPresentation == SignInErrorPresentation.Inline && it.field == SignInField.Form
         }
     val dialogError: SignInError?
         get() = error?.takeIf { errorPresentation == SignInErrorPresentation.Dialog }
 
     /** First client-side problem with the current input, or null when it is worth a network call. */
     fun validate(): SignInError? = when {
         email.isBlank() -> SignInError.EmailRequired
         !EMAIL_PATTERN.matches(email.trim()) -> SignInError.EmailInvalid
         password.isEmpty() -> SignInError.PasswordRequired
         mode == AuthMode.SignUp && password.length < MIN_PASSWORD_LENGTH -> SignInError.PasswordTooShort
         else -> null
     }
 }
 
 sealed interface SignInEvent {
     data class OnEmailChanged(val email: String) : SignInEvent
     data class OnPasswordChanged(val password: String) : SignInEvent
     data object OnToggleModeClick : SignInEvent
     data object OnSubmitClick : SignInEvent
     data object OnLoginClick : SignInEvent
     data object OnAnonymousLoginClick : SignInEvent
     data object OnErrorDismiss : SignInEvent
     data object OnTermsClick : SignInEvent
 }
 
 sealed interface SignInEffect {
     data class NavigateToWorkspaceSelector(
         val cloudDataUnavailable: Boolean = false,
     ) : SignInEffect
 
     data object NavigateToLegal : SignInEffect
 }
 
 private class AuthNonFatalException(
     label: String,
     error: AuthError,
 ) : RuntimeException("Auth $label failed: ${error.type}", error.cause)