diff --git a/.gitignore b/.gitignore index bdd2abbbb..8a9ccabef 100644 --- a/.gitignore +++ b/.gitignore @@ -276,6 +276,7 @@ debug.properties release.properties prod.properties earlybird.properties +aussie.properties _ignore*.kt core/database/schemas/com.crisiscleanup.core.database.TestCrisisCleanupDatabase diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e66c2d11..8319db813 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,10 +13,10 @@ plugins { android { defaultConfig { - val buildVersion = 166 + val buildVersion = 170 applicationId = "com.crisiscleanup" versionCode = buildVersion - versionName = "0.8.${buildVersion - 155}" + versionName = "0.9.${buildVersion - 168}" // Custom test runner to set up Hilt dependency graph testInstrumentationRunner = "com.crisiscleanup.core.testing.CrisisCleanupTestRunner" @@ -69,6 +69,11 @@ android { buildConfigField("Boolean", "IS_PROD_BUILD", "true") buildConfigField("Boolean", "IS_EARLYBIRD_BUILD", "true") } + + val aussie by getting { + buildConfigField("Boolean", "IS_PROD_BUILD", "true") + buildConfigField("Boolean", "IS_EARLYBIRD_BUILD", "false") + } } packaging { @@ -91,7 +96,10 @@ secrets { androidComponents { beforeVariants { variantBuilder -> // Unnecessary variants - if (variantBuilder.name == "prodDebug" || variantBuilder.name == "earlybirdDebug") { + if (variantBuilder.name == "prodDebug" || + variantBuilder.name == "earlybirdDebug" || + variantBuilder.name == "aussieDebug" + ) { variantBuilder.enable = false } } diff --git a/app/src/aussie/res/drawable/ic_launcher_foreground.xml b/app/src/aussie/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..452a9f151 --- /dev/null +++ b/app/src/aussie/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/app/src/demo/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/demo/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 6ae6a23dd..000000000 --- a/app/src/demo/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/demoDebug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/demoDebug/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 6ae6a23dd..000000000 --- a/app/src/demoDebug/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/earlybird/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/earlybird/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 3b9280ae0..000000000 --- a/app/src/earlybird/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 933c722a1..5ae878e5e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,7 +32,8 @@ + android:exported="true" + android:launchMode="singleInstance"> @@ -50,8 +51,10 @@ - + + + diff --git a/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt b/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt index 3b8d712bd..9eb4ecab8 100644 --- a/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt +++ b/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt @@ -31,8 +31,9 @@ class ExternalIntentProcessor @Inject constructor( } private fun processMainIntent(url: Uri, urlPath: String): Boolean { - if (urlPath.startsWith("/o/callback")) { - url.getQueryParameter("code")?.let { code -> + if (urlPath.startsWith("/l/")) { + val code = urlPath.replace("/l/", "") + if (code.isNotBlank()) { authEventBus.onEmailLoginLink(code) } } else if (urlPath.startsWith("/password/reset/")) { diff --git a/app/src/main/java/com/crisiscleanup/MainActivity.kt b/app/src/main/java/com/crisiscleanup/MainActivity.kt index 728933adb..1da8c90d2 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivity.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivity.kt @@ -1,5 +1,6 @@ package com.crisiscleanup +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -160,14 +161,22 @@ class MainActivity : ComponentActivity() { intent?.let { if (!intentProcessor.processMainIntent(it)) { - it.data?.let { dataUri -> - // TODO Open to browser or WebView. Do no loop back here. - logger.logDebug("App link not processed $dataUri") - } + logUnprocessedExternalUri(it) } } } + override fun onNewIntent(intent: Intent?) { + var isConsumed = false + intent?.let { + isConsumed = intentProcessor.processMainIntent(it) + } + if (!isConsumed) { + logUnprocessedExternalUri(intent) + super.onNewIntent(intent) + } + } + override fun onResume() { super.onResume() lazyStats.get().isTrackingEnabled = true @@ -202,6 +211,13 @@ class MainActivity : ComponentActivity() { trimMemoryEventManager.onTrimMemory(level) super.onTrimMemory(level) } + + private fun logUnprocessedExternalUri(intent: Intent?) { + intent?.data?.let { dataUri -> + // TODO Open to browser or WebView. Do no loop back here. + logger.logDebug("App link not processed $dataUri") + } + } } /** diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 2028c8de9..5313f2f28 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -20,7 +20,6 @@ import com.crisiscleanup.core.data.IncidentSelector import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AppDataManagementRepository -import com.crisiscleanup.core.data.repository.ClearAppDataStep.* import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LocalAppMetricsRepository import com.crisiscleanup.core.data.repository.LocalAppPreferencesRepository @@ -165,8 +164,8 @@ class MainActivityViewModel @Inject constructor( return null } - // TODO Build route to auth/forgot-password rather than switches through the hierarchy val showPasswordReset = authEventBus.showResetPassword + val showMagicLinkLogin = authEventBus.showMagicLinkLogin val isSwitchingToProduction: StateFlow val productionSwitchMessage: StateFlow diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt index 330ad1f46..fbe0f130d 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt @@ -5,14 +5,18 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost -import com.crisiscleanup.core.appnav.RouteConstant +import com.crisiscleanup.core.appnav.RouteConstant.authGraphRoutePattern import com.crisiscleanup.feature.authentication.navigation.authGraph import com.crisiscleanup.feature.authentication.navigation.emailLoginLinkScreen import com.crisiscleanup.feature.authentication.navigation.forgotPasswordScreen import com.crisiscleanup.feature.authentication.navigation.loginWithEmailScreen +import com.crisiscleanup.feature.authentication.navigation.loginWithPhoneScreen +import com.crisiscleanup.feature.authentication.navigation.magicLinkLoginScreen import com.crisiscleanup.feature.authentication.navigation.navigateToEmailLoginLink import com.crisiscleanup.feature.authentication.navigation.navigateToForgotPassword import com.crisiscleanup.feature.authentication.navigation.navigateToLoginWithEmail +import com.crisiscleanup.feature.authentication.navigation.navigateToLoginWithPhone +import com.crisiscleanup.feature.authentication.navigation.resetPasswordScreen @Composable fun CrisisCleanupAuthNavHost( @@ -21,10 +25,12 @@ fun CrisisCleanupAuthNavHost( closeAuthentication: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, - startDestination: String = RouteConstant.authGraphRoutePattern, + startDestination: String = authGraphRoutePattern, ) { val navToLoginWithEmail = remember(navController) { { navController.navigateToLoginWithEmail() } } + val navToLoginWithPhone = + remember(navController) { { navController.navigateToLoginWithPhone() } } val navToForgotPassword = remember(navController) { { navController.navigateToForgotPassword() } } val navToEmailMagicLink = @@ -51,9 +57,22 @@ fun CrisisCleanupAuthNavHost( ) }, ) + loginWithPhoneScreen( + onBack = onBack, + closeAuthentication = closeAuthentication, + ) + resetPasswordScreen( + onBack = onBack, + closeResetPassword = navToLoginWithEmail, + ) + magicLinkLoginScreen( + onBack = onBack, + closeAuthentication = closeAuthentication, + ) }, enableBackHandler = enableBackHandler, openLoginWithEmail = navToLoginWithEmail, + openLoginWithPhone = navToLoginWithPhone, closeAuthentication = closeAuthentication, ) } diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index 2bce84163..79bf126dc 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -73,6 +72,8 @@ import com.crisiscleanup.core.designsystem.icon.Icon.ImageVectorIcon import com.crisiscleanup.core.ui.AppLayoutArea import com.crisiscleanup.core.ui.LocalAppLayout import com.crisiscleanup.core.ui.rememberIsKeyboardOpen +import com.crisiscleanup.feature.authentication.navigation.navigateToMagicLinkLogin +import com.crisiscleanup.feature.authentication.navigation.navigateToPasswordReset import com.crisiscleanup.feature.cases.ui.SelectIncidentDialog import com.crisiscleanup.navigation.CrisisCleanupAuthNavHost import com.crisiscleanup.navigation.CrisisCleanupNavHost @@ -153,9 +154,22 @@ private fun LoadedContent( val isAccountExpired by viewModel.isAccountExpired val showPasswordReset by viewModel.showPasswordReset.collectAsStateWithLifecycle(false) + val showMagicLinkLogin by viewModel.showMagicLinkLogin.collectAsStateWithLifecycle(false) val isNotAuthenticatedState = authState !is AuthState.Authenticated var openAuthentication by rememberSaveable { mutableStateOf(isNotAuthenticatedState) } - if (openAuthentication || isNotAuthenticatedState || showPasswordReset) { + if (openAuthentication || + isNotAuthenticatedState || + showPasswordReset || + showMagicLinkLogin + ) { + LaunchedEffect(showPasswordReset, showMagicLinkLogin) { + if (showPasswordReset) { + appState.navController.navigateToPasswordReset() + } else if (showMagicLinkLogin) { + appState.navController.navigateToMagicLinkLogin() + } + } + val toggleAuthentication = remember(authState) { { open: Boolean -> openAuthentication = open } } @@ -209,7 +223,6 @@ private fun LoadedContent( @OptIn( ExperimentalComposeUiApi::class, - ExperimentalLayoutApi::class, ) @Composable private fun AuthenticateContent( @@ -246,7 +259,6 @@ private fun AuthenticateContent( } @OptIn( - ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, ) @Composable diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt index 96716cf2a..4a9cbe28c 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt @@ -18,6 +18,7 @@ enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: St demo(FlavorDimension.contentType, ".demo"), prod(FlavorDimension.contentType, ".prod"), earlybird(FlavorDimension.contentType, ".earlybird"), + aussie(FlavorDimension.contentType, ".aussie"), } fun configureFlavors( diff --git a/build_sign_prod_release_bundle.sh b/build_sign_prod_release_bundle.sh index 79d36ac83..b34d1b3e3 100755 --- a/build_sign_prod_release_bundle.sh +++ b/build_sign_prod_release_bundle.sh @@ -57,14 +57,36 @@ APP_OUT=$DIR/app/build/outputs if [[ -z "$DIST_DIR" ]]; then DIST_DIR=$DIR/app/build fi + DIST_AAB=$DIST_DIR/app-prod-release.aab -MAPPING_FILE_NAME=release-aab-mapping.txt +MAPPING_FILE_NAME=prod-release-aab-mapping.txt cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_AAB cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/$MAPPING_FILE_NAME # Sign app bundle jarsigner -verbose -storepass $CRISIS_CLEANUP_ANDROID_KEYSTORE_PW -keystore $CRISIS_CLEANUP_ANDROID_KEYSTORE_PATH $DIST_AAB $CRISIS_CLEANUP_ANDROID_KEYSTORE_KEY_ALIAS -keypass $CRISIS_CLEANUP_ANDROID_KEYSTORE_KEY_PW +# Most likely successful +if [[ -f "$DIST_AAB" && -f "$DIST_DIR/$MAPPING_FILE_NAME" ]]; then + GREEN='\033[0;32m' + NC='\033[0m' + dirPathLength=${#DIR} + bundleRelativePath=${DIST_AAB:dirPathLength} + echo -e "\n${GREEN}Signed bundle at${NC} .$bundleRelativePath. Mapping $MAPPING_FILE_NAME is copied to same area.\n" +else + messageExit "Something went wrong during build/signing" +fi + +$GRADLEW bundleAussieRelease + +DIST_AAB=$DIST_DIR/app-aussie-release.aab +MAPPING_FILE_NAME=aussie-release-aab-mapping.txt +cp $APP_OUT/bundle/aussieRelease/app-aussie-release.aab $DIST_AAB +cp $APP_OUT/mapping/aussieRelease/mapping.txt $DIST_DIR/$MAPPING_FILE_NAME + +# Sign app bundle +jarsigner -verbose -storepass $CRISIS_CLEANUP_ANDROID_KEYSTORE_PW -keystore $CRISIS_CLEANUP_ANDROID_KEYSTORE_PATH $DIST_AAB $CRISIS_CLEANUP_ANDROID_KEYSTORE_KEY_ALIAS -keypass $CRISIS_CLEANUP_ANDROID_KEYSTORE_KEY_PW + # Most likely successful if [[ -f "$DIST_AAB" && -f "$DIST_DIR/$MAPPING_FILE_NAME" ]]; then GREEN='\033[0;32m' diff --git a/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/AddressSearchRepository.kt b/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/AddressSearchRepository.kt index 87a15a2fe..a22b902dd 100644 --- a/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/AddressSearchRepository.kt +++ b/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/AddressSearchRepository.kt @@ -17,7 +17,7 @@ interface AddressSearchRepository { center: LatLng? = null, southwest: LatLng? = null, northeast: LatLng? = null, - maxResults: Int = 8, + maxResults: Int = 10, ): List suspend fun getPlaceAddress(placeId: String): LocationAddress? diff --git a/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt index 5dc2ff17d..784536c0a 100644 --- a/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt +++ b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt @@ -6,9 +6,11 @@ object RouteConstant { const val authRoute = "auth_route" const val loginWithEmailRoute = "$authRoute/login_with_email" + const val loginWithPhoneRoute = "$authRoute/login_with_phone" const val forgotPasswordRoute = "forgot_password_route" const val emailLoginLinkRoute = "email_login_link_route" - // const val resetPasswordRoute = "reset_password_route" + const val resetPasswordRoute = "$authRoute/reset_password_route" + const val magicLinkLoginRoute = "$authRoute/magic_link_login" // This cannot be used as the navHost startDestination const val casesRoute = "cases_route" diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/event/AuthEventBus.kt b/core/common/src/main/java/com/crisiscleanup/core/common/event/AuthEventBus.kt index 5e8521537..75d5e0622 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/event/AuthEventBus.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/event/AuthEventBus.kt @@ -17,7 +17,9 @@ interface AuthEventBus { val showResetPassword: Flow val resetPasswords: StateFlow - val emailLoginLinks: Flow + + val showMagicLinkLogin: Flow + val emailLoginCodes: Flow fun onLogout() @@ -37,7 +39,9 @@ class CrisisCleanupAuthEventBus @Inject constructor( override val resetPasswords = MutableStateFlow("") override val showResetPassword = resetPasswords.map { it.isNotBlank() } - override val emailLoginLinks = MutableSharedFlow(1) + + override val emailLoginCodes = MutableStateFlow("") + override val showMagicLinkLogin = emailLoginCodes.map { it.isNotBlank() } override fun onLogout() { externalScope.launch { @@ -59,7 +63,7 @@ class CrisisCleanupAuthEventBus @Inject constructor( override fun onEmailLoginLink(code: String) { externalScope.launch { - emailLoginLinks.emit(code) + emailLoginCodes.emit(code) } } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/IncidentNetworkDataPageRequest.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/IncidentWorksitesPageRequest.kt similarity index 100% rename from core/data/src/main/java/com/crisiscleanup/core/data/model/IncidentNetworkDataPageRequest.kt rename to core/data/src/main/java/com/crisiscleanup/core/data/model/IncidentWorksitesPageRequest.kt diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountUpdateRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountUpdateRepository.kt index 1a271f5e1..e8280ab4f 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountUpdateRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountUpdateRepository.kt @@ -10,6 +10,7 @@ import javax.inject.Inject interface AccountUpdateRepository { suspend fun initiateEmailMagicLink(emailAddress: String): Boolean + suspend fun initiatePhoneLogin(phoneNumber: String): Boolean suspend fun initiatePasswordReset(emailAddress: String): PasswordResetInitiation suspend fun changePassword(password: String, token: String): Boolean } @@ -27,6 +28,15 @@ class CrisisCleanupAccountUpdateRepository @Inject constructor( return false } + override suspend fun initiatePhoneLogin(phoneNumber: String): Boolean { + try { + return accountApi.initiatePhoneLogin(phoneNumber) + } catch (e: Exception) { + logger.logException(e) + } + return false + } + override suspend fun initiatePasswordReset(emailAddress: String): PasswordResetInitiation { try { val result = accountApi.initiatePasswordReset(emailAddress) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt index 161aaa1a2..7ab8a3d0d 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt @@ -140,7 +140,7 @@ class OfflineFirstWorksitesRepository @Inject constructor( } private val organizationAffiliates = orgId.map { - organizationsRepository.getOrganizationAffiliateIds(it).toSet() + organizationsRepository.getOrganizationAffiliateIds(it, true).toSet() } .stateIn( scope = externalScope, diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrganizationsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrganizationsRepository.kt index a2f907844..23b2eea7e 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrganizationsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrganizationsRepository.kt @@ -36,7 +36,7 @@ interface OrganizationsRepository { updateLocations: Boolean = false, ) - fun getOrganizationAffiliateIds(organizationId: Long): Set + fun getOrganizationAffiliateIds(organizationId: Long, addOrganizationId: Boolean): Set suspend fun getNearbyClaimingOrganizations( latitude: Double, @@ -113,9 +113,13 @@ class OfflineFirstOrganizationsRepository @Inject constructor( } } - override fun getOrganizationAffiliateIds(organizationId: Long) = + override fun getOrganizationAffiliateIds(organizationId: Long, addOrganizationId: Boolean) = incidentOrganizationDao.getAffiliateOrganizationIds(organizationId).toMutableSet() - .apply { add(organizationId) } + .apply { + if (addOrganizationId) { + add(organizationId) + } + } override suspend fun getNearbyClaimingOrganizations( latitude: Double, diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksiteChangeRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksiteChangeRepository.kt index 9301532d0..c31c7313b 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksiteChangeRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksiteChangeRepository.kt @@ -365,7 +365,7 @@ class CrisisCleanupWorksiteChangeRepository @Inject constructor( val workTypeIdLookup = workTypeDao.getNetworkedIdMap(worksiteId).asLookup() val organizationId = accountDataRepository.accountData.first().org.id val affiliateOrganizations = - organizationsRepository.getOrganizationAffiliateIds(organizationId) + organizationsRepository.getOrganizationAffiliateIds(organizationId, true) val syncResult = worksiteChangeSyncer.sync( accountDataRepository.accountData.first(), oldestReferenceChange, diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TextField.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TextField.kt index 9597ae947..4d34b2989 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TextField.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TextField.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -20,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -94,6 +96,7 @@ fun SingleLineTextField( readOnly: Boolean = false, drawOutline: Boolean = false, placeholder: String = "", + textStyle: TextStyle = LocalTextStyle.current, ) { val focusRequester = FocusRequester() val modifier2 = @@ -148,6 +151,7 @@ fun SingleLineTextField( trailingIcon = trailingIconContent, readOnly = readOnly, placeholder = placeholderContent, + textStyle = textStyle, ) } else { TextField( @@ -165,6 +169,7 @@ fun SingleLineTextField( trailingIcon = trailingIconContent, readOnly = readOnly, placeholder = placeholderContent, + textStyle = textStyle, colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, diff --git a/core/network/src/aussie/java/com/crisiscleanup/core/network/di/FlavoredNetworkModule.kt b/core/network/src/aussie/java/com/crisiscleanup/core/network/di/FlavoredNetworkModule.kt new file mode 100644 index 000000000..13d281a23 --- /dev/null +++ b/core/network/src/aussie/java/com/crisiscleanup/core/network/di/FlavoredNetworkModule.kt @@ -0,0 +1,15 @@ +package com.crisiscleanup.core.network.di + +import com.crisiscleanup.core.network.endoflife.EndOfLifeClient +import com.crisiscleanup.core.network.endoflife.NoEndOfLifeClient +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface FlavoredNetworkModule { + @Binds + fun bindsEndOfLifeClient(apiClient: NoEndOfLifeClient): EndOfLifeClient +} diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt index 5c6d56ac4..fab8b8f76 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt @@ -3,6 +3,7 @@ package com.crisiscleanup.core.network import com.crisiscleanup.core.network.model.InitiatePasswordResetResult import com.crisiscleanup.core.network.model.NetworkAuthResult import com.crisiscleanup.core.network.model.NetworkCaseHistoryEvent +import com.crisiscleanup.core.network.model.NetworkCodeAuthResult import com.crisiscleanup.core.network.model.NetworkCountResult import com.crisiscleanup.core.network.model.NetworkIncident import com.crisiscleanup.core.network.model.NetworkIncidentOrganization @@ -12,6 +13,8 @@ import com.crisiscleanup.core.network.model.NetworkLocation import com.crisiscleanup.core.network.model.NetworkOauthResult import com.crisiscleanup.core.network.model.NetworkOrganizationsResult import com.crisiscleanup.core.network.model.NetworkPersonContact +import com.crisiscleanup.core.network.model.NetworkPhoneOneTimePasswordResult +import com.crisiscleanup.core.network.model.NetworkUserProfile import com.crisiscleanup.core.network.model.NetworkWorkTypeRequest import com.crisiscleanup.core.network.model.NetworkWorkTypeStatusResult import com.crisiscleanup.core.network.model.NetworkWorksiteCoreData @@ -24,12 +27,24 @@ import kotlinx.datetime.Instant interface CrisisCleanupAuthApi { suspend fun login(email: String, password: String): NetworkAuthResult suspend fun oauthLogin(email: String, password: String): NetworkOauthResult + suspend fun magicLinkLogin(token: String): NetworkCodeAuthResult + suspend fun verifyPhoneCode( + phoneNumber: String, + code: String, + ): NetworkPhoneOneTimePasswordResult? + + suspend fun oneTimePasswordLogin( + accountId: Long, + oneTimePasswordId: Long, + ): NetworkCodeAuthResult? + suspend fun refreshTokens(refreshToken: String): NetworkOauthResult? suspend fun logout() } interface CrisisCleanupAccountApi { suspend fun initiateMagicLink(emailAddress: String): Boolean + suspend fun initiatePhoneLogin(phoneNumber: String): Boolean suspend fun initiatePasswordReset(emailAddress: String): InitiatePasswordResetResult suspend fun changePassword( password: String, @@ -126,4 +141,6 @@ interface CrisisCleanupNetworkDataSource { suspend fun getCaseHistory(worksiteId: Long): List suspend fun getUsers(ids: Collection): List + + suspend fun getProfile(accessToken: String): NetworkUserProfile? } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/fake/FakeAuthApi.kt b/core/network/src/main/java/com/crisiscleanup/core/network/fake/FakeAuthApi.kt index 60ffb777a..914f6ae02 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/fake/FakeAuthApi.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/fake/FakeAuthApi.kt @@ -3,7 +3,9 @@ package com.crisiscleanup.core.network.fake import com.crisiscleanup.core.network.CrisisCleanupAuthApi import com.crisiscleanup.core.network.model.NetworkAuthResult import com.crisiscleanup.core.network.model.NetworkAuthUserClaims +import com.crisiscleanup.core.network.model.NetworkCodeAuthResult import com.crisiscleanup.core.network.model.NetworkOauthResult +import com.crisiscleanup.core.network.model.NetworkPhoneOneTimePasswordResult import kotlinx.coroutines.delay import javax.inject.Inject @@ -35,6 +37,26 @@ class FakeAuthApi @Inject constructor() : CrisisCleanupAuthApi { ) } + override suspend fun magicLinkLogin(token: String) = NetworkCodeAuthResult( + refreshToken = "refresh", + accessToken = "access", + expiresIn = 3600, + ) + + override suspend fun verifyPhoneCode( + phoneNumber: String, + code: String, + ) = NetworkPhoneOneTimePasswordResult() + + override suspend fun oneTimePasswordLogin( + accountId: Long, + oneTimePasswordId: Long, + ) = NetworkCodeAuthResult( + refreshToken = "refresh", + accessToken = "access", + expiresIn = 3600, + ) + private var refreshTokenCounter = 1 override suspend fun refreshTokens(refreshToken: String): NetworkOauthResult { delay(1000) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAuth.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAuth.kt index 4758e41ec..f00c94542 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAuth.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAuth.kt @@ -60,3 +60,14 @@ data class NetworkOauthResult( @SerialName("expires_in") val expiresIn: Int, ) + +@Serializable +data class NetworkCodeAuthResult( + val errors: List? = null, + @SerialName("refresh_token") + val refreshToken: String? = null, + @SerialName("access_token") + val accessToken: String? = null, + @SerialName("expires_in") + val expiresIn: Int? = null, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkFile.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkFile.kt index 2b66f10e7..22c36939f 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkFile.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkFile.kt @@ -35,6 +35,9 @@ data class NetworkFile( val isProfilePicture = fileTypeT == "fileTypes.user_profile_picture" } +val List.profilePictureUrl: String? + get() = find { it.isProfilePicture }?.largeThumbnailUrl + @Serializable data class NetworkFilePush( @SerialName("file") diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkLanguage.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkLanguage.kt index f105b1a20..36c74c636 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkLanguage.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkLanguage.kt @@ -11,6 +11,7 @@ data class NetworkLanguagesResult( @Serializable data class NetworkLanguageDescription( + val id: Long, val subtag: String, @SerialName("name_t") val name: String, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPasswordRecovery.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPasswordRecovery.kt index a949a384a..38102afdc 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPasswordRecovery.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPasswordRecovery.kt @@ -11,7 +11,51 @@ data class NetworkEmailPayload( @Serializable data class NetworkMagicLinkResult( - val detail: String, + val errors: List? = null, +) + +@Serializable +data class NetworkPhonePayload( + @SerialName("phone_number") + val phone: String, +) + +@Serializable +data class NetworkPhoneCodePayload( + @SerialName("phone_number") + val phone: String, + @SerialName("otp") + val code: String, +) + +@Serializable +data class NetworkPhoneOneTimePasswordResult( + val errors: List? = null, + val accounts: List? = null, + @SerialName("otp_id") + val otpId: Long? = null, +) + +@Serializable +data class OneTimePasswordPhoneAccount( + val id: Long, + val email: String, + @SerialName("organization") + val organizationName: String, +) + +@Serializable +data class NetworkOneTimePasswordPayload( + @SerialName("user") + val accountId: Long, + @SerialName("otp_id") + val otpId: Long, +) + +@Serializable +data class NetworkPhoneCodeResult( + val errors: List? = null, + val message: String?, ) @Serializable diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkUser.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkUser.kt new file mode 100644 index 000000000..c26273d87 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkUser.kt @@ -0,0 +1,31 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkUser( + val id: Long, + @SerialName("first_name") + val firstName: String, + @SerialName("last_name") + val lastName: String, + val organization: Long, + val files: List, +) + + +@Serializable +data class NetworkUserProfile( + val id: Long, + val email: String, + @SerialName("first_name") + val firstName: String, + @SerialName("last_name") + val lastName: String, + val files: List?, + val organization: NetworkOrganizationShort, +) { + val profilePicUrl: String? + get() = files?.profilePictureUrl +} \ No newline at end of file diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/AccountApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/AccountApiClient.kt index 31b1874cf..65fb5b2db 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/AccountApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/AccountApiClient.kt @@ -6,19 +6,29 @@ import com.crisiscleanup.core.network.model.NetworkEmailPayload import com.crisiscleanup.core.network.model.NetworkMagicLinkResult import com.crisiscleanup.core.network.model.NetworkPasswordResetPayload import com.crisiscleanup.core.network.model.NetworkPasswordResetResult +import com.crisiscleanup.core.network.model.NetworkPhoneCodeResult +import com.crisiscleanup.core.network.model.NetworkPhonePayload import retrofit2.Retrofit import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Headers import retrofit2.http.POST import retrofit2.http.Path import javax.inject.Inject private interface AccountApi { + @Headers("Cookie: ") @POST("magic_link") suspend fun initiateMagicLink( @Body emailPayload: NetworkEmailPayload, ): NetworkMagicLinkResult + @Headers("Cookie: ") + @POST("otp") + suspend fun initiatePhoneLogin( + @Body phonePayload: NetworkPhonePayload, + ): NetworkPhoneCodeResult + @POST("password_reset_requests") suspend fun initiatePasswordReset( @Body emailPayload: NetworkEmailPayload, @@ -43,7 +53,11 @@ class AccountApiClient @Inject constructor( override suspend fun initiateMagicLink(emailAddress: String) = accountApi.initiateMagicLink( NetworkEmailPayload(emailAddress), - ).detail.isNotBlank() + ).errors == null + + override suspend fun initiatePhoneLogin(phoneNumber: String) = accountApi.initiatePhoneLogin( + NetworkPhonePayload(phoneNumber), + ).errors?.isNotEmpty() != true override suspend fun initiatePasswordReset(emailAddress: String) = accountApi.initiatePasswordReset( diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/AuthApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/AuthApiClient.kt index 880ac3c4d..4fa35d13d 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/AuthApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/AuthApiClient.kt @@ -3,13 +3,19 @@ package com.crisiscleanup.core.network.retrofit import com.crisiscleanup.core.network.CrisisCleanupAuthApi import com.crisiscleanup.core.network.model.NetworkAuthPayload import com.crisiscleanup.core.network.model.NetworkAuthResult +import com.crisiscleanup.core.network.model.NetworkCodeAuthResult import com.crisiscleanup.core.network.model.NetworkOauthPayload import com.crisiscleanup.core.network.model.NetworkOauthResult +import com.crisiscleanup.core.network.model.NetworkOneTimePasswordPayload +import com.crisiscleanup.core.network.model.NetworkPhoneCodePayload +import com.crisiscleanup.core.network.model.NetworkPhoneOneTimePasswordResult import com.crisiscleanup.core.network.model.NetworkRefreshToken import kotlinx.coroutines.sync.Mutex import retrofit2.Retrofit import retrofit2.http.Body +import retrofit2.http.Headers import retrofit2.http.POST +import retrofit2.http.Path import javax.inject.Inject import javax.inject.Singleton @@ -23,6 +29,22 @@ private interface AuthApi { suspend fun oauthLogin(@Body body: NetworkOauthPayload): NetworkOauthResult @ThrowClientErrorHeader + @Headers("Cookie: ") + @POST("magic_link/{code}/login") + suspend fun magicLinkCodeAuth(@Path("code") token: String): NetworkCodeAuthResult + + @ThrowClientErrorHeader + @Headers("Cookie: ") + @POST("otp/verify") + suspend fun verifyPhoneCode(@Body body: NetworkPhoneCodePayload): NetworkPhoneOneTimePasswordResult + + @ThrowClientErrorHeader + @Headers("Cookie: ") + @POST("otp/generate_token") + suspend fun oneTimePasswordAuth(@Body body: NetworkOneTimePasswordPayload): NetworkCodeAuthResult + + @ThrowClientErrorHeader + @Headers("Cookie: ") @POST("api-mobile-refresh-token") suspend fun refreshAccountTokens(@Body body: NetworkRefreshToken): NetworkOauthResult } @@ -41,6 +63,18 @@ class AuthApiClient @Inject constructor( override suspend fun oauthLogin(email: String, password: String): NetworkOauthResult = networkApi.oauthLogin(NetworkOauthPayload(email, password)) + override suspend fun magicLinkLogin(token: String) = networkApi.magicLinkCodeAuth(token) + + override suspend fun verifyPhoneCode( + phoneNumber: String, + code: String, + ) = networkApi.verifyPhoneCode(NetworkPhoneCodePayload(phoneNumber, code)) + + override suspend fun oneTimePasswordLogin( + accountId: Long, + oneTimePasswordId: Long, + ) = networkApi.oneTimePasswordAuth(NetworkOneTimePasswordPayload(accountId, oneTimePasswordId)) + override suspend fun refreshTokens(refreshToken: String): NetworkOauthResult? { if (refreshMutex.tryLock()) { try { diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 962b1e4fb..b830896be 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt @@ -5,6 +5,8 @@ import com.crisiscleanup.core.network.model.* import kotlinx.datetime.Instant import retrofit2.Retrofit import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers import retrofit2.http.Path import retrofit2.http.Query import retrofit2.http.QueryMap @@ -191,6 +193,13 @@ private interface DataSourceApi { @Query("id__in") ids: String, ): NetworkUsersResult + + @Headers("Cookie: ") + @GET("/users/me") + suspend fun getProfile( + @Header("Authorization") + accessToken: String, + ): NetworkUserProfile } private val worksiteCoreDataFields = listOf( @@ -226,10 +235,7 @@ class DataApiClient @Inject constructor( ) : CrisisCleanupNetworkDataSource { private val networkApi = retrofit.create(DataSourceApi::class.java) - override suspend fun getProfilePic(): String? { - val result = networkApi.getProfile() - return result.files?.firstOrNull(NetworkFile::isProfilePicture)?.largeThumbnailUrl - } + override suspend fun getProfilePic() = networkApi.getProfile().files?.profilePictureUrl override suspend fun getOrganizations(organizations: List) = networkApi.getOrganizations(organizations.joinToString(",")).let { @@ -395,4 +401,7 @@ class DataApiClient @Inject constructor( it.errors?.tryThrowException() it.results ?: emptyList() } + + override suspend fun getProfile(accessToken: String) = + networkApi.getProfile("Bearer $accessToken") } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/AuthenticationViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/AuthenticationViewModel.kt index ec43da2af..b0ebfa848 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/AuthenticationViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/AuthenticationViewModel.kt @@ -17,8 +17,8 @@ import com.crisiscleanup.core.model.data.OrgData import com.crisiscleanup.core.model.data.emptyOrgData import com.crisiscleanup.core.network.CrisisCleanupAuthApi import com.crisiscleanup.core.network.model.CrisisCleanupNetworkException -import com.crisiscleanup.core.network.model.NetworkFile import com.crisiscleanup.core.network.model.condenseMessages +import com.crisiscleanup.core.network.model.profilePictureUrl import com.crisiscleanup.feature.authentication.model.AuthenticationState import com.crisiscleanup.feature.authentication.model.LoginInputData import dagger.hilt.android.lifecycle.HiltViewModel @@ -152,8 +152,7 @@ class AuthenticationViewModel @Inject constructor( val claims = result.claims!! val profilePicUri = - claims.files?.firstOrNull(NetworkFile::isProfilePicture)?.largeThumbnailUrl - ?: "" + claims.files?.profilePictureUrl ?: "" // TODO Test coverage val organization = result.organizations diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt new file mode 100644 index 000000000..e1a801044 --- /dev/null +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt @@ -0,0 +1,340 @@ +package com.crisiscleanup.feature.authentication + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.crisiscleanup.core.common.KeyResourceTranslator +import com.crisiscleanup.core.common.log.AppLogger +import com.crisiscleanup.core.common.log.CrisisCleanupLoggers.Account +import com.crisiscleanup.core.common.log.Logger +import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO +import com.crisiscleanup.core.common.network.Dispatcher +import com.crisiscleanup.core.common.throttleLatest +import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.AccountUpdateRepository +import com.crisiscleanup.core.model.data.OrgData +import com.crisiscleanup.core.network.CrisisCleanupAuthApi +import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import com.crisiscleanup.feature.authentication.model.AuthenticationState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class LoginWithPhoneViewModel @Inject constructor( + private val authApi: CrisisCleanupAuthApi, + private val dataApi: CrisisCleanupNetworkDataSource, + private val accountUpdateRepository: AccountUpdateRepository, + private val accountDataRepository: AccountDataRepository, + private val translator: KeyResourceTranslator, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, + @Logger(Account) private val logger: AppLogger, +) : ViewModel() { + val uiState: StateFlow = accountDataRepository.accountData.map { + AuthenticateScreenUiState.Ready( + authenticationState = AuthenticationState(accountData = it), + ) + }.stateIn( + scope = viewModelScope, + initialValue = AuthenticateScreenUiState.Loading, + started = SharingStarted.WhileSubscribed(), + ) + + val phoneNumberInput = MutableStateFlow("") + private val numberRegex = """[\d -]+""".toRegex() + private val nonNumberRegex = """\D""".toRegex() + val phoneNumberNumbers = phoneNumberInput + .map { it.replace(nonNumberRegex, "") } + .stateIn( + scope = viewModelScope, + initialValue = "", + started = SharingStarted.WhileSubscribed(), + ) + val obfuscatedPhoneNumber = phoneNumberNumbers + .throttleLatest(250) + .map { + // TODO Refactor and test + var s = it + if (it.length > 4) { + val startIndex = 0.coerceAtLeast(it.length - 4) + val endIndex = it.length + val lastFour = s.substring(startIndex, endIndex) + val firstCount = s.length - 4 + fun obfuscated(count: Int): String { + return "•".repeat(count) + } + s = if (firstCount > 3) { + val obfuscatedStart = obfuscated(firstCount - 3) + val obfuscatedMiddle = obfuscated(3) + "($obfuscatedStart) $obfuscatedMiddle - $lastFour" + } else { + val obfuscated = obfuscated(firstCount) + "$obfuscated - $lastFour" + } + } + s + } + .stateIn( + scope = viewModelScope, + initialValue = "", + started = SharingStarted.WhileSubscribed(), + ) + + val singleCodes = mutableStateListOf("", "", "", "", "", "") + + val isRequestingCode = MutableStateFlow(false) + var openPhoneCodeLogin by mutableStateOf(false) + + private val isVerifyingCode = MutableStateFlow(false) + + val isExchangingCode = isRequestingCode.combine( + isVerifyingCode, + ::Pair, + ) + .map { it.first || it.second } + .stateIn( + scope = viewModelScope, + initialValue = false, + started = SharingStarted.WhileSubscribed(), + ) + + private var oneTimePasswordId = 0L + var accountOptions = mutableStateListOf() + val isSelectAccount = MutableStateFlow(true) + val selectedAccount = MutableStateFlow(PhoneNumberAccountNone) + + /** + * General error message during authentication + */ + var errorMessage by mutableStateOf("") + private set + + val isAuthenticateSuccessful = MutableStateFlow(false) + + fun onCloseScreen() { + phoneNumberInput.value = "" + errorMessage = "" + isAuthenticateSuccessful.value = false + } + + private fun resetVisualState() { + errorMessage = "" + } + + private fun clearAccountSelect() { + isSelectAccount.value = false + selectedAccount.value = PhoneNumberAccountNone + accountOptions.clear() + } + + fun onIncompleteCode() { + errorMessage = translator.translate("~~Enter a full phone code.", 0) + } + + fun requestPhoneCode(phoneNumber: String) { + resetVisualState() + clearAccountSelect() + + val trimPhoneNumber = phoneNumber.trim() + if (numberRegex.matchEntire(trimPhoneNumber) == null) { + errorMessage = translator.translate("info.enter_valid_phone", 0) + return + } + + if (isRequestingCode.value) { + return + } + isRequestingCode.value = true + viewModelScope.launch(ioDispatcher) { + try { + if (accountUpdateRepository.initiatePhoneLogin(trimPhoneNumber)) { + openPhoneCodeLogin = true + } else { + // TODO Be more specific + // TODO Capture error and report to backend + errorMessage = translator.translate( + "~~Phone number is invalid or phone login is down. Try again later.", + 0, + ) + } + } finally { + isRequestingCode.value = false + } + } + } + + private suspend fun verifyPhoneCode(phoneNumber: String, code: String): PhoneCodeVerification { + val result = authApi.verifyPhoneCode(phoneNumber, code) + val verification = result?.accounts?.map { + PhoneNumberAccount(it.id, it.email, it.organizationName) + } + ?.let { + return PhoneCodeVerification( + result.otpId!!, + it, + OneTimePasswordError.None, + ) + } + + return verification ?: PhoneCodeVerification( + 0, + emptyList(), + OneTimePasswordError.InvalidCode, + ) + } + + fun authenticate(code: String) { + val selectedUserId = selectedAccount.value.userId + if ( + isSelectAccount.value && + selectedUserId == 0L + ) { + errorMessage = translator.translate("~~Select an account to login with.", 0) + return + } + + if ( + accountOptions.isNotEmpty() && + accountOptions.find { it.userId == selectedUserId } == null + ) { + selectedAccount.value = PhoneNumberAccountNone + isSelectAccount.value = true + errorMessage = translator.translate("~~Select an account to login with.", 0) + return + } + + if (isExchangingCode.value) { + return + } + isVerifyingCode.value = true + + resetVisualState() + + viewModelScope.launch(ioDispatcher) { + try { + if (oneTimePasswordId == 0L) { + val result = verifyPhoneCode(phoneNumberInput.value, code) + if (result.associatedAccounts.isEmpty()) { + errorMessage = translator.translate( + "~~There are no accounts associated with this phone number.", + 0, + ) + return@launch + } else { + oneTimePasswordId = result.otpId + + // TODO Test associated accounts + accountOptions.clear() + if (result.associatedAccounts.size > 1) { + accountOptions.addAll(result.associatedAccounts) + selectedAccount.value = PhoneNumberAccountNone + isSelectAccount.value = true + } else { + selectedAccount.value = result.associatedAccounts.first() + isSelectAccount.value = false + } + } + } + + var isSuccessful = false + val accountId = selectedAccount.value.userId + if ( + accountId != 0L && + oneTimePasswordId != 0L + ) { + val accountData = accountDataRepository.accountData.first() + val otpAuth = authApi.oneTimePasswordLogin(accountId, oneTimePasswordId) + otpAuth?.let { tokens -> + if ( + tokens.refreshToken?.isNotBlank() == true && + tokens.accessToken?.isNotBlank() == true + ) { + dataApi.getProfile(tokens.accessToken!!)?.let { accountProfile -> + val emailAddress = accountData.emailAddress + if (emailAddress.isNotBlank() && + emailAddress != accountProfile.email + ) { + errorMessage = translator.translate( + "~~Logging in with an account different from the currently signed in account is not supported. Logout of the signed in account first then login with a different account.", + 0, + ) + // TODO Clear account data and support logging in with different email address? + } else { + val expirySeconds = + Clock.System.now() + .plus(tokens.expiresIn!!.seconds).epochSeconds + accountDataRepository.setAccount( + refreshToken = tokens.refreshToken!!, + accessToken = tokens.accessToken!!, + id = accountProfile.id, + email = accountProfile.email, + firstName = accountProfile.firstName, + lastName = accountProfile.lastName, + expirySeconds = expirySeconds, + profilePictureUri = accountProfile.profilePicUrl ?: "", + org = OrgData( + id = accountProfile.organization.id, + name = accountProfile.organization.name, + ), + ) + isSuccessful = true + logger.logDebug("Phone login successful") + } + } + } + } + } + + if (!isSuccessful && + errorMessage.isBlank() + ) { + errorMessage = + translator.translate("~~Login failed. Try requesting a new magic link.", 0) + } + + isAuthenticateSuccessful.value = isSuccessful + } catch (e: Exception) { + // TODO Be more specific on the failure where possible + errorMessage = translator.translate( + "~~Check the phone number and code is correct. If login continues to fail try again later or request a new code.", + 0, + ) + } finally { + isVerifyingCode.value = false + } + } + } +} + +data class PhoneNumberAccount( + val userId: Long, + val userDisplayName: String, + val organizationName: String, + val accountDisplay: String = if (userId > 0) "${userDisplayName}, $organizationName" else "", +) + +val PhoneNumberAccountNone = PhoneNumberAccount(0, "", "") + +private data class PhoneCodeVerification( + val otpId: Long, + val associatedAccounts: List, + val error: OneTimePasswordError, +) + +private enum class OneTimePasswordError { + None, + InvalidCode +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt new file mode 100644 index 000000000..b5d8ea2ca --- /dev/null +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt @@ -0,0 +1,107 @@ +package com.crisiscleanup.feature.authentication + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.crisiscleanup.core.common.KeyResourceTranslator +import com.crisiscleanup.core.common.event.AuthEventBus +import com.crisiscleanup.core.common.log.AppLogger +import com.crisiscleanup.core.common.log.CrisisCleanupLoggers +import com.crisiscleanup.core.common.log.Logger +import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers +import com.crisiscleanup.core.common.network.Dispatcher +import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.model.data.OrgData +import com.crisiscleanup.core.network.CrisisCleanupAuthApi +import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class MagicLinkLoginViewModel @Inject constructor( + accountDataRepository: AccountDataRepository, + authApi: CrisisCleanupAuthApi, + dataApi: CrisisCleanupNetworkDataSource, + private val translator: KeyResourceTranslator, + private val authEventBus: AuthEventBus, + @Dispatcher(CrisisCleanupDispatchers.IO) ioDispatcher: CoroutineDispatcher, + @Logger(CrisisCleanupLoggers.Account) private val logger: AppLogger, +) : ViewModel() { + var errorMessage by mutableStateOf("") + private set + + val isAuthenticating = MutableStateFlow(true) + val isAuthenticateSuccessful = MutableStateFlow(false) + + init { + viewModelScope.launch(ioDispatcher) { + var message = "" + try { + val loginCode = authEventBus.emailLoginCodes.first() + if (loginCode.isNotBlank()) { + val tokens = authApi.magicLinkLogin(loginCode) + tokens.accessToken?.let { accessToken -> + val refreshToken = tokens.refreshToken!! + val expiresIn = tokens.expiresIn!! + + dataApi.getProfile(accessToken)?.let { accountProfile -> + val accountData = accountDataRepository.accountData.first() + val emailAddress = accountData.emailAddress + if (emailAddress.isNotBlank() && emailAddress != accountProfile.email) { + message = + translator.translate( + "~~Logging in with an account different from the currently signed in account is not supported. Logout of the signed in account first then login with a different account.", + 0, + ) + + // TODO Clear account data and support logging in with different email address? + } else { + val expirySeconds = + Clock.System.now().plus(expiresIn.seconds).epochSeconds + accountDataRepository.setAccount( + refreshToken = refreshToken, + accessToken = accessToken, + id = accountProfile.id, + email = accountProfile.email, + firstName = accountProfile.firstName, + lastName = accountProfile.lastName, + expirySeconds = expirySeconds, + profilePictureUri = accountProfile.profilePicUrl ?: "", + org = OrgData( + id = accountProfile.organization.id, + name = accountProfile.organization.name, + ), + ) + + isAuthenticateSuccessful.value = true + } + } + } + } + } catch (e: Exception) { + logger.logException(e) + + } finally { + isAuthenticating.value = false + } + + if (!isAuthenticateSuccessful.value) { + errorMessage = message.ifBlank { + translator("~~Magic link is invalid. Request another magic link.", 0) + } + } + } + } + + fun clearMagicLinkLogin() { + authEventBus.onEmailLoginLink("") + } +} \ No newline at end of file diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasswordRecoverViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasswordRecoverViewModel.kt index 5430c68b6..91d4fb4f1 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasswordRecoverViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasswordRecoverViewModel.kt @@ -31,7 +31,7 @@ class PasswordRecoverViewModel @Inject constructor( private val accountUpdateRepository: AccountUpdateRepository, private val inputValidator: InputValidator, private val translator: KeyResourceTranslator, - authEventBus: AuthEventBus, + private val authEventBus: AuthEventBus, @Logger(CrisisCleanupLoggers.Account) private val logger: AppLogger, ) : ViewModel() { val emailAddress = MutableStateFlow(null) @@ -92,6 +92,9 @@ class PasswordRecoverViewModel @Inject constructor( return } + if (isInitiatingPasswordReset.value) { + return + } isInitiatingPasswordReset.value = true viewModelScope.launch { try { @@ -128,6 +131,9 @@ class PasswordRecoverViewModel @Inject constructor( return } + if (isInitiatingMagicLink.value) { + return + } isInitiatingMagicLink.value = true viewModelScope.launch { try { @@ -176,7 +182,6 @@ class PasswordRecoverViewModel @Inject constructor( viewModelScope.launch { try { val isChanged = accountUpdateRepository.changePassword(pw, resetToken) - if (isChanged) { isPasswordReset.value = true clearState() @@ -189,4 +194,8 @@ class PasswordRecoverViewModel @Inject constructor( } } } + + fun clearResetPassword() { + authEventBus.onResetPassword("") + } } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RootAuthViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RootAuthViewModel.kt index dd2bcc009..b298848de 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RootAuthViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RootAuthViewModel.kt @@ -2,7 +2,6 @@ package com.crisiscleanup.feature.authentication import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.crisiscleanup.core.common.event.AuthEventBus import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger @@ -17,7 +16,6 @@ import javax.inject.Inject @HiltViewModel class RootAuthViewModel @Inject constructor( accountDataRepository: AccountDataRepository, - private val authEventBus: AuthEventBus, @Logger(CrisisCleanupLoggers.Auth) private val logger: AppLogger, ) : ViewModel() { val authState = accountDataRepository.accountData @@ -33,12 +31,6 @@ class RootAuthViewModel @Inject constructor( initialValue = AuthState.Loading, started = SharingStarted.WhileSubscribed(), ) - - val showResetPassword = authEventBus.showResetPassword - - fun clearResetPassword() { - authEventBus.onResetPassword("") - } } sealed interface AuthState { diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/AuthNavigation.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/AuthNavigation.kt index 04a49bc2c..193fe2434 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/AuthNavigation.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/AuthNavigation.kt @@ -11,6 +11,7 @@ fun NavGraphBuilder.authGraph( nestedGraphs: NavGraphBuilder.() -> Unit, enableBackHandler: Boolean = false, openLoginWithEmail: () -> Unit = {}, + openLoginWithPhone: () -> Unit = {}, closeAuthentication: () -> Unit = {}, ) { navigation( @@ -21,6 +22,7 @@ fun NavGraphBuilder.authGraph( RootAuthRoute( enableBackHandler = enableBackHandler, openLoginWithEmail = openLoginWithEmail, + openLoginWithPhone = openLoginWithPhone, closeAuthentication = closeAuthentication, ) } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithEmailNavigation.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithEmailNavigation.kt index 82687486e..5f3dc137b 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithEmailNavigation.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithEmailNavigation.kt @@ -3,11 +3,17 @@ package com.crisiscleanup.feature.authentication.navigation import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import com.crisiscleanup.core.appnav.RouteConstant import com.crisiscleanup.core.appnav.RouteConstant.loginWithEmailRoute import com.crisiscleanup.feature.authentication.ui.LoginWithEmailRoute +import com.crisiscleanup.feature.authentication.ui.MagicLinkLoginRoute fun NavController.navigateToLoginWithEmail() { - this.navigate(loginWithEmailRoute) + navigate(loginWithEmailRoute) +} + +fun NavController.navigateToMagicLinkLogin() { + navigate(RouteConstant.magicLinkLoginRoute) } fun NavGraphBuilder.loginWithEmailScreen( @@ -27,3 +33,15 @@ fun NavGraphBuilder.loginWithEmailScreen( } nestedGraphs() } + +fun NavGraphBuilder.magicLinkLoginScreen( + onBack: () -> Unit, + closeAuthentication: () -> Unit, +) { + composable(route = RouteConstant.magicLinkLoginRoute) { + MagicLinkLoginRoute( + onBack = onBack, + closeAuthentication = closeAuthentication, + ) + } +} \ No newline at end of file diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithPhoneNavigation.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithPhoneNavigation.kt new file mode 100644 index 000000000..36ec24f65 --- /dev/null +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithPhoneNavigation.kt @@ -0,0 +1,23 @@ +package com.crisiscleanup.feature.authentication.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.crisiscleanup.core.appnav.RouteConstant +import com.crisiscleanup.feature.authentication.ui.LoginWithPhoneRoute + +fun NavController.navigateToLoginWithPhone() { + navigate(RouteConstant.loginWithPhoneRoute) +} + +fun NavGraphBuilder.loginWithPhoneScreen( + onBack: () -> Unit, + closeAuthentication: () -> Unit, +) { + composable(route = RouteConstant.loginWithPhoneRoute) { + LoginWithPhoneRoute( + onBack = onBack, + closeAuthentication = closeAuthentication, + ) + } +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/PasswordRecoverNavigation.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/PasswordRecoverNavigation.kt index a2cd24aae..0ab0daa73 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/PasswordRecoverNavigation.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/PasswordRecoverNavigation.kt @@ -5,14 +5,20 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.crisiscleanup.core.appnav.RouteConstant.emailLoginLinkRoute import com.crisiscleanup.core.appnav.RouteConstant.forgotPasswordRoute +import com.crisiscleanup.core.appnav.RouteConstant.resetPasswordRoute import com.crisiscleanup.feature.authentication.ui.PasswordRecoverRoute +import com.crisiscleanup.feature.authentication.ui.ResetPasswordRoute fun NavController.navigateToForgotPassword() { - this.navigate(forgotPasswordRoute) + navigate(forgotPasswordRoute) } fun NavController.navigateToEmailLoginLink() { - this.navigate(emailLoginLinkRoute) + navigate(emailLoginLinkRoute) +} + +fun NavController.navigateToPasswordReset() { + navigate(resetPasswordRoute) } fun NavGraphBuilder.forgotPasswordScreen( @@ -37,3 +43,15 @@ fun NavGraphBuilder.emailLoginLinkScreen( ) } } + +fun NavGraphBuilder.resetPasswordScreen( + onBack: () -> Unit, + closeResetPassword: () -> Unit, +) { + composable(route = resetPasswordRoute) { + ResetPasswordRoute( + onBack = onBack, + closeResetPassword = closeResetPassword, + ) + } +} \ No newline at end of file diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt index 27bed930e..473816747 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt @@ -79,7 +79,6 @@ fun LoginWithEmailRoute( val readyState = uiState as AuthenticateScreenUiState.Ready val authState = readyState.authenticationState Box(modifier) { - // TODO Scroll when content is longer than screen height with keyboard open Column( Modifier .scrollFlingListener(closeKeyboard) @@ -94,7 +93,6 @@ fun LoginWithEmailRoute( onBack = onBack, openForgotPassword = openForgotPassword, openEmailMagicLink = openEmailMagicLink, - closeAuthentication = closeAuthentication, ) Spacer(modifier = Modifier.weight(1f)) } @@ -109,7 +107,6 @@ private fun LoginWithEmailScreen( onBack: () -> Unit = {}, openForgotPassword: () -> Unit = {}, openEmailMagicLink: () -> Unit = {}, - closeAuthentication: () -> Unit = {}, viewModel: AuthenticationViewModel = hiltViewModel(), ) { val translator = LocalAppTranslator.current @@ -127,7 +124,7 @@ private fun LoginWithEmailScreen( val isNotBusy by viewModel.isNotAuthenticating.collectAsStateWithLifecycle() val focusEmail = viewModel.loginInputData.emailAddress.isEmpty() || - viewModel.isInvalidEmail.value + viewModel.isInvalidEmail.value val updateEmailInput = remember(viewModel) { { s: String -> viewModel.loginInputData.emailAddress = s } } val clearErrorVisuals = remember(viewModel) { { viewModel.clearErrorVisuals() } } @@ -169,14 +166,14 @@ private fun LoginWithEmailScreen( ) if (translateCount > 0) { -// LinkAction( -// "actions.request_magic_link", -// Modifier -// .actionHeight() -// .listItemPadding(), -// enabled = isNotBusy, -// action = openEmailMagicLink, -// ) + LinkAction( + "actions.request_magic_link", + Modifier + .actionHeight() + .listItemPadding(), + enabled = isNotBusy, + action = openEmailMagicLink, + ) LinkAction( "invitationSignup.forgot_password", @@ -223,7 +220,7 @@ private fun LoginWithEmailScreen( .testTag("emailLoginBackBtn"), arrangement = Arrangement.Start, enabled = isNotBusy, - action = closeAuthentication, + action = onBack, ) } else { LoginWithDifferentMethod( diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt new file mode 100644 index 000000000..1eb7b3e89 --- /dev/null +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt @@ -0,0 +1,422 @@ +package com.crisiscleanup.feature.authentication.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.toSize +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.BusyButton +import com.crisiscleanup.core.designsystem.component.OutlinedClearableTextField +import com.crisiscleanup.core.designsystem.component.SingleLineTextField +import com.crisiscleanup.core.designsystem.component.TopAppBarCancelAction +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.disabledAlpha +import com.crisiscleanup.core.designsystem.theme.fillWidthPadded +import com.crisiscleanup.core.designsystem.theme.listItemDropdownMenuOffset +import com.crisiscleanup.core.designsystem.theme.listItemHorizontalPadding +import com.crisiscleanup.core.designsystem.theme.listItemModifier +import com.crisiscleanup.core.designsystem.theme.listItemPadding +import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy +import com.crisiscleanup.core.designsystem.theme.listItemVerticalPadding +import com.crisiscleanup.core.designsystem.theme.optionItemHeight +import com.crisiscleanup.core.ui.rememberCloseKeyboard +import com.crisiscleanup.core.ui.rememberIsKeyboardOpen +import com.crisiscleanup.core.ui.scrollFlingListener +import com.crisiscleanup.feature.authentication.AuthenticateScreenUiState +import com.crisiscleanup.feature.authentication.LoginWithPhoneViewModel +import com.crisiscleanup.feature.authentication.PhoneNumberAccount +import com.crisiscleanup.feature.authentication.R +import com.crisiscleanup.feature.authentication.model.AuthenticationState + +@Composable +fun LoginWithPhoneRoute( + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + closeAuthentication: () -> Unit = {}, + viewModel: LoginWithPhoneViewModel = hiltViewModel(), +) { + val onCloseScreen = remember(viewModel, closeAuthentication) { + { + viewModel.onCloseScreen() + closeAuthentication() + } + } + + val isAuthenticateSuccessful by viewModel.isAuthenticateSuccessful.collectAsStateWithLifecycle() + if (isAuthenticateSuccessful) { + onCloseScreen() + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + when (uiState) { + is AuthenticateScreenUiState.Loading -> { + Box(Modifier.fillMaxSize()) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + } + + is AuthenticateScreenUiState.Ready -> { + val isKeyboardOpen = rememberIsKeyboardOpen() + val closeKeyboard = rememberCloseKeyboard(viewModel) + + val readyState = uiState as AuthenticateScreenUiState.Ready + val authState = readyState.authenticationState + Box(modifier) { + Column( + Modifier + .scrollFlingListener(closeKeyboard) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + if (viewModel.openPhoneCodeLogin) { + val onPhoneCodeBack = + remember(viewModel) { { viewModel.openPhoneCodeLogin = false } } + VerifyPhoneCodeScreen( + onBack = onPhoneCodeBack, + ) + } else { + AnimatedVisibility(visible = !isKeyboardOpen) { + CrisisCleanupLogoRow() + } + LoginWithPhoneScreen( + authState, + onBack = onBack, + ) + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} + +@Composable +private fun LoginWithPhoneScreen( + authState: AuthenticationState, + onBack: () -> Unit = {}, + viewModel: LoginWithPhoneViewModel = hiltViewModel(), +) { + val translator = LocalAppTranslator.current + + Text( + modifier = listItemModifier.testTag("phoneLoginHeaderText"), + text = translator("actions.login", R.string.login), + style = LocalFontStyles.current.header1, + ) + + ConditionalErrorMessage(viewModel.errorMessage) + + val isRequestingCode by viewModel.isRequestingCode.collectAsStateWithLifecycle() + val isNotBusy = !isRequestingCode + + val phoneNumber by viewModel.phoneNumberInput.collectAsStateWithLifecycle() + val focusPhone = phoneNumber.isEmpty() + val updateEmailInput = + remember(viewModel) { { s: String -> viewModel.phoneNumberInput.value = s } } + val requestPhoneCode = remember(viewModel, phoneNumber) { + { + viewModel.requestPhoneCode(phoneNumber) + } + } + OutlinedClearableTextField( + modifier = fillWidthPadded.testTag("loginPhoneTextField"), + label = translator("~~Enter cell phone"), + value = phoneNumber, + onValueChange = updateEmailInput, + keyboardType = KeyboardType.Phone, + enabled = isNotBusy, + isError = false, + hasFocus = focusPhone, + imeAction = ImeAction.Done, + onEnter = requestPhoneCode, + ) + + BusyButton( + modifier = fillWidthPadded.testTag("phoneLoginBtn"), + onClick = requestPhoneCode, + enabled = isNotBusy, + text = translator("loginForm.login_with_cell"), + indicateBusy = isRequestingCode, + ) + + if (authState.hasAuthenticated) { + LinkAction( + "actions.back", + modifier = Modifier + .listItemPadding() + .testTag("phoneLoginBackBtn"), + arrangement = Arrangement.Start, + enabled = isNotBusy, + action = onBack, + ) + } else { + LoginWithDifferentMethod( + onClick = onBack, + enabled = isNotBusy, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ColumnScope.VerifyPhoneCodeScreen( + onBack: () -> Unit = {}, + viewModel: LoginWithPhoneViewModel = hiltViewModel(), +) { + BackHandler { + onBack() + } + + val translator = LocalAppTranslator.current + + val isExchangingCode by viewModel.isExchangingCode.collectAsStateWithLifecycle() + val isNotBusy = !isExchangingCode + + val isSelectAccount by viewModel.isSelectAccount.collectAsStateWithLifecycle() + val isNotSelectAccount = !isSelectAccount + + val closeKeyboard = rememberCloseKeyboard(viewModel) + + TopAppBarCancelAction( + modifier = Modifier + .testTag("verifyPhoneCodeBackBtn"), + title = translator.translate("actions.login", 0), + onAction = onBack, + ) + + ConditionalErrorMessage(viewModel.errorMessage) + + val singleCodes = viewModel.singleCodes.toList() + + val obfuscatedPhoneNumber by viewModel.obfuscatedPhoneNumber.collectAsStateWithLifecycle() + Column(listItemModifier) { + Text( + translator.translate( + "~~Enter the ${singleCodes.size} digit code we sent to", + 0, + ), + ) + Text(obfuscatedPhoneNumber) + } + + var focusIndex = remember(viewModel) { + if (singleCodes.all { it.isBlank() }) { + 0 + } else { + -1 + } + } + val focusManager = LocalFocusManager.current + val submitCode = remember(viewModel) { + { + val codes = viewModel.singleCodes.toList() + val fullCode = codes + .map { it.trim() } + .filter { it.isNotBlank() } + .map { it[it.length - 1] } + .joinToString("") + + if (fullCode.length == codes.size) { + viewModel.authenticate(fullCode) + closeKeyboard() + } else { + viewModel.onIncompleteCode() + + codes.forEachIndexed { i, code -> + if (code.isBlank()) { + // TODO Not working as expected. + // Likely needs more complicated focus measures. + focusIndex = i + return@forEachIndexed + } + } + } + } + } + Row( + listItemModifier, + horizontalArrangement = listItemSpacedBy, + ) { + singleCodes.forEachIndexed { i: Int, code: String -> + val isLastCode = i >= singleCodes.size - 1 + val onEnter = if (isLastCode) submitCode else null + SingleLineTextField( + modifier = Modifier.weight(1f), + value = code, + onValueChange = { s -> + // TODO Set entire code if length matches and code is blank + + val updated = if (s.isBlank()) "" else s.last().toString() + viewModel.singleCodes[i] = updated + + if (updated.isNotBlank()) { + val focusDirection = + if (isLastCode) FocusDirection.Down else FocusDirection.Next + focusManager.moveFocus(focusDirection) + + if ( + isLastCode && + singleCodes.filter { it.isNotBlank() }.size >= singleCodes.size - 1 + ) { + closeKeyboard() + } + } + }, + enabled = isNotBusy && isNotSelectAccount, + isError = false, + drawOutline = true, + keyboardType = KeyboardType.Number, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + hasFocus = i == focusIndex, + imeAction = if (isLastCode) ImeAction.Done else ImeAction.Next, + onEnter = onEnter, + ) + } + } + + val phoneNumber by viewModel.phoneNumberNumbers.collectAsStateWithLifecycle() + val requestPhoneCode = remember(phoneNumber, viewModel) { + { + viewModel.requestPhoneCode(phoneNumber) + } + } + LinkAction( + "~~Resend Code", + modifier = Modifier + .listItemPadding() + .testTag("resendPhoneCodeBtn"), + arrangement = Arrangement.End, + enabled = isNotBusy, + action = requestPhoneCode, + ) + + val accountOptions = viewModel.accountOptions.toList() + if (accountOptions.size > 1) { + Text( + translator("~~This phone number is associated with multiple accounts."), + modifier = listItemModifier, + ) + + Text( + translator("~~Select Account"), + modifier = Modifier + .listItemHorizontalPadding(), + style = LocalFontStyles.current.header4, + ) + + Box(Modifier.fillMaxWidth()) { + var contentWidth by remember { mutableStateOf(Size.Zero) } + var showDropdown by remember { mutableStateOf(false) } + Column( + Modifier + .clickable( + onClick = { showDropdown = !showDropdown }, + enabled = isNotBusy, + ) + .fillMaxWidth() + .onGloballyPositioned { + contentWidth = it.size.toSize() + } + .then(listItemModifier), + ) { + val selectedOption by viewModel.selectedAccount.collectAsStateWithLifecycle() + Row( + Modifier.listItemVerticalPadding(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(selectedOption.accountDisplay.ifBlank { translator("~~Select an account") }) + Spacer(modifier = Modifier.weight(1f)) + var tint = LocalContentColor.current + if (!isNotBusy) { + tint = tint.disabledAlpha() + } + Icon( + imageVector = CrisisCleanupIcons.UnfoldMore, + contentDescription = translator("~~Select account"), + tint = tint, + ) + } + } + + val onSelect = { account: PhoneNumberAccount -> + viewModel.selectedAccount.value = account + showDropdown = false + } + DropdownMenu( + modifier = Modifier + .width( + with(LocalDensity.current) { + contentWidth.width.toDp().minus(listItemDropdownMenuOffset.x.times(2)) + }, + ), + expanded = showDropdown, + onDismissRequest = { showDropdown = false }, + ) { + for (option in accountOptions) { + key(option.userId) { + DropdownMenuItem( + modifier = Modifier.optionItemHeight(), + text = { + Text( + option.accountDisplay, + style = LocalFontStyles.current.header4, + ) + }, + onClick = { onSelect(option) }, + ) + } + } + } + } + } + + // TODO Move button above the screen keyboard (when visible) + Spacer(Modifier.weight(1f)) + + BusyButton( + modifier = fillWidthPadded.testTag("verifyPhoneCodeBtn"), + onClick = submitCode, + enabled = isNotBusy, + text = translator("actions.submit"), + indicateBusy = isExchangingCode, + ) +} \ No newline at end of file diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt new file mode 100644 index 000000000..2ea2b831c --- /dev/null +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt @@ -0,0 +1,76 @@ +package com.crisiscleanup.feature.authentication.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.AnimatedBusyIndicator +import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +import com.crisiscleanup.core.designsystem.theme.fillWidthPadded +import com.crisiscleanup.core.designsystem.theme.listItemModifier +import com.crisiscleanup.feature.authentication.MagicLinkLoginViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MagicLinkLoginRoute( + onBack: () -> Unit, + closeAuthentication: () -> Unit = {}, + viewModel: MagicLinkLoginViewModel = hiltViewModel(), +) { + val isAuthenticateSuccessful by viewModel.isAuthenticateSuccessful.collectAsStateWithLifecycle() + + val clearStateOnBack = remember(onBack, viewModel, isAuthenticateSuccessful) { + { + viewModel.clearMagicLinkLogin() + + if (isAuthenticateSuccessful) { + closeAuthentication() + } else { + onBack() + } + } + } + + if (isAuthenticateSuccessful) { + clearStateOnBack() + } + + BackHandler { + clearStateOnBack() + } + + val translator = LocalAppTranslator.current + + Column { + TopAppBarBackAction( + modifier = Modifier + .testTag("magicLinkLoginBackBtn"), + title = translator.translate("actions.login", 0), + onAction = clearStateOnBack, + ) + + val isAuthenticating by viewModel.isAuthenticating.collectAsStateWithLifecycle() + val errorMessage = viewModel.errorMessage + if (isAuthenticating) { + AnimatedBusyIndicator( + true, + modifier = listItemModifier, + ) + } else if (errorMessage.isNotBlank()) { + Text( + modifier = fillWidthPadded, + text = errorMessage, + color = MaterialTheme.colorScheme.error, + ) + } + } +} \ No newline at end of file diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/PasswordRecoverScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/PasswordRecoverScreen.kt index 29cc0cbbf..61d04eaaf 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/PasswordRecoverScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/PasswordRecoverScreen.kt @@ -11,9 +11,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.ImeAction @@ -24,7 +22,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyButton import com.crisiscleanup.core.designsystem.component.OutlinedClearableTextField -import com.crisiscleanup.core.designsystem.component.OutlinedObfuscatingTextField import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction import com.crisiscleanup.core.designsystem.theme.LocalFontStyles import com.crisiscleanup.core.designsystem.theme.fillWidthPadded @@ -41,15 +38,12 @@ fun PasswordRecoverRoute( onBack: () -> Unit, viewModel: PasswordRecoverViewModel = hiltViewModel(), showForgotPassword: Boolean = false, - showResetPassword: Boolean = false, showMagicLink: Boolean = false, ) { val translator = LocalAppTranslator.current var titleKey = "nav.magic_link" if (showForgotPassword) { titleKey = "invitationSignup.forgot_password" - } else if (showResetPassword) { - titleKey = "actions.reset_password" } val emailAddress by viewModel.emailAddress.collectAsStateWithLifecycle() @@ -79,16 +73,13 @@ fun PasswordRecoverRoute( TopAppBarBackAction( title = translator(titleKey), onAction = clearStateOnBack, - modifier = Modifier.testTag("forgotPasswordBackBtn"), + modifier = Modifier.testTag("passwordRecoverBackBtn"), ) val isResetInitiated by viewModel.isPasswordResetInitiated.collectAsStateWithLifecycle() - val isPasswordReset by viewModel.isPasswordReset.collectAsStateWithLifecycle() val isMagicLinkInitiated by viewModel.isMagicLinkInitiated.collectAsStateWithLifecycle() if (isResetInitiated) { PasswordResetInitiatedView() - } else if (isPasswordReset) { - PasswordResetSuccessfulView() } else if (isMagicLinkInitiated) { MagicLinkInitiatedView() } else { @@ -102,27 +93,13 @@ fun PasswordRecoverRoute( Spacer(Modifier.height(32.dp)) } - if (showResetPassword) { - val resetToken by viewModel.resetPasswordToken.collectAsStateWithLifecycle() - if (resetToken.isBlank()) { - PasswordResetNotPossibleView() - } else { - ResetPasswordView( - isEditable = isNotLoading, - isBusy = isBusy, - ) - } - - Spacer(Modifier.height(32.dp)) + if (showMagicLink) { + MagicLinkView( + emailAddress = emailAddressNn, + isEditable = isNotLoading, + isBusy = isBusy, + ) } - -// if (showMagicLink) { -// MagicLinkView( -// emailAddress = emailAddressNn, -// isEditable = isNotLoading, -// isBusy = isBusy, -// ) -// } } } } @@ -155,7 +132,7 @@ private fun ForgotPasswordView( val emailErrorMessage by viewModel.forgotPasswordErrorMessage.collectAsStateWithLifecycle() val hasError = emailErrorMessage.isNotBlank() - if (emailErrorMessage.isNotBlank()) { + if (hasError) { Text( emailErrorMessage, listItemModifier, @@ -196,105 +173,6 @@ private fun PasswordResetInitiatedView() { ) } -@Composable -private fun ResetPasswordView( - viewModel: PasswordRecoverViewModel = hiltViewModel(), - isEditable: Boolean = false, - isBusy: Boolean = false, -) { - val translator = LocalAppTranslator.current - - Text( - translator("nav.reset_password"), - listItemModifier, - style = LocalFontStyles.current.header3, - ) - - Text( - translator("resetPassword.enter_new_password"), - listItemModifier, - ) - - var isObfuscatingPassword by remember { mutableStateOf(true) } - var isObfuscatingConfirmPassword by remember { mutableStateOf(true) } - val updatePasswordInput = remember(viewModel) { - { s: String -> viewModel.password = s } - } - val updateConfirmPasswordInput = remember(viewModel) { - { s: String -> viewModel.confirmPassword = s } - } - val passwordErrorMessage by viewModel.resetPasswordErrorMessage.collectAsStateWithLifecycle() - val confirmPasswordErrorMessage by viewModel.resetPasswordConfirmErrorMessage.collectAsStateWithLifecycle() - val hasPasswordError = passwordErrorMessage.isNotBlank() - val hasConfirmPasswordError = confirmPasswordErrorMessage.isNotBlank() - - val errorMessage = passwordErrorMessage.ifBlank { confirmPasswordErrorMessage } - if (errorMessage.isNotBlank()) { - Text( - errorMessage, - listItemModifier, - color = primaryRedColor, - ) - } - - OutlinedObfuscatingTextField( - modifier = fillWidthPadded.testTag("resetPasswordTextField"), - label = translator("resetPassword.password"), - value = viewModel.password, - onValueChange = updatePasswordInput, - isObfuscating = isObfuscatingPassword, - onObfuscate = { isObfuscatingPassword = !isObfuscatingPassword }, - enabled = !isBusy, - isError = hasPasswordError, - hasFocus = hasPasswordError, - onNext = viewModel::clearResetPasswordErrors, - imeAction = ImeAction.Next, - ) - - OutlinedObfuscatingTextField( - modifier = fillWidthPadded.testTag("resetPasswordConfirmTextField"), - label = translator("resetPassword.confirm_password"), - value = viewModel.confirmPassword, - onValueChange = updateConfirmPasswordInput, - isObfuscating = isObfuscatingConfirmPassword, - onObfuscate = { isObfuscatingConfirmPassword = !isObfuscatingConfirmPassword }, - enabled = !isBusy, - isError = hasConfirmPasswordError, - hasFocus = hasConfirmPasswordError, - onEnter = viewModel::onResetPassword, - imeAction = ImeAction.Done, - ) - - BusyButton( - modifier = fillWidthPadded.testTag("resetPasswordBtn"), - onClick = viewModel::onResetPassword, - enabled = isEditable, - text = translator("actions.reset"), - indicateBusy = isBusy, - ) -} - -@Composable -private fun PasswordResetNotPossibleView() { - val translator = LocalAppTranslator.current - - Text( - translator("resetPassword.password_reset_not_possible"), - listItemModifier, - ) -} - -@Composable -private fun PasswordResetSuccessfulView() { - val translator = LocalAppTranslator.current - - Text( - translator("resetPassword.password_reset"), - listItemModifier, - style = LocalFontStyles.current.header3, - ) -} - @Composable private fun MagicLinkView( viewModel: PasswordRecoverViewModel = hiltViewModel(), diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt new file mode 100644 index 000000000..959b22596 --- /dev/null +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt @@ -0,0 +1,195 @@ +package com.crisiscleanup.feature.authentication.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.BusyButton +import com.crisiscleanup.core.designsystem.component.OutlinedObfuscatingTextField +import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.fillWidthPadded +import com.crisiscleanup.core.designsystem.theme.listItemModifier +import com.crisiscleanup.core.designsystem.theme.primaryRedColor +import com.crisiscleanup.core.ui.rememberCloseKeyboard +import com.crisiscleanup.core.ui.scrollFlingListener +import com.crisiscleanup.feature.authentication.PasswordRecoverViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResetPasswordRoute( + onBack: () -> Unit = {}, + closeResetPassword: () -> Unit = {}, + viewModel: PasswordRecoverViewModel = hiltViewModel(), +) { + val isPasswordReset by viewModel.isPasswordReset.collectAsStateWithLifecycle() + + val clearStateOnBack = remember(onBack, viewModel, isPasswordReset) { + { + viewModel.clearResetPassword() + + if (isPasswordReset) { + closeResetPassword() + } else { + onBack() + } + } + } + + BackHandler { + clearStateOnBack() + } + + val translator = LocalAppTranslator.current + + val closeKeyboard = rememberCloseKeyboard(viewModel) + + val emailAddress by viewModel.emailAddress.collectAsStateWithLifecycle() + val isEmailDefined = emailAddress != null + val isBusy by viewModel.isBusy.collectAsStateWithLifecycle() + + Column( + Modifier + .scrollFlingListener(closeKeyboard) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + TopAppBarBackAction( + title = translator("actions.reset_password"), + onAction = clearStateOnBack, + modifier = Modifier.testTag("passwordRecoverBackBtn"), + ) + + if (isPasswordReset) { + PasswordResetSuccessfulView() + } else { + val resetToken by viewModel.resetPasswordToken.collectAsStateWithLifecycle() + if (resetToken.isBlank()) { + PasswordResetNotPossibleView() + } else { + ResetPasswordView( + isEditable = isEmailDefined, + isBusy = isBusy, + ) + } + + Spacer(Modifier.height(32.dp)) + } + } +} + +@Composable +private fun PasswordResetSuccessfulView() { + val translator = LocalAppTranslator.current + + Text( + translator("resetPassword.password_reset"), + listItemModifier, + style = LocalFontStyles.current.header3, + ) +} + +@Composable +private fun PasswordResetNotPossibleView() { + val translator = LocalAppTranslator.current + + Text( + translator("resetPassword.password_reset_not_possible"), + listItemModifier, + ) +} + +@Composable +private fun ResetPasswordView( + viewModel: PasswordRecoverViewModel = hiltViewModel(), + isEditable: Boolean = false, + isBusy: Boolean = false, +) { + val translator = LocalAppTranslator.current + + Text( + translator("nav.reset_password"), + listItemModifier, + style = LocalFontStyles.current.header3, + ) + + Text( + translator("resetPassword.enter_new_password"), + listItemModifier, + ) + + var isObfuscatingPassword by remember { mutableStateOf(true) } + var isObfuscatingConfirmPassword by remember { mutableStateOf(true) } + val updatePasswordInput = remember(viewModel) { + { s: String -> viewModel.password = s } + } + val updateConfirmPasswordInput = remember(viewModel) { + { s: String -> viewModel.confirmPassword = s } + } + val passwordErrorMessage by viewModel.resetPasswordErrorMessage.collectAsStateWithLifecycle() + val confirmPasswordErrorMessage by viewModel.resetPasswordConfirmErrorMessage.collectAsStateWithLifecycle() + val hasPasswordError = passwordErrorMessage.isNotBlank() + val hasConfirmPasswordError = confirmPasswordErrorMessage.isNotBlank() + + val errorMessage = passwordErrorMessage.ifBlank { confirmPasswordErrorMessage } + if (errorMessage.isNotBlank()) { + Text( + errorMessage, + listItemModifier, + color = primaryRedColor, + ) + } + + OutlinedObfuscatingTextField( + modifier = fillWidthPadded.testTag("resetPasswordTextField"), + label = translator("resetPassword.password"), + value = viewModel.password, + onValueChange = updatePasswordInput, + isObfuscating = isObfuscatingPassword, + onObfuscate = { isObfuscatingPassword = !isObfuscatingPassword }, + enabled = !isBusy, + isError = hasPasswordError, + hasFocus = hasPasswordError, + onNext = viewModel::clearResetPasswordErrors, + imeAction = ImeAction.Next, + ) + + OutlinedObfuscatingTextField( + modifier = fillWidthPadded.testTag("resetPasswordConfirmTextField"), + label = translator("resetPassword.confirm_password"), + value = viewModel.confirmPassword, + onValueChange = updateConfirmPasswordInput, + isObfuscating = isObfuscatingConfirmPassword, + onObfuscate = { isObfuscatingConfirmPassword = !isObfuscatingConfirmPassword }, + enabled = !isBusy, + isError = hasConfirmPasswordError, + hasFocus = hasConfirmPasswordError, + onEnter = viewModel::onResetPassword, + imeAction = ImeAction.Done, + ) + + BusyButton( + modifier = fillWidthPadded.testTag("resetPasswordBtn"), + onClick = viewModel::onResetPassword, + enabled = isEditable, + text = translator("actions.reset"), + indicateBusy = isBusy, + ) +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt index ce5711709..35bbf0843 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt @@ -47,26 +47,18 @@ import com.crisiscleanup.feature.authentication.RootAuthViewModel fun RootAuthRoute( enableBackHandler: Boolean = false, openLoginWithEmail: () -> Unit = {}, + openLoginWithPhone: () -> Unit = {}, closeAuthentication: () -> Unit = {}, - viewModel: RootAuthViewModel = hiltViewModel(), ) { BackHandler(enableBackHandler) { closeAuthentication() } - // TODO Push route rather than toggling state - val showResetPassword by viewModel.showResetPassword.collectAsStateWithLifecycle(false) - if (showResetPassword) { - PasswordRecoverRoute( - onBack = viewModel::clearResetPassword, - showResetPassword = true, - ) - } else { - RootAuthScreen( - openLoginWithEmail = openLoginWithEmail, - closeAuthentication = closeAuthentication, - ) - } + RootAuthScreen( + openLoginWithEmail = openLoginWithEmail, + openLoginWithPhone = openLoginWithPhone, + closeAuthentication = closeAuthentication, + ) } @Composable @@ -74,6 +66,7 @@ internal fun RootAuthScreen( modifier: Modifier = Modifier, viewModel: RootAuthViewModel = hiltViewModel(), openLoginWithEmail: () -> Unit = {}, + openLoginWithPhone: () -> Unit = {}, closeAuthentication: () -> Unit = {}, ) { val authState by viewModel.authState.collectAsStateWithLifecycle() @@ -110,6 +103,7 @@ internal fun RootAuthScreen( val hasAuthenticated = (authState as AuthState.NotAuthenticated).hasAuthenticated NotAuthenticatedScreen( openLoginWithEmail = openLoginWithEmail, + openLoginWithPhone = openLoginWithPhone, closeAuthentication = closeAuthentication, hasAuthenticated = hasAuthenticated, ) @@ -159,15 +153,15 @@ private fun AuthenticatedScreen( @Composable private fun NotAuthenticatedScreen( openLoginWithEmail: () -> Unit = {}, + openLoginWithPhone: () -> Unit = {}, closeAuthentication: () -> Unit = {}, hasAuthenticated: Boolean = false, - viewModel: AuthenticationViewModel = hiltViewModel(), ) { val translator = LocalAppTranslator.current val uriHandler = LocalUriHandler.current val registerHereLink = "https://crisiscleanup.org/register" val iNeedHelpCleaningLink = "https://crisiscleanup.org/survivor" - val closeKeyboard = rememberCloseKeyboard(viewModel) + val closeKeyboard = rememberCloseKeyboard(openLoginWithEmail) Column( Modifier @@ -198,8 +192,7 @@ private fun NotAuthenticatedScreen( modifier = Modifier .fillMaxWidth() .testTag("loginLoginWithPhoneBtn"), - onClick = {}, - enabled = false, // !isBusy, + onClick = openLoginWithPhone, text = translator("loginForm.login_with_cell", R.string.loginWithPhone), ) CrisisCleanupOutlinedButton( @@ -207,7 +200,7 @@ private fun NotAuthenticatedScreen( .fillMaxWidth() .testTag("loginVolunteerWithOrgBtn"), onClick = {}, - enabled = false, // !isBusy, + enabled = !hasAuthenticated, text = translator( "actions.request_access", R.string.volunteerWithYourOrg, @@ -273,8 +266,8 @@ private fun NotAuthenticatedScreen( @DayNightPreviews @Composable -private fun RootLoginScreenPreview() { +private fun NotAuthenticatedScreenPreview() { CrisisCleanupTheme { - RootAuthScreen() + NotAuthenticatedScreen() } } diff --git a/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt b/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt index 04753c5af..8b9d2ed35 100644 --- a/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt +++ b/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt @@ -1,6 +1,7 @@ package com.crisiscleanup.feature.authentication import com.crisiscleanup.core.common.AndroidResourceProvider +import com.crisiscleanup.core.common.AppEnv import com.crisiscleanup.core.common.InputValidator import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.event.AuthEventBus @@ -81,6 +82,17 @@ class AuthenticationViewModelTest { // private val passwordCredentialsStream = MutableSharedFlow(0) + private val testAppEnv = object : AppEnv { + override val isDebuggable = false + override val isProduction = true + override val isNotProduction = false + override val isEarlybird = false + override val apiEnvironment = "" + + override fun runInNonProd(block: () -> Unit) { + } + } + @Before fun setUp() { MockKAnnotations.init(this) @@ -106,8 +118,6 @@ class AuthenticationViewModelTest { UserData( darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, shouldHideOnboarding = false, - saveCredentialsPromptCount = 0, - disableSaveCredentialsPrompt = false, syncAttempt = SyncAttempt(0, 0, 0), selectedIncidentId = 0, languageKey = EnglishLanguage.key, @@ -116,10 +126,6 @@ class AuthenticationViewModelTest { ), ) - coEvery { - appPreferences.incrementSaveCredentialsPrompt() - } returns Unit - // every { // authEventBus.passwordCredentialResults // } returns passwordCredentialsStream @@ -154,6 +160,7 @@ class AuthenticationViewModelTest { // appPreferences, translator, // resProvider, + testAppEnv, UnconfinedTestDispatcher(), appLogger, ) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseShareViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseShareViewModel.kt index eb61867d4..11d53cd89 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseShareViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseShareViewModel.kt @@ -66,7 +66,7 @@ class CaseShareViewModel @Inject constructor( ) val hasClaimedWorkType = organizationId.map { orgId -> - val affiliatedOrgIds = organizationsRepository.getOrganizationAffiliateIds(orgId) + val affiliatedOrgIds = organizationsRepository.getOrganizationAffiliateIds(orgId, true) val claimedBys = worksiteIn.workTypes.mapNotNull(WorkType::orgClaim).toSet() val isClaimed = claimedBys.any { claimedBy -> affiliatedOrgIds.contains( diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesTableViewDataLoader.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesTableViewDataLoader.kt index f69ff1f49..6a126a0f3 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesTableViewDataLoader.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesTableViewDataLoader.kt @@ -112,7 +112,7 @@ class CasesTableViewDataLoader( val myOrg = accountDataRepository.accountData.first().org val myOrgId = myOrg.id - val affiliateIds = organizationsRepository.getOrganizationAffiliateIds(myOrgId) + val affiliateIds = organizationsRepository.getOrganizationAffiliateIds(myOrgId, true) val claimStatus = worksite.getClaimStatus(affiliateIds) diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt index 0e72633d0..62c63cb0c 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt @@ -3,6 +3,9 @@ package com.crisiscleanup.sync import android.content.Context import com.crisiscleanup.core.common.NetworkMonitor import com.crisiscleanup.core.common.di.ApplicationScope +import com.crisiscleanup.core.common.log.AppLogger +import com.crisiscleanup.core.common.log.CrisisCleanupLoggers +import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.common.sync.SyncLogger @@ -44,6 +47,7 @@ class AppSyncer @Inject constructor( private val statusRepository: WorkTypeStatusRepository, private val worksiteChangeRepository: WorksiteChangeRepository, private val appPreferences: LocalAppPreferencesDataSource, + @Logger(CrisisCleanupLoggers.Sync) private val appLogger: AppLogger, private val syncLogger: SyncLogger, private val networkMonitor: NetworkMonitor, @ApplicationContext private val context: Context, @@ -122,6 +126,7 @@ class AppSyncer @Inject constructor( scheduleSyncWorksitesFull() } catch (e: Exception) { syncLogger.log("Sync pull fail. ${e.message}".trim()) + appLogger.logException(e) return SyncResult.Error(e.message ?: "Sync fail") }