Skip to content

Commit

Permalink
Merge pull request #3213 from CruGlobal/accountCreationFeedback
Browse files Browse the repository at this point in the history
GT-2112 Present auth error messages to the user on login
  • Loading branch information
frett authored Nov 7, 2023
2 parents 2a702f4 + 2adb2f9 commit 3624911
Show file tree
Hide file tree
Showing 28 changed files with 633 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.cru.godtools.account.GodToolsAccountManager
import org.cru.godtools.base.ui.activity.BaseActivity
import org.cru.godtools.base.ui.theme.GodToolsTheme
Expand All @@ -28,13 +27,11 @@ fun Context.startLoginActivity(createAccount: Boolean = false) = startActivity(
class LoginActivity : BaseActivity() {
@Inject
internal lateinit var accountManager: GodToolsAccountManager
private lateinit var loginState: GodToolsAccountManager.LoginState

private val createAccount get() = intent?.getBooleanExtra(EXTRA_CREATE, false) ?: false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loginState = accountManager.prepareForLogin(this)
finishWhenAuthenticated()

setContent {
Expand All @@ -43,8 +40,6 @@ class LoginActivity : BaseActivity() {
createAccount = createAccount,
onEvent = {
when (it) {
is LoginLayoutEvent.Login ->
lifecycleScope.launch { accountManager.login(it.type, loginState) }
LoginLayoutEvent.Close -> finish()
}
}
Expand Down
49 changes: 46 additions & 3 deletions app/src/main/kotlin/org/cru/godtools/ui/login/LoginLayout.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
Expand All @@ -19,10 +20,15 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
Expand All @@ -33,14 +39,26 @@ import androidx.compose.ui.unit.dp
import org.ccci.gto.android.common.androidx.compose.foundation.layout.padding
import org.cru.godtools.R
import org.cru.godtools.account.AccountType
import org.cru.godtools.account.LoginResponse
import org.cru.godtools.account.compose.rememberLoginLauncher
import org.cru.godtools.base.ui.theme.GodToolsTheme

private val MARGIN_HORIZONTAL = 32.dp
private val FACEBOOK_BLUE = Color(red = 0x18, green = 0x77, blue = 0xf2)

@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEvent) -> Unit = {}) {
fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEvent) -> Unit) {
var loginError: LoginResponse.Error? by rememberSaveable { mutableStateOf(null) }
val loginLauncher = rememberLoginLauncher(createAccount) {
when (it) {
LoginResponse.Success -> onEvent(LoginLayoutEvent.Close)
is LoginResponse.Error -> loginError = it
}
}

LoginError(loginError, onDismiss = { loginError = null })

Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
Expand Down Expand Up @@ -86,7 +104,7 @@ fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEven
)

Button(
onClick = { onEvent(LoginLayoutEvent.Login(AccountType.GOOGLE)) },
onClick = { loginLauncher.launch(AccountType.GOOGLE) },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black.copy(alpha = 0.54f)
Expand All @@ -104,7 +122,7 @@ fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEven
Text(stringResource(com.google.android.gms.base.R.string.common_signin_button_text_long))
}
Button(
onClick = { onEvent(LoginLayoutEvent.Login(AccountType.FACEBOOK)) },
onClick = { loginLauncher.launch(AccountType.FACEBOOK) },
colors = ButtonDefaults.buttonColors(
containerColor = FACEBOOK_BLUE,
contentColor = Color.White
Expand All @@ -124,3 +142,28 @@ fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEven
}
}
}

