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")
}