@Composable
private fun LoginError(error: LoginResponse.Error?, onDismiss: () -> Unit) {
if (error != null) {
AlertDialog(
text = {
Text(
stringResource(
when (error) {
LoginResponse.Error.UserAlreadyExists -> R.string.account_error_user_already_exists
LoginResponse.Error.UserNotFound -> R.string.account_error_user_not_found
else -> R.string.account_error_unknown
}
)
)
},
confirmButton = {
TextButton(onClick = { onDismiss() }) {
Text(stringResource(R.string.account_error_dialog_dismiss))
}
},
onDismissRequest = { onDismiss() },
)
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package org.cru.godtools.ui.login

import org.cru.godtools.account.AccountType

sealed class LoginLayoutEvent {
class Login(val type: AccountType) : LoginLayoutEvent()
object Close : LoginLayoutEvent()
data object Close : LoginLayoutEvent()
}
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings_account.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
<string name="account_create_description">Create an account to have real stories, encouragement, and practical tips at your fingertips.</string>
<string name="account_login_heading">Sign in</string>
<string name="account_login_description">Log in to your account to have real stories, encouragement, and practical tips at your fingertips.</string>
<string name="account_error_user_already_exists">User account already exists.</string>
<string name="account_error_user_not_found">User account not found.</string>
<string name="account_error_unknown">Error logging in.</string>
<string name="account_error_dialog_dismiss">OK</string>

<!-- Activity -->
<eat-comment />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ class ExternalSingletonsModule {
val accountManager by lazy {
mockk<GodToolsAccountManager> {
every { isAuthenticatedFlow } returns flowOf(false)
every { prepareForLogin(any()) } returns mockk()
}
}
@get:Provides
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ godtoolsShared-user-activity = { module = "org.cru.godtools.kotlin:user-activity
google-auto-value = { module = "com.google.auto.value:auto-value", version.ref = "google-auto-value" }
google-auto-value-annotations = { module = "com.google.auto.value:auto-value-annotations", version.ref = "google-auto-value" }
gradleDownloadTask = "de.undercouch:gradle-download-task:5.5.0"
gtoSupport-androidx-activity = { module = "org.ccci.gto.android:gto-support-androidx-activity", version.ref = "gtoSupport" }
gtoSupport-androidx-collection = { module = "org.ccci.gto.android:gto-support-androidx-collection", version.ref = "gtoSupport" }
gtoSupport-androidx-compose = { module = "org.ccci.gto.android:gto-support-androidx-compose", version.ref = "gtoSupport" }
gtoSupport-androidx-compose-material3 = { module = "org.ccci.gto.android:gto-support-androidx-compose-material3", version.ref = "gtoSupport" }
Expand Down
10 changes: 9 additions & 1 deletion library/account/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ plugins {
alias(libs.plugins.ksp)
}

android.namespace = "org.cru.godtools.account"
android {
namespace = "org.cru.godtools.account"

configureCompose(project)
}

dependencies {
implementation(project(":library:api"))

implementation(libs.androidx.activity.compose)

implementation(libs.gtoSupport.androidx.activity)
implementation(libs.gtoSupport.core)
implementation(libs.gtoSupport.facebook)
implementation(libs.gtoSupport.kotlin.coroutines)
Expand All @@ -28,6 +35,7 @@ dependencies {
ksp(libs.dagger.compiler)
ksp(libs.hilt.compiler)

testImplementation(libs.json)
testImplementation(libs.kotlin.coroutines.test)
testImplementation(libs.turbine)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ abstract class AccountModule {
accountManager: GodToolsAccountManager
) = object : MobileContentApiSessionInterceptor(context) {
override fun userId() = accountManager.userId
override suspend fun authenticate() = accountManager.authenticateWithMobileContentApi()
override suspend fun authenticate() = accountManager.authenticateWithMobileContentApi().getOrNull()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package org.cru.godtools.account

import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.app.ActivityOptionsCompat
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
Expand All @@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.ccci.gto.android.common.Ordered
import org.cru.godtools.account.provider.AccountProvider
import org.cru.godtools.account.provider.AuthenticationException

@Singleton
@OptIn(ExperimentalCoroutinesApi::class)
Expand Down Expand Up @@ -58,19 +62,44 @@ class GodToolsAccountManager @VisibleForTesting internal constructor(
.distinctUntilChanged()

// region Login/Logout
class LoginState internal constructor(internal val providerState: Map<AccountType, AccountProvider.LoginState?>)
@Composable
internal fun rememberLauncherForLogin(
createAccount: Boolean,
onResponse: (LoginResponse) -> Unit,
): ActivityResultLauncher<AccountType> {
val launchers = providers.associate {
it.type to it.rememberLauncherForLogin(createAccount) { result ->
onResponse(
when {
result.isSuccess -> LoginResponse.Success
else -> when (result.exceptionOrNull()) {
AuthenticationException.UserNotFound -> LoginResponse.Error.UserNotFound
AuthenticationException.UserAlreadyExists -> LoginResponse.Error.UserAlreadyExists
else -> LoginResponse.Error()
}
}
)
}
}

fun prepareForLogin(activity: ComponentActivity) =
LoginState(providers.associate { it.type to it.prepareForLogin(activity) })
suspend fun login(type: AccountType, state: LoginState) {
val providerState = state.providerState[type] ?: return
providers.first { it.type == type }.login(providerState)
return remember(launchers) {
object : ActivityResultLauncher<AccountType>() {
override fun launch(input: AccountType, options: ActivityOptionsCompat?) {
launchers[input]?.launch(input, options)
}

override fun unregister() = TODO("Unsupported")
override fun getContract() = TODO("Unsupported")
}
}
}

suspend fun logout() = coroutineScope {
// trigger a logout for any provider we happen to be logged into
providers.forEach { launch { it.logout() } }
}
// endregion Login/Logout

internal suspend fun authenticateWithMobileContentApi() = activeProvider?.authenticateWithMobileContentApi()
internal suspend fun authenticateWithMobileContentApi() = activeProvider?.authenticateWithMobileContentApi(false)
?: Result.failure(AuthenticationException.NoActiveProvider)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.cru.godtools.account

sealed interface LoginResponse {
data object Success : LoginResponse
open class Error : LoginResponse {
data object UserNotFound : Error()
data object UserAlreadyExists : Error()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.cru.godtools.account.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalContext
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.cru.godtools.account.GodToolsAccountManager

internal object LocalGodToolsAccountManager {
private val LocalComposition = compositionLocalOf<GodToolsAccountManager?> { null }

/**
* Returns current composition local value for the manager or fallback to resolution using Dagger.
*/
val current: GodToolsAccountManager
@Composable
get() = LocalComposition.current
?: EntryPointAccessors.fromApplication<AccountEntryPoint>(LocalContext.current).accountManager

/**
* Associates a [GodToolsAccountManager] key to a value in a call to [CompositionLocalProvider].
*/
infix fun provides(accountManager: GodToolsAccountManager): ProvidedValue<GodToolsAccountManager?> {
return LocalComposition.provides(accountManager)
}
}

@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface AccountEntryPoint {
val accountManager: GodToolsAccountManager
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.cru.godtools.account.compose

import androidx.compose.runtime.Composable
import org.cru.godtools.account.LoginResponse

@Composable
fun rememberLoginLauncher(createAccount: Boolean, onResponse: (LoginResponse) -> Unit) =
LocalGodToolsAccountManager.current.rememberLauncherForLogin(createAccount, onResponse)
Loading

0 comments on commit 3624911

Please sign in to comment.