diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 17aa68374..7acdabb0a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 264 + val buildVersion = 268 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/MainActivity.kt b/app/src/main/java/com/crisiscleanup/MainActivity.kt index 958c6ffcb..0af36a588 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivity.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivity.kt @@ -1,6 +1,7 @@ package com.crisiscleanup import android.content.Intent +import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle @@ -15,7 +16,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.toArgb import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle @@ -38,7 +38,6 @@ import com.crisiscleanup.core.data.repository.EndOfLifeRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.LocalAppMetricsRepository import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme -import com.crisiscleanup.core.designsystem.theme.navigationContainerColor import com.crisiscleanup.core.model.data.DarkThemeConfig import com.crisiscleanup.sync.initializers.scheduleSyncWorksites import com.crisiscleanup.ui.CrisisCleanupApp @@ -127,10 +126,9 @@ class MainActivity : ComponentActivity() { windowSizeClass = windowSizeClass, ) - val barColor = navigationContainerColor.toArgb() enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(barColor), - navigationBarStyle = SystemBarStyle.dark(barColor), + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), ) CompositionLocalProvider { diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index b80f25ed9..6897f1077 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -21,6 +21,7 @@ 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.SyncPuller +import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.common.throttleLatest import com.crisiscleanup.core.data.IncidentSelector import com.crisiscleanup.core.data.repository.AccountDataRefresher @@ -67,6 +68,7 @@ class MainActivityViewModel @Inject constructor( val tutorialViewTracker: TutorialViewTracker, val translator: KeyResourceTranslator, private val syncPuller: SyncPuller, + private val syncPusher: SyncPusher, appSettingsProvider: AppSettingsProvider, private val appEnv: AppEnv, firebaseAnalytics: FirebaseAnalytics, @@ -205,6 +207,8 @@ class MainActivityViewModel @Inject constructor( syncPuller.appPullLanguage() syncPuller.appPullStatuses() + syncPusher.scheduleSyncMedia() + accountDataRepository.accountData .mapLatest { it.hasAcceptedTerms } .filter { !it } @@ -263,10 +267,9 @@ class MainActivityViewModel @Inject constructor( return } - if (isUpdatingTermsAcceptance.value) { + if (!isUpdatingTermsAcceptance.compareAndSet(expect = false, update = true)) { return } - isUpdatingTermsAcceptance.value = true viewModelScope.launch(ioDispatcher) { try { val isAccepted = accountUpdateRepository.acceptTerms() diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt index 5f129c68a..2fd74772b 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt @@ -58,6 +58,13 @@ fun CrisisCleanupAuthNavHost( navController.navigateToLoginWithPhone() } } + val navToLogin = navController::popToAuth + val navToForgotPasswordClearStack = remember(navController) { + { + navController.popToAuth() + navController.navigateToForgotPassword() + } + } NavHost( navController = navController, @@ -110,8 +117,11 @@ fun CrisisCleanupAuthNavHost( closeAuthentication = closeAuthentication, ) requestAccessScreen( + false, onBack = onBack, closeRequestAccess = navToLoginWithEmail, + openAuth = navToAuth, + openForgotPassword = navToForgotPasswordClearStack, ) orgPersistentInviteScreen( onBack = onBack, diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index e171b0dab..837438f21 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -22,6 +22,7 @@ import com.crisiscleanup.core.appnav.navigateToExistingCase import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.EmptyWorksite +import com.crisiscleanup.feature.authentication.navigation.requestAccessScreen import com.crisiscleanup.feature.authentication.navigation.resetPasswordScreen import com.crisiscleanup.feature.caseeditor.navigation.caseAddFlagScreen import com.crisiscleanup.feature.caseeditor.navigation.caseEditMoveLocationOnMapScreen @@ -246,5 +247,13 @@ fun CrisisCleanupNavHost( onBack = onBack, closeResetPassword = onBack, ) + + requestAccessScreen( + true, + onBack = onBack, + closeRequestAccess = onBack, + openAuth = {}, + openForgotPassword = {}, + ) } } diff --git a/app/src/main/java/com/crisiscleanup/ui/AppNavigation.kt b/app/src/main/java/com/crisiscleanup/ui/AppNavigation.kt index 1d1111e82..b8ff6137e 100644 --- a/app/src/main/java/com/crisiscleanup/ui/AppNavigation.kt +++ b/app/src/main/java/com/crisiscleanup/ui/AppNavigation.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -42,17 +41,18 @@ private fun TopLevelDestination.Icon(isSelected: Boolean, description: String) { } else { unselectedIcon } + var tint = LocalContentColor.current + if (!isSelected) { + tint = tint.disabledAlpha() + } when (icon) { is Icon.ImageVectorIcon -> Icon( imageVector = icon.imageVector, contentDescription = description, + tint = tint, ) is Icon.DrawableResourceIcon -> { - var tint = LocalContentColor.current - if (isSelected) { - tint = Color.White - } Icon( painter = painterResource(id = icon.id), contentDescription = description, diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index e7f78b8b6..1d210ffa8 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -58,6 +59,7 @@ import com.crisiscleanup.core.designsystem.component.CrisisCleanupAlertDialog import com.crisiscleanup.core.designsystem.component.CrisisCleanupBackground import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton import com.crisiscleanup.core.designsystem.theme.LocalDimensions +import com.crisiscleanup.core.designsystem.theme.navigationContainerColor import com.crisiscleanup.core.model.data.TutorialViewId import com.crisiscleanup.core.ui.AppLayoutArea import com.crisiscleanup.core.ui.LayoutSizePosition @@ -142,6 +144,8 @@ private fun BoxScope.LoadedContent( var openAuthentication by rememberSaveable { mutableStateOf(isNotAuthenticatedState) } val showPasswordReset by viewModel.showPasswordReset.collectAsStateWithLifecycle(false) + val orgUserInviteCode by viewModel.orgUserInvites.collectAsStateWithLifecycle("") + val showOrgInviteTransfer = orgUserInviteCode.isNotBlank() if (openAuthentication || isNotAuthenticatedState @@ -158,19 +162,22 @@ private fun BoxScope.LoadedContent( if (isNotAuthenticatedState) { val showMagicLinkLogin by viewModel.showMagicLinkLogin.collectAsStateWithLifecycle(false) - val orgUserInviteCode by viewModel.orgUserInvites.collectAsStateWithLifecycle("") val orgPersistentInvite by viewModel.orgPersistentInvites.collectAsStateWithLifecycle() - if (showPasswordReset) { - LaunchedEffect(Unit) { - appState.navController.navigateToPasswordReset(false) + with(appState.navController) { + if (showPasswordReset) { + LaunchedEffect(Unit) { + navigateToPasswordReset(false) + } + } else if (showMagicLinkLogin) { + navigateToMagicLinkLogin() + } else if (showOrgInviteTransfer) { + LaunchedEffect(Unit) { + navigateToRequestAccess(orgUserInviteCode, false) + } + } else if (orgPersistentInvite.isValidInvite) { + navigateToOrgPersistentInvite() } - } else if (showMagicLinkLogin) { - appState.navController.navigateToMagicLinkLogin() - } else if (orgUserInviteCode.isNotBlank()) { - appState.navController.navigateToRequestAccess(orgUserInviteCode) - } else if (orgPersistentInvite.isValidInvite) { - appState.navController.navigateToOrgPersistentInvite() } } } else if (!hasAcceptedTerms) { @@ -225,9 +232,15 @@ private fun BoxScope.LoadedContent( } } - if (showPasswordReset) { - LaunchedEffect(Unit) { - appState.navController.navigateToPasswordReset(true) + with(appState.navController) { + if (showPasswordReset) { + LaunchedEffect(Unit) { + navigateToPasswordReset(true) + } + } else if (showOrgInviteTransfer) { + LaunchedEffect(Unit) { + navigateToRequestAccess(orgUserInviteCode, true) + } } } } @@ -340,9 +353,11 @@ private fun NavigableContent( } Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, + modifier = Modifier + .background(navigationContainerColor) + .semantics { + testTagsAsResourceId = true + }, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, contentWindowInsets = WindowInsets(0, 0, 0, 0), @@ -396,7 +411,7 @@ private fun NavigableContent( } val isKeyboardOpen = rememberIsKeyboardOpen() - Column { + Column(Modifier.background(Color.White)) { val snackbarAreaHeight = if (!showNavigation && snackbarHostState.currentSnackbarData != null && diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index e57a60873..796c6a357 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -28,7 +28,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension -internal const val DefaultConfigTargetSdk = 35 +internal const val DefaultConfigTargetSdk = 36 /** * Configure base Kotlin with Android options 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 da35f70d3..d58d6518a 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 @@ -12,7 +12,7 @@ object RouteConstant { const val AUTH_RESET_PASSWORD_ROUTE = "$AUTH_ROUTE/auth_reset_password_route" const val MAGIC_LINK_ROUTE = "$AUTH_ROUTE/magic_link_login" - const val REQUEST_ACCESS_ROUTE = "$AUTH_ROUTE/request_access" + const val AUTH_REQUEST_ACCESS_ROUTE = "$AUTH_ROUTE/request_access" const val ORG_PERSISTENT_INVITE_ROUTE = "$AUTH_ROUTE/org_persistent_invite" const val VOLUNTEER_ORG_ROUTE = "$AUTH_ROUTE/volunteer_org" @@ -51,6 +51,7 @@ object RouteConstant { const val WORKSITE_IMAGES_ROUTE = "worksite_images" const val ACCOUNT_RESET_PASSWORD_ROUTE = "account_reset_password_route" + const val ACCOUNT_TRANSFER_ORG_ROUTE = "account_transfer_org" const val LISTS_ROUTE = "crisis_cleanup_lists" const val VIEW_LIST_ROUTE = "view_list" 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 6c5fd8365..0c545f92a 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 @@ -16,6 +16,10 @@ interface AccountUpdateRepository { suspend fun initiatePasswordReset(emailAddress: String): PasswordResetInitiation suspend fun changePassword(password: String, token: String): Boolean suspend fun acceptTerms(): Boolean + suspend fun acceptOrganizationChange( + action: ChangeOrganizationAction, + invitationToken: String, + ): Boolean } class CrisisCleanupAccountUpdateRepository @Inject constructor( @@ -75,4 +79,21 @@ class CrisisCleanupAccountUpdateRepository @Inject constructor( } return false } + + override suspend fun acceptOrganizationChange( + action: ChangeOrganizationAction, + invitationToken: String, + ): Boolean { + try { + return accountApi.moveToOrganization(action.literal, invitationToken) + } catch (e: Exception) { + logger.logException(e) + } + return false + } +} + +enum class ChangeOrganizationAction(val literal: String) { + All("all"), + Users("users"), } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt index 7c271f893..d43778856 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt @@ -81,4 +81,8 @@ class AppPreferencesRepository @Inject constructor( override suspend fun setWorkScreenView(isTableView: Boolean) { preferencesDataSource.saveWorkScreenView(isTableView) } + + override suspend fun setSyncMediaImmediate(syncImmediate: Boolean) { + preferencesDataSource.saveSyncMediaImmediate(syncImmediate) + } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CasesFilterRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CasesFilterRepository.kt index 05fdbe7fa..e278102a0 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CasesFilterRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CasesFilterRepository.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import javax.inject.Inject import javax.inject.Singleton @@ -25,7 +25,7 @@ interface CasesFilterRepository { val casesFiltersLocation: StateFlow> val filtersCount: Flow - fun changeFilters(filters: CasesFilter) + suspend fun changeFilters(filters: CasesFilter) fun updateWorkTypeFilters(workTypes: Collection) fun reapplyFilters() } @@ -60,10 +60,8 @@ class CrisisCleanupCasesFilterRepository @Inject constructor( // TODO Update or clear work type filters when incident changes - override fun changeFilters(filters: CasesFilter) { - externalScope.launch(ioDispatcher) { - dataSource.updateFilters(filters) - } + override suspend fun changeFilters(filters: CasesFilter) = withContext(ioDispatcher) { + dataSource.updateFilters(filters) } override fun updateWorkTypeFilters(workTypes: Collection) { diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt index 80a628bd9..f7ca42221 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt @@ -40,4 +40,6 @@ interface LocalAppPreferencesRepository { suspend fun setTeamMapBounds(bounds: IncidentCoordinateBounds) suspend fun setWorkScreenView(isTableView: Boolean) + + suspend fun setSyncMediaImmediate(syncImmediate: Boolean) } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt index 145ec559b..2b081aa8b 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt @@ -1,5 +1,6 @@ package com.crisiscleanup.core.data.repository +import com.crisiscleanup.core.common.InputValidator import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger @@ -46,6 +47,7 @@ class OfflineFirstIncidentsRepository @Inject constructor( private val incidentOrganizationDao: IncidentOrganizationDao, private val incidentOrganizationsSyncer: IncidentOrganizationsSyncer, private val appPreferences: LocalAppPreferencesDataSource, + inputValidator: InputValidator, @Logger(CrisisCleanupLoggers.Incidents) private val logger: AppLogger, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, ) : IncidentsRepository { @@ -84,9 +86,22 @@ class OfflineFirstIncidentsRepository @Inject constructor( incidentDao.streamIncidents().mapLatest { it.map(PopulatedIncident::asExternalModel) } override val hotlineIncidents = incidents.mapLatest { - it.filter { incident -> - incident.activePhoneNumbers.isNotEmpty() - } + it + .filter { incident -> + incident.activePhoneNumbers.isNotEmpty() + } + .map { incident -> + incident.copy( + activePhoneNumbers = incident.activePhoneNumbers.map { phoneNumber -> + val validation = inputValidator.validatePhoneNumber(phoneNumber) + if (validation.isValid) { + validation.formatted + } else { + phoneNumber + } + }, + ) + } } override suspend fun getIncident(id: Long, loadFormFields: Boolean) = diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto index ffb17b679..6579db767 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto @@ -42,4 +42,6 @@ message UserPreferences { IncidentMapBoundsProto team_map_bounds = 14; bool is_work_screen_table_view = 15; + + bool sync_media_immediate = 16; } diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt index efa073c2f..4f49a6f30 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt @@ -60,6 +60,8 @@ class LocalAppPreferencesDataSource @Inject constructor( teamMapBounds = it.teamMapBounds.asExternalModel(), isWorkScreenTableView = it.isWorkScreenTableView, + + isSyncMediaImmediate = it.syncMediaImmediate, ) } @@ -192,6 +194,12 @@ class LocalAppPreferencesDataSource @Inject constructor( it.copy { isWorkScreenTableView = isTableView } } } + + suspend fun saveSyncMediaImmediate(syncImmediate: Boolean) { + userPreferences.updateData { + it.copy { syncMediaImmediate = syncImmediate } + } + } } private fun IncidentMapBoundsProto.asExternalModel() = IncidentCoordinateBounds( diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HelpDialog.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HelpDialog.kt index 7be0e6298..0e0bf002e 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HelpDialog.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HelpDialog.kt @@ -38,12 +38,14 @@ fun HelpRow( @Composable fun HelpAction( helpHint: String, - showHelp: () -> Unit, + onShowHelp: () -> Unit, + modifier: Modifier = Modifier, ) { CrisisCleanupIconButton( - onClick = showHelp, + modifier = modifier, imageVector = CrisisCleanupIcons.Help, contentDescription = helpHint, + onClick = onShowHelp, ) } diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HotlineView.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HotlineView.kt index 835c804c8..6a27e4694 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HotlineView.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HotlineView.kt @@ -3,6 +3,7 @@ package com.crisiscleanup.core.designsystem.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -59,10 +60,12 @@ fun HotlineIncidentView( com.crisiscleanup.core.designsystem.R.style.link_text_style_black, ) } else { - Text( - text, - modifier, - style = style, - ) + SelectionContainer { + Text( + text, + modifier, + style = style, + ) + } } } diff --git a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerColor.kt b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerColor.kt index eb55be28b..c6e84d8a2 100644 --- a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerColor.kt +++ b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerColor.kt @@ -72,6 +72,24 @@ private const val FILTERED_OUT_DOT_STROKE_ALPHA = 0.5f private const val FILTERED_OUT_DOT_FILL_ALPHA = 0.1f private const val DUPLICATE_MARKER_ALPHA = 0.3f +private fun getMapMarkerColors( + statusClaim: WorkTypeStatusClaim, + isVisited: Boolean = false, +): MapMarkerColor { + var colors = statusClaimMapMarkerColors[statusClaim] + if (colors == null) { + val status = statusClaimToStatus[statusClaim] + colors = statusMapMarkerColors[status] ?: statusMapMarkerColors[Unknown]!! + } + if (isVisited) { + colors = MapMarkerColor( + colors.fillLong, + strokeLong = visitedCaseMarkerColorCode, + ) + } + return colors +} + internal fun getMapMarkerColors( statusClaim: WorkTypeStatusClaim, isDuplicate: Boolean, @@ -80,18 +98,12 @@ internal fun getMapMarkerColors( isVisited: Boolean, isDot: Boolean, ): MapMarkerColor { - var colors = statusClaimMapMarkerColors[statusClaim] - if (colors == null) { - val status = statusClaimToStatus[statusClaim] - colors = statusMapMarkerColors[status] ?: statusMapMarkerColors[Unknown]!! - } + var colors = getMapMarkerColors( + statusClaim, + isVisited && !(isDuplicate || isMarkedForDelete || isFilteredOut), + ) - if (isDuplicate || isMarkedForDelete) { - colors = colors.copy( - fill = colors.fill.copy(alpha = DUPLICATE_MARKER_ALPHA), - stroke = colors.stroke.copy(alpha = DUPLICATE_MARKER_ALPHA), - ) - } else if (isFilteredOut) { + if (isFilteredOut) { val fillAlpha = if (isDot) FILTERED_OUT_DOT_FILL_ALPHA else FILTERED_OUT_MARKER_FILL_ALPHA val strokeAlpha = if (isDot) FILTERED_OUT_DOT_STROKE_ALPHA else FILTERED_OUT_MARKER_STROKE_ALPHA @@ -102,10 +114,10 @@ internal fun getMapMarkerColors( stroke = it.stroke.copy(alpha = strokeAlpha), ) } - } else if (isVisited) { - colors = MapMarkerColor( - colors.fillLong, - strokeLong = visitedCaseMarkerColorCode, + } else if (isDuplicate || isMarkedForDelete) { + colors = colors.copy( + fill = colors.fill.copy(alpha = DUPLICATE_MARKER_ALPHA), + stroke = colors.stroke.copy(alpha = DUPLICATE_MARKER_ALPHA), ) } diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt index 1cfd1b41f..60dcf33bd 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt @@ -11,6 +11,8 @@ data class OrgUserInviteInfo( val orgName: String, val expiration: Instant, val isExpiredInvite: Boolean, + val isExistingUser: Boolean, + val fromOrgName: String = "", ) val ExpiredNetworkOrgInvite = OrgUserInviteInfo( @@ -21,4 +23,5 @@ val ExpiredNetworkOrgInvite = OrgUserInviteInfo( orgName = "", expiration = Instant.fromEpochSeconds(0), isExpiredInvite = true, + isExistingUser = false, ) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt index 405179faa..68ca232d0 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt @@ -27,4 +27,6 @@ data class UserData( val teamMapBounds: IncidentCoordinateBounds, val isWorkScreenTableView: Boolean, + + val isSyncMediaImmediate: Boolean, ) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAccountApi.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAccountApi.kt index 763923557..8a5e9241a 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAccountApi.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAccountApi.kt @@ -18,4 +18,9 @@ interface CrisisCleanupAccountApi { ): Boolean suspend fun acceptTerms(userId: Long, timestamp: Instant = Clock.System.now()): Boolean + + suspend fun moveToOrganization( + action: String, + token: String, + ): Boolean } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt index 5578e3871..914451b38 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt @@ -1,5 +1,6 @@ package com.crisiscleanup.core.network.model +import com.crisiscleanup.core.network.model.util.NetworkOrganizationShortDeserializer import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -14,6 +15,7 @@ data class NetworkAccountProfileResult( @SerialName("accepted_terms_timestamp") val acceptedTermsTimestamp: Instant?, val files: List?, + @Serializable(NetworkOrganizationShortDeserializer::class) val organization: NetworkOrganizationShort?, @SerialName("active_roles") val activeRoles: Set?, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationInfo.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationInfo.kt index 78f32c675..b8bd8ac4a 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationInfo.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationInfo.kt @@ -20,7 +20,13 @@ data class NetworkInvitationInfo( val organization: Long, @SerialName("invited_by") val inviter: NetworkInviterInfo, -) + @SerialName("existing_user") + val existingUser: NetworkInviteeInfo?, +) { + val isExistingUser by lazy { + (existingUser?.id ?: 0) > 0 + } +} @Serializable data class NetworkInviterInfo( @@ -33,3 +39,9 @@ data class NetworkInviterInfo( @SerialName("mobile") val phone: String, ) + +@Serializable +data class NetworkInviteeInfo( + val id: Long, + val organization: Long, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkTransferOrganizationPayload.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkTransferOrganizationPayload.kt new file mode 100644 index 000000000..c9e6faec5 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkTransferOrganizationPayload.kt @@ -0,0 +1,17 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkTransferOrganizationPayload( + @SerialName("transfer_action") + val action: String, + @SerialName("invitation_token") + val token: String, +) + +@Serializable +data class NetworkTransferOrganizationResult( + val status: String, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/util/NetworkOrganizationShortDeserializer.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/util/NetworkOrganizationShortDeserializer.kt new file mode 100644 index 000000000..51670b7f7 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/util/NetworkOrganizationShortDeserializer.kt @@ -0,0 +1,45 @@ +package com.crisiscleanup.core.network.model.util + +import com.crisiscleanup.core.network.model.NetworkOrganizationShort +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.long + +class NetworkOrganizationShortDeserializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("com.crisiscleanup.NetworkOrganizationShort") + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize(encoder: Encoder, value: NetworkOrganizationShort?) { + throw SerializationException("Not for deserializing") + } + + override fun deserialize(decoder: Decoder): NetworkOrganizationShort? { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("NetworkOrganizationShortDeserializer only supports JSON decoding") + + val element = jsonDecoder.decodeJsonElement() + + return when (element) { + is JsonNull -> null + is JsonPrimitive -> NetworkOrganizationShort(element.long, "") + is JsonObject -> { + jsonDecoder.json.decodeFromJsonElement( + NetworkOrganizationShort.serializer(), + element, + ) + } + + else -> throw SerializationException("Unexpected JSON for NetworkOrganizationShort: $element") + } + } +} 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 004081fb1..7a80ab765 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 @@ -12,6 +12,8 @@ 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 com.crisiscleanup.core.network.model.NetworkTransferOrganizationPayload +import com.crisiscleanup.core.network.model.NetworkTransferOrganizationResult import kotlinx.datetime.Instant import retrofit2.Retrofit import retrofit2.http.Body @@ -56,6 +58,11 @@ private interface AccountApi { @Path("userId") userId: Long, @Body acceptTermsPayload: NetworkAcceptTermsPayload, ): NetworkAccountProfileResult + + @POST("transfer_requests/invitation") + suspend fun transferOrganization( + @Body transferOrganizationPayload: NetworkTransferOrganizationPayload, + ): NetworkTransferOrganizationResult } class AccountApiClient @Inject constructor( @@ -100,4 +107,13 @@ class AccountApiClient @Inject constructor( val result = accountApi.acceptTerms(userId, payload) return result.hasAcceptedTerms == true } + + override suspend fun moveToOrganization(action: String, token: String): Boolean { + val payload = NetworkTransferOrganizationPayload( + action = action, + token = token, + ) + val result = accountApi.transferOrganization(payload) + return result.status == "accepted" + } } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt index a6d47c4f2..c642889fb 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt @@ -140,11 +140,13 @@ class RegisterApiClient @Inject constructor( return null } + private suspend fun getOrganizationName(orgId: Long) = networkApi.noAuthOrganization(orgId).name + private suspend fun getUserDetails(userId: Long): UserDetails { val userInfo = networkApi.noAuthUser(userId) val displayName = "${userInfo.firstName} ${userInfo.lastName}" val avatarUrl = userInfo.files.profilePictureUrl?.let { URL(it) } - val orgName = networkApi.noAuthOrganization(userInfo.organization).name + val orgName = getOrganizationName(userInfo.organization) return UserDetails( displayName = displayName, organizationName = orgName, @@ -167,6 +169,7 @@ class RegisterApiClient @Inject constructor( orgName = userDetails.organizationName, expiration = persistentInvite.expiresAt, isExpiredInvite = false, + isExistingUser = false, ) } @@ -181,6 +184,9 @@ class RegisterApiClient @Inject constructor( val inviter = invite.inviter val userDetails = getUserDetails(inviter.id) + val orgName = invite.existingUser?.organization?.let { orgId -> + getOrganizationName(orgId) + } ?: "" return OrgUserInviteInfo( displayName = "${inviter.firstName} ${inviter.lastName}", inviterEmail = inviter.email, @@ -189,6 +195,8 @@ class RegisterApiClient @Inject constructor( orgName = userDetails.organizationName, expiration = invite.expiresAt, isExpiredInvite = false, + isExistingUser = invite.isExistingUser, + fromOrgName = orgName, ) } diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkProfileTest.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkProfileTest.kt new file mode 100644 index 000000000..3d132419a --- /dev/null +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkProfileTest.kt @@ -0,0 +1,82 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.datetime.Instant +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NetworkProfileTest { + @Test + fun profileNoAuthResult() { + val account = TestUtil.decodeResource("/getProfileAuth.json") + + assertEquals(setOf(291L), account.approvedIncidents) + assertEquals(true, account.hasAcceptedTerms) + assertEquals(Instant.parse("2025-07-26T19:40:22Z"), account.acceptedTermsTimestamp) + assertEquals( + listOf( + NetworkFile( + id = 920, + blogUrl = "blog-image", + createdAt = Instant.parse("2022-06-17T23:47:21.119619Z"), + file = 728, + fileTypeT = "fileTypes.user_profile_picture", + fileName = "Screenshot 2023-01-09 at 11.31.21 AM-abc.png", + filenameOriginal = "Screenshot 2023-01-09 at 11.31.21 AM.png", + fullUrl = "full-url", + largeThumbnailUrl = "large-thumbnail", + mimeContentType = "image/png", + smallThumbnailUrl = "small-thumbnail", + url = "url-file", + ), + ), + account.files, + ) + assertEquals( + NetworkOrganizationShort( + id = 9, + name = "Test org", + isActive = true, + ), + account.organization, + ) + assertEquals(setOf(7), account.activeRoles) + } + + @Test + fun profileAuthResult() { + val account = TestUtil.decodeResource("/getProfileNoAuth.json", true) + + assertNull(account.approvedIncidents) + assertNull(account.hasAcceptedTerms) + assertNull(account.acceptedTermsTimestamp) + assertEquals( + listOf( + NetworkFile( + id = 920, + blogUrl = "blog-image", + createdAt = Instant.parse("2022-06-17T23:47:21.119619Z"), + file = 728, + fileTypeT = "fileTypes.user_profile_picture", + fileName = "Screenshot 2023-01-09 at 11.31.21 AM-abc.png", + filenameOriginal = "Screenshot 2023-01-09 at 11.31.21 AM.png", + fullUrl = "full-url", + largeThumbnailUrl = "large-thumbnail", + mimeContentType = "image/png", + smallThumbnailUrl = "small-thumbnail", + url = "url-file", + ), + ), + account.files, + ) + assertEquals( + NetworkOrganizationShort( + id = 9, + name = "", + isActive = null, + ), + account.organization, + ) + assertNull(account.activeRoles) + } +} diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt index 05814b546..81d6646e5 100644 --- a/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt @@ -6,11 +6,20 @@ import kotlinx.serialization.json.Json object TestUtil { val json = Json { ignoreUnknownKeys = true } + val jsonMinimal = Json { + explicitNulls = false + ignoreUnknownKeys = true + } + fun loadFile(filePath: String) = TestUtil::class.java.getResource(filePath)?.readText()!! - inline fun decodeResource(filePath: String) = - json.decodeFromString(loadFile(filePath)) + inline fun decodeResource(filePath: String, ignoreUnknownKeys: Boolean = false) = + if (ignoreUnknownKeys) { + jsonMinimal.decodeFromString(loadFile(filePath)) + } else { + json.decodeFromString(loadFile(filePath)) + } } internal fun fillNetworkIncident( diff --git a/core/network/src/test/resources/getProfileAuth.json b/core/network/src/test/resources/getProfileAuth.json new file mode 100644 index 000000000..d7c17f760 --- /dev/null +++ b/core/network/src/test/resources/getProfileAuth.json @@ -0,0 +1,102 @@ +{ + "id": 9, + "email": "test@test.test", + "permissions": { + "create_incident": true, + "approve_orgs_full": true, + "approve_orgs_preliminary": true, + "edit_portal_settings": true, + "affiliate_org": true, + "invite_users": true, + "invite_orgs": true, + "remove_workers": true, + "phone_agent": true, + "advanced_maps": false, + "translate": true, + "support_agent": true, + "manage_crew": false, + "manage_cases": true, + "view_user_contacts": true, + "submit_edu": true, + "view_sensitive": "inherit", + "move_orgs": true, + "view_own_volunteer_locations": true, + "view_other_volunteer_locations": true, + "move_other_users_worksites": false, + "approve_new_user_requests": true, + "approve_work_type_transfers": true, + "receive_work_type_transfer_requests": true, + "unclaim_from_anyone": true, + "access_all_incidents": true, + "manage_team": true, + "is_public_facing": true, + "read_work_details": true, + "write_work_details": true, + "move_other_org_worksites": true, + "approve_organization_redeploy": true + }, + "first_name": "Test", + "last_name": "User", + "states": {}, + "preferences": { + "dashboard": "phone-volunteer", + "hideAppBanner": true, + "hideTrainingBanner": true + }, + "organization": { + "id": 9, + "name": "Test org", + "is_active": true, + "roles": [], + "type_t": "orgType.survivor_client_services", + "affiliates": [1], + "all_affiliates_and_groups": [1], + "primary_location": 415, + "secondary_location": 345 + }, + "roles": [ + 7 + ], + "active_roles": [ + 7 + ], + "pending_roles": [], + "mobile": "1234567890", + "social": null, + "lineage": [ + 1, + null + ], + "primary_language": 1, + "secondary_language": null, + "files": [ + { + "tag": null, + "title": null, + "notes": null, + "created_at": "2022-06-17T23:47:21.119619Z", + "file_updated_at": "2021-04-28T19:23:36Z", + "id": 920, + "file": 728, + "filename": "Screenshot 2023-01-09 at 11.31.21 AM-abc.png", + "url": "url-file", + "full_url": "full-url", + "general_file_url": "general-file", + "blog_url": "blog-image", + "large_blog_url": "large-blog-image", + "large_thumbnail_url": "large-thumbnail", + "small_thumbnail_url": "small-thumbnail", + "filename_original": "Screenshot 2023-01-09 at 11.31.21 AM.png", + "file_type_t": "fileTypes.user_profile_picture", + "mime_content_type": "image/png" + } + ], + "referring_user": 1, + "accepted_terms": true, + "accepted_terms_timestamp": "2025-07-26T19:40:22Z", + "beta_features": [], + "approved_incidents": [ + 291 + ], + "mobile_dnis_verified_at": null +} \ No newline at end of file diff --git a/core/network/src/test/resources/getProfileNoAuth.json b/core/network/src/test/resources/getProfileNoAuth.json new file mode 100644 index 000000000..c452a44d7 --- /dev/null +++ b/core/network/src/test/resources/getProfileNoAuth.json @@ -0,0 +1,29 @@ +{ + "id": 9, + "first_name": "Test", + "last_name": "User", + "referring_user": 1, + "organization": 9, + "files": [ + { + "tag": null, + "title": null, + "notes": null, + "created_at": "2022-06-17T23:47:21.119619Z", + "file_updated_at": "2021-04-28T19:23:36Z", + "id": 920, + "file": 728, + "filename": "Screenshot 2023-01-09 at 11.31.21 AM-abc.png", + "url": "url-file", + "full_url": "full-url", + "general_file_url": "general-file", + "blog_url": "blog-image", + "large_blog_url": "large-blog-image", + "large_thumbnail_url": "large-thumbnail", + "small_thumbnail_url": "small-thumbnail", + "filename_original": "Screenshot 2023-01-09 at 11.31.21 AM.png", + "file_type_t": "fileTypes.user_profile_picture", + "mime_content_type": "image/png" + } + ] +} \ No newline at end of file diff --git a/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt b/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt index 96c14b15c..8bca4d051 100644 --- a/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt +++ b/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt @@ -21,4 +21,5 @@ val UserDataNone = UserData( casesMapBounds = IncidentCoordinateBoundsNone, teamMapBounds = IncidentCoordinateBoundsNone, isWorkScreenTableView = false, + isSyncMediaImmediate = false, ) 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 index ed4a71ea0..91b3f0f8e 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt @@ -180,10 +180,9 @@ class LoginWithPhoneViewModel @Inject constructor( return } - if (isRequestingCode.value) { + if (!isRequestingCode.compareAndSet(expect = false, update = true)) { return } - isRequestingCode.value = true phoneCode = "" diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/OrgPersistentInviteViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/OrgPersistentInviteViewModel.kt index 67e11f5ef..e0176fea4 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/OrgPersistentInviteViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/OrgPersistentInviteViewModel.kt @@ -104,9 +104,8 @@ class OrgPersistentInviteViewModel @Inject constructor( } .launchIn(viewModelScope) - viewModelScope.launch(ioDispatcher) { - if (!isPullingLanguageOptions.value) { - isPullingLanguageOptions.value = true + if (isPullingLanguageOptions.compareAndSet(expect = false, update = true)) { + viewModelScope.launch(ioDispatcher) { try { languageOptions.value = languageRepository.getLanguageOptions() } catch (e: Exception) { @@ -124,8 +123,9 @@ class OrgPersistentInviteViewModel @Inject constructor( private fun queryInviteInfo(persistentInvite: UserPersistentInvite) = viewModelScope.launch(ioDispatcher) { - if (persistentInvite.isValidInvite && !isJoiningOrg.value) { - isJoiningOrg.value = true + if (persistentInvite.isValidInvite && + isJoiningOrg.compareAndSet(expect = false, update = true) + ) { try { val inviteInfo = orgVolunteerRepository.getInvitationInfo(persistentInvite) ?: ExpiredNetworkOrgInvite @@ -163,10 +163,9 @@ class OrgPersistentInviteViewModel @Inject constructor( return } - if (isJoiningOrg.value) { + if (!isJoiningOrg.compareAndSet(expect = false, update = true)) { return } - isJoiningOrg.value = true viewModelScope.launch(ioDispatcher) { try { val joinResult = orgVolunteerRepository.acceptPersistentInvitation( 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 496ea3af5..3a11f2681 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 @@ -92,10 +92,9 @@ class PasswordRecoverViewModel @Inject constructor( return } - if (isInitiatingPasswordReset.value) { + if (!isInitiatingPasswordReset.compareAndSet(expect = false, update = true)) { return } - isInitiatingPasswordReset.value = true viewModelScope.launch { try { val result = accountUpdateRepository.initiatePasswordReset(email) @@ -131,10 +130,9 @@ class PasswordRecoverViewModel @Inject constructor( return } - if (isInitiatingMagicLink.value) { + if (!isInitiatingMagicLink.compareAndSet(expect = false, update = true)) { return } - isInitiatingMagicLink.value = true viewModelScope.launch { try { val isInitiated = accountUpdateRepository.initiateEmailMagicLink(email) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasteOrgInviteViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasteOrgInviteViewModel.kt index cc6d3405b..196e5b387 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasteOrgInviteViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasteOrgInviteViewModel.kt @@ -38,12 +38,10 @@ class PasteOrgInviteViewModel @Inject constructor( inviteCodeError.value = "" + if (!isVerifyingCode.compareAndSet(expect = false, update = true)) { + return + } viewModelScope.launch(ioDispatcher) { - if (isVerifyingCode.value) { - return@launch - } - isVerifyingCode.value = true - try { var errorMessageKey = "" diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt index cf6086edf..33464b8d3 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.crisiscleanup.core.common.InputValidator import com.crisiscleanup.core.common.KeyResourceTranslator +import com.crisiscleanup.core.common.event.AccountEventBus import com.crisiscleanup.core.common.event.ExternalEventBus import com.crisiscleanup.core.common.isPast import com.crisiscleanup.core.common.log.AppLogger @@ -15,6 +16,9 @@ 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.data.repository.AccountUpdateRepository +import com.crisiscleanup.core.data.repository.ChangeOrganizationAction import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.OrgVolunteerRepository import com.crisiscleanup.core.model.data.CodeInviteAccept @@ -30,6 +34,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -37,13 +42,17 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.net.URL import javax.inject.Inject +import com.crisiscleanup.core.common.combine as combineMore @HiltViewModel class RequestOrgAccessViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val languageRepository: LanguageTranslationsRepository, private val orgVolunteerRepository: OrgVolunteerRepository, + private val accountUpdateRepository: AccountUpdateRepository, + private val accountDataRepository: AccountDataRepository, private val inputValidator: InputValidator, + private val accountEventBus: AccountEventBus, private val externalEventBus: ExternalEventBus, private val translator: KeyResourceTranslator, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @@ -74,9 +83,34 @@ class RequestOrgAccessViewModel @Inject constructor( var requestSentTitle by mutableStateOf("") var requestSentText by mutableStateOf("") - val screenTitle = requestedOrg - .map { - val key = if (it == null) "actions.sign_up" else "actions.request_access" + val transferOrgOptions = listOf( + TransferOrgOption.Users, + TransferOrgOption.All, + TransferOrgOption.DoNotTransfer, + ) + var selectedOrgTransfer by mutableStateOf(TransferOrgOption.NotSelected) + private set + var transferOrgErrorMessage by mutableStateOf("") + private set + val isTransferringOrg = MutableStateFlow(false) + val isOrgTransferred = MutableStateFlow(false) + + val screenTitle = combine( + requestedOrg, + inviteDisplay, + translator.translationCount, + ::Triple, + ) + .map { (org, invite, _) -> + val key = if (org == null) { + if (invite?.inviteInfo?.isExistingUser == true) { + "actions.transfer" + } else { + "actions.sign_up" + } + } else { + "actions.request_access" + } translator(key) } .stateIn( @@ -85,13 +119,14 @@ class RequestOrgAccessViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) - val isLoading = combine( + val isLoading = combineMore( isPullingLanguageOptions, isFetchingInviteInfo, isRequestingInvite, - ::Triple, - ) - .map { (b0, b1, b2) -> b0 || b1 || b2 } + isTransferringOrg, + ) { b0, b1, b2, b3 -> + b0 || b1 || b2 || b3 + } .stateIn( scope = viewModelScope, initialValue = false, @@ -147,8 +182,7 @@ class RequestOrgAccessViewModel @Inject constructor( .launchIn(viewModelScope) viewModelScope.launch(ioDispatcher) { - if (!isPullingLanguageOptions.value) { - isPullingLanguageOptions.value = true + if (isPullingLanguageOptions.compareAndSet(expect = false, update = true)) { try { languageOptions.value = languageRepository.getLanguageOptions() } catch (e: Exception) { @@ -223,10 +257,9 @@ class RequestOrgAccessViewModel @Inject constructor( return } - if (isRequestingInvite.value) { + if (!isRequestingInvite.compareAndSet(expect = false, update = true)) { return } - isRequestingInvite.value = true viewModelScope.launch(ioDispatcher) { try { if (showEmailInput) { @@ -283,6 +316,53 @@ class RequestOrgAccessViewModel @Inject constructor( } } } + + fun onChangeTransferOrgOption(option: TransferOrgOption) { + selectedOrgTransfer = option + transferOrgErrorMessage = "" + } + + fun onTransferOrg() { + when (selectedOrgTransfer) { + TransferOrgOption.DoNotTransfer -> isInviteRequested.value = true + TransferOrgOption.Users, + TransferOrgOption.All, + -> { + if (isTransferringOrg.compareAndSet(expect = false, update = true)) { + viewModelScope.launch(ioDispatcher) { + try { + val action = if (selectedOrgTransfer == TransferOrgOption.Users) { + ChangeOrganizationAction.Users + } else { + ChangeOrganizationAction.All + } + transferToOrg(action) + + val isAuthenticated = accountDataRepository.isAuthenticated.first() + if (isAuthenticated) { + clearInviteCode() + accountEventBus.onLogout() + } + } finally { + isTransferringOrg.value = false + } + } + } + } + + else -> {} + } + } + + private suspend fun transferToOrg(action: ChangeOrganizationAction) { + if (accountUpdateRepository.acceptOrganizationChange(action, invitationCode)) { + isOrgTransferred.value = true + } else { + logger.logException(Exception("User transfer to org failed.")) + transferOrgErrorMessage = + translator("~~There was an issue during organization transfer. Try again later or reach out to support for help.") + } + } } data class InviteDisplayInfo( @@ -294,3 +374,10 @@ data class InviteDisplayInfo( val displayName: String get() = inviteInfo.displayName } + +enum class TransferOrgOption(val translateKey: String) { + NotSelected(""), + Users("invitationSignup.yes_transfer_just_me"), + All("invitationSignup.yes_transfer_me_and_cases"), + DoNotTransfer("invitationSignup.no_transfer"), +} 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 b2bb8b9f8..eb7ce1348 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 @@ -18,9 +18,14 @@ fun NavController.navigateToEmailLoginLink() { navigate(EMAIL_LOGIN_LINK_ROUTE) } +private fun getResetPasswordRoute(isAuthenticated: Boolean) = if (isAuthenticated) { + ACCOUNT_RESET_PASSWORD_ROUTE +} else { + AUTH_RESET_PASSWORD_ROUTE +} + fun NavController.navigateToPasswordReset(isAuthenticated: Boolean) { - val resetPasswordRoute = - if (isAuthenticated) ACCOUNT_RESET_PASSWORD_ROUTE else AUTH_RESET_PASSWORD_ROUTE + val resetPasswordRoute = getResetPasswordRoute(isAuthenticated) navigate(resetPasswordRoute) } @@ -52,8 +57,7 @@ fun NavGraphBuilder.resetPasswordScreen( onBack: () -> Unit, closeResetPassword: () -> Unit, ) { - val resetPasswordRoute = - if (isAuthenticated) ACCOUNT_RESET_PASSWORD_ROUTE else AUTH_RESET_PASSWORD_ROUTE + val resetPasswordRoute = getResetPasswordRoute(isAuthenticated) composable(route = resetPasswordRoute) { ResetPasswordRoute( onBack = onBack, diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/RequestOrgAccessNavigation.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/RequestOrgAccessNavigation.kt index be4df7dd5..2bd8f3ac4 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/RequestOrgAccessNavigation.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/RequestOrgAccessNavigation.kt @@ -5,7 +5,8 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navArgument -import com.crisiscleanup.core.appnav.RouteConstant.REQUEST_ACCESS_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.ACCOUNT_TRANSFER_ORG_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.AUTH_REQUEST_ACCESS_ROUTE import com.crisiscleanup.feature.authentication.ui.RequestOrgAccessRoute internal const val INVITE_CODE_ARG = "inviteCode" @@ -21,17 +22,28 @@ internal class RequestOrgAccessArgs( ) } -fun NavController.navigateToRequestAccess(code: String) { - val route = "$REQUEST_ACCESS_ROUTE?$INVITE_CODE_ARG=$code" +private fun getOrgAccessRoute(isAuthenticated: Boolean) = if (isAuthenticated) { + ACCOUNT_TRANSFER_ORG_ROUTE +} else { + AUTH_REQUEST_ACCESS_ROUTE +} + +fun NavController.navigateToRequestAccess(code: String, isAuthenticated: Boolean) { + val orgAccessRoute = getOrgAccessRoute(isAuthenticated) + val route = "$orgAccessRoute?$INVITE_CODE_ARG=$code" navigate(route) } fun NavGraphBuilder.requestAccessScreen( + isAuthenticated: Boolean, onBack: () -> Unit, closeRequestAccess: () -> Unit, + openAuth: () -> Unit, + openForgotPassword: () -> Unit, ) { + val orgAccessRoute = getOrgAccessRoute(isAuthenticated) composable( - route = "$REQUEST_ACCESS_ROUTE?$INVITE_CODE_ARG={$INVITE_CODE_ARG}", + route = "$orgAccessRoute?$INVITE_CODE_ARG={$INVITE_CODE_ARG}", arguments = listOf( navArgument(INVITE_CODE_ARG) {}, ), @@ -39,6 +51,8 @@ fun NavGraphBuilder.requestAccessScreen( RequestOrgAccessRoute( onBack = onBack, closeRequestAccess = closeRequestAccess, + openAuth = openAuth, + openForgotPassword = openForgotPassword, ) } } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/VolunteerOrgNavigation.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/VolunteerOrgNavigation.kt index d00ba80ae..ab01b9f33 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/VolunteerOrgNavigation.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/VolunteerOrgNavigation.kt @@ -61,7 +61,7 @@ fun NavGraphBuilder.navigateToVolunteerPasteInviteLink( remember(navController) { { code: String -> navController.popBackStack() - navController.navigateToRequestAccess(code) + navController.navigateToRequestAccess(code, false) } } VolunteerPasteInviteLinkRoute( diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt index 3a8af6112..b02aadeb9 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt @@ -1,11 +1,11 @@ package com.crisiscleanup.feature.authentication.ui import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -33,11 +33,15 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.crisiscleanup.core.designsystem.LocalAppTranslator -import com.crisiscleanup.core.designsystem.component.AnimatedBusyIndicator import com.crisiscleanup.core.designsystem.component.BusyButton +import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter +import com.crisiscleanup.core.designsystem.component.CrisisCleanupOutlinedButton +import com.crisiscleanup.core.designsystem.component.CrisisCleanupRadioButton +import com.crisiscleanup.core.designsystem.component.LinkifyHtmlText import com.crisiscleanup.core.designsystem.component.OutlinedClearableTextField import com.crisiscleanup.core.designsystem.component.RegisterSuccessView import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +import com.crisiscleanup.core.designsystem.component.actionHeight import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons import com.crisiscleanup.core.designsystem.theme.LocalFontStyles import com.crisiscleanup.core.designsystem.theme.fillWidthPadded @@ -45,9 +49,12 @@ import com.crisiscleanup.core.designsystem.theme.listItemHorizontalPadding import com.crisiscleanup.core.designsystem.theme.listItemModifier import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy import com.crisiscleanup.core.designsystem.theme.listItemTopPadding +import com.crisiscleanup.core.designsystem.theme.primaryRedColor import com.crisiscleanup.core.ui.rememberCloseKeyboard import com.crisiscleanup.core.ui.scrollFlingListener +import com.crisiscleanup.feature.authentication.InviteDisplayInfo import com.crisiscleanup.feature.authentication.RequestOrgAccessViewModel +import com.crisiscleanup.feature.authentication.TransferOrgOption import kotlinx.coroutines.launch import java.net.URL @@ -56,9 +63,12 @@ import java.net.URL fun RequestOrgAccessRoute( onBack: () -> Unit, closeRequestAccess: () -> Unit, + openAuth: () -> Unit = {}, + openForgotPassword: () -> Unit = {}, viewModel: RequestOrgAccessViewModel = hiltViewModel(), ) { val isInviteRequested by viewModel.isInviteRequested.collectAsStateWithLifecycle() + val isOrgTransferred by viewModel.isOrgTransferred.collectAsStateWithLifecycle() val clearStateOnBack = remember(viewModel, isInviteRequested, onBack, closeRequestAccess) { { @@ -102,126 +112,82 @@ fun RequestOrgAccessRoute( actionText = actionText, onAction = clearStateOnBack, ) + } else if (isOrgTransferred) { + val clearStateOpenAuth = remember(viewModel, openAuth) { + { + viewModel.clearInviteCode() + openAuth() + } + } + val clearStateForgotPassword = remember(viewModel, openForgotPassword) { + { + viewModel.clearInviteCode() + openForgotPassword() + } + } + + val displayInfo by viewModel.inviteDisplay.collectAsStateWithLifecycle() + val orgName = displayInfo?.inviteInfo?.orgName ?: "" + OrgTransferSuccessView( + orgName, + onForgotPassword = clearStateForgotPassword, + onLogin = clearStateOpenAuth, + ) } else { - RequestOrgUserInfoInputView() + RequestOrgUserInfoInputView( + onBack = clearStateOnBack, + ) } } } @Composable private fun RequestOrgUserInfoInputView( + onBack: () -> Unit, viewModel: RequestOrgAccessViewModel = hiltViewModel(), ) { - val t = LocalAppTranslator.current - val closeKeyboard = rememberCloseKeyboard(viewModel) - val displayInfo by viewModel.inviteDisplay.collectAsStateWithLifecycle() - - val isEditable by viewModel.isEditable.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() - val clearErrorVisuals = remember(viewModel) { { viewModel.clearErrors() } } - - val scrollState = rememberScrollState() - var contentSize by remember { mutableStateOf(Size.Zero) } - Column( - Modifier - .scrollFlingListener(closeKeyboard) - .fillMaxSize() - .verticalScroll(scrollState) - .onGloballyPositioned { - contentSize = it.size.toSize() - }, - ) { - if (viewModel.showEmailInput) { - val requestInstructions = t("requestAccess.request_access_enter_email") - Text( - requestInstructions, - listItemModifier.testTag("requestAccessByEmailInstructions"), - ) + if (displayInfo == null) { + Box(Modifier.fillMaxSize()) { + BusyIndicatorFloatingTopCenter(true) + } + } else { + val closeKeyboard = rememberCloseKeyboard(viewModel) - val hasEmailError = viewModel.emailAddressError.isNotBlank() - if (hasEmailError) { - Text( - viewModel.emailAddressError, - Modifier - .listItemHorizontalPadding() - .listItemTopPadding() - .testTag("requestAccessByEmailError"), - color = MaterialTheme.colorScheme.error, + var contentSize by remember { mutableStateOf(Size.Zero) } + + val scrollState = rememberScrollState() + Column( + Modifier + .scrollFlingListener(closeKeyboard) + .fillMaxSize() + .verticalScroll(scrollState) + .onGloballyPositioned { + contentSize = it.size.toSize() + }, + ) { + val isExistingUser = displayInfo?.inviteInfo?.isExistingUser == true + val isEditable by viewModel.isEditable.collectAsStateWithLifecycle() + + if (isExistingUser) { + InviteExistingUserContent( + onBack = onBack, + isEditable = isEditable, + isLoading = isLoading, + displayInfo!!, ) - } - // TODO Initial focus isn't taking (on emulator) - val hasEmailFocus = viewModel.emailAddress.isBlank() || hasEmailError - OutlinedClearableTextField( - modifier = listItemModifier.testTag("requestAccessByEmailTextField"), - label = t("requestAccess.existing_member_email"), - value = viewModel.emailAddress, - onValueChange = { viewModel.emailAddress = it }, - keyboardType = KeyboardType.Email, - enabled = isEditable, - isError = hasEmailError, - hasFocus = hasEmailFocus, - onNext = clearErrorVisuals, - ) - } else { - if (displayInfo == null) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - AnimatedBusyIndicator( - isLoading, - padding = 16.dp, - ) - } } else { - val info = displayInfo!! - val avatarUrl = displayInfo?.avatarUrl - if (avatarUrl != null && - info.displayName.isNotBlank() && - info.inviteMessage.isNotBlank() - ) { - InviterAvatar( - avatarUrl, - displayName = info.displayName, - inviteMessage = info.inviteMessage, - ) - } + InviteNewUserContent( + contentSize, + scrollState, + isEditable = isEditable, + isLoading = isLoading, + displayInfo!!, + ) } } - - Text( - t("requestAccess.complete_form_request_access"), - fillWidthPadded.testTag("requestAccessInputInstruction"), - style = LocalFontStyles.current.header3, - ) - - val coroutineScope = rememberCoroutineScope() - val languageOptions by viewModel.languageOptions.collectAsStateWithLifecycle() - UserInfoInputView( - infoData = viewModel.userInfo, - languageOptions = languageOptions, - isEditable = isEditable, - onEndOfInput = { - coroutineScope.launch { - scrollState.animateScrollTo(contentSize.height.toInt()) - } - }, - ) - - Text( - t("requestAccess.request_will_be_sent"), - listItemModifier.testTag("requestAccessSubmitExplainer"), - ) - - BusyButton( - fillWidthPadded.testTag("requestAccessSubmitAction"), - enabled = isEditable, - text = t("actions.request_access"), - indicateBusy = isLoading, - onClick = viewModel::onVolunteerWithOrg, - ) } } @@ -265,3 +231,195 @@ internal fun InviterAvatar( } } } + +@Composable +private fun InviteExistingUserContent( + onBack: () -> Unit, + isEditable: Boolean, + isLoading: Boolean, + displayInfo: InviteDisplayInfo, + viewModel: RequestOrgAccessViewModel = hiltViewModel(), +) { + val t = LocalAppTranslator.current + val translationCount by t.translationCount.collectAsStateWithLifecycle() + + val inviteInfo = displayInfo.inviteInfo + val transferInstructions = t("invitationSignup.inviting_to_transfer_confirm") + .replace("{user}", inviteInfo.displayName) + .replace("{fromOrg}", inviteInfo.fromOrgName) + .replace("{toOrg}", inviteInfo.orgName) + + LinkifyHtmlText( + transferInstructions, + listItemModifier, + ) + + val selectedOption = viewModel.selectedOrgTransfer + for (option in viewModel.transferOrgOptions) { + CrisisCleanupRadioButton( + listItemModifier, + option == selectedOption, + text = t(option.translateKey), + onSelect = { viewModel.onChangeTransferOrgOption(option) }, + enabled = isEditable, + ) + } + + val errorMessage = viewModel.transferOrgErrorMessage + if (errorMessage.isNotBlank()) { + Text( + errorMessage, + listItemModifier, + color = primaryRedColor, + ) + } + + val transferText = remember(translationCount) { + t("actions.transfer") + } + BusyButton( + fillWidthPadded.testTag("transferOrgSubmitAction"), + enabled = isEditable && selectedOption != TransferOrgOption.NotSelected, + text = transferText, + indicateBusy = isLoading, + onClick = { + if (selectedOption == TransferOrgOption.DoNotTransfer) { + onBack() + } else { + viewModel.onTransferOrg() + } + }, + ) +} + +@Composable +private fun OrgTransferSuccessView( + orgName: String, + onForgotPassword: () -> Unit, + onLogin: () -> Unit, +) { + val t = LocalAppTranslator.current + + Text( + t("invitationSignup.move_completed"), + listItemModifier, + style = MaterialTheme.typography.headlineSmall, + ) + + Text( + t("invitationSignup.congrats_move_complete") + .replace("{toOrg}", orgName), + listItemModifier, + ) + + CrisisCleanupOutlinedButton( + modifier = listItemModifier + .actionHeight(), + enabled = true, + onClick = onForgotPassword, + text = t("invitationSignup.forgot_password"), + ) + + BusyButton( + modifier = listItemModifier + .actionHeight(), + onClick = onLogin, + text = t("actions.login"), + ) +} + +@Composable +private fun InviteNewUserContent( + contentSize: Size, + scrollState: ScrollState, + isEditable: Boolean, + isLoading: Boolean, + displayInfo: InviteDisplayInfo, + viewModel: RequestOrgAccessViewModel = hiltViewModel(), +) { + val t = LocalAppTranslator.current + val translationCount by t.translationCount.collectAsStateWithLifecycle() + + val clearErrorVisuals = viewModel::clearErrors + + if (viewModel.showEmailInput) { + val requestInstructions = t("requestAccess.request_access_enter_email") + Text( + requestInstructions, + listItemModifier.testTag("requestAccessByEmailInstructions"), + ) + + val hasEmailError = viewModel.emailAddressError.isNotBlank() + if (hasEmailError) { + Text( + viewModel.emailAddressError, + Modifier + .listItemHorizontalPadding() + .listItemTopPadding() + .testTag("requestAccessByEmailError"), + color = MaterialTheme.colorScheme.error, + ) + } + // TODO Initial focus isn't taking (on emulator) + val hasEmailFocus = viewModel.emailAddress.isBlank() || hasEmailError + OutlinedClearableTextField( + modifier = listItemModifier.testTag("requestAccessByEmailTextField"), + label = t("requestAccess.existing_member_email"), + value = viewModel.emailAddress, + onValueChange = { viewModel.emailAddress = it }, + keyboardType = KeyboardType.Email, + enabled = isEditable, + isError = hasEmailError, + hasFocus = hasEmailFocus, + onNext = clearErrorVisuals, + ) + } else { + val info = displayInfo + val avatarUrl = displayInfo.avatarUrl + if (avatarUrl != null && + info.displayName.isNotBlank() && + info.inviteMessage.isNotBlank() + ) { + InviterAvatar( + avatarUrl, + displayName = info.displayName, + inviteMessage = info.inviteMessage, + ) + } + } + + Text( + t("requestAccess.complete_form_request_access"), + fillWidthPadded.testTag("requestAccessInputInstruction"), + style = LocalFontStyles.current.header3, + ) + + val coroutineScope = rememberCoroutineScope() + val languageOptions by viewModel.languageOptions.collectAsStateWithLifecycle() + UserInfoInputView( + infoData = viewModel.userInfo, + languageOptions = languageOptions, + isEditable = isEditable, + onEndOfInput = { + coroutineScope.launch { + scrollState.animateScrollTo(contentSize.height.toInt()) + } + }, + ) + + Text( + t("requestAccess.request_will_be_sent"), + listItemModifier.testTag("requestAccessSubmitExplainer"), + ) + + val requestAccessText = remember(translationCount) { + t("actions.request_access") + } + BusyButton( + fillWidthPadded.testTag("requestOrgAccessSubmitAction"), + enabled = isEditable, + text = requestAccessText, + indicateBusy = isLoading, + onClick = viewModel::onVolunteerWithOrg, + ) +} 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 02e0be63f..0aeb2116d 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 @@ -232,11 +232,9 @@ class CaseShareViewModel @Inject constructor( return } - if (isSharing.value) { + if (!isSharing.compareAndSet(expect = false, update = true)) { return } - isSharing.value = true - viewModelScope.launch(ioDispatcher) { try { isShared.value = worksitesRepository.shareWorksite( diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt index d51c3facf..6b1a0bd6a 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt @@ -594,11 +594,8 @@ class CreateEditCaseViewModel @Inject constructor( return } - synchronized(isSavingWorksite) { - if (isSavingWorksite.value) { - return - } - isSavingWorksite.value = true + if (!isSavingWorksite.compareAndSet(expect = false, update = true)) { + return } viewModelScope.launch(ioDispatcher) { try { diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyDataScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyDataScreen.kt index 873c18f99..5cc3f0c03 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyDataScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyDataScreen.kt @@ -2,6 +2,7 @@ package com.crisiscleanup.feature.caseeditor.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -15,6 +16,7 @@ import androidx.compose.runtime.getValue 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.onFocusChanged import androidx.compose.ui.geometry.Size @@ -31,6 +33,7 @@ import com.crisiscleanup.core.commoncase.model.CaseSummaryResult import com.crisiscleanup.core.commoncase.ui.ExistingCaseLocationsDropdownItems import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.CrisisCleanupRadioButton +import com.crisiscleanup.core.designsystem.component.HelpAction import com.crisiscleanup.core.designsystem.component.HelpRow import com.crisiscleanup.core.designsystem.component.OutlinedClearableTextField import com.crisiscleanup.core.designsystem.component.WithHelpDialog @@ -40,6 +43,7 @@ import com.crisiscleanup.core.designsystem.theme.listItemHeight 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.listItemSpacedByHalf import com.crisiscleanup.core.model.data.AutoContactFrequency import com.crisiscleanup.core.ui.rememberCloseKeyboard import com.crisiscleanup.feature.caseeditor.CasePropertyDataEditor @@ -51,43 +55,63 @@ internal fun PropertyFormView( editor: CasePropertyDataEditor, focusOnOpen: Boolean = false, ) { - val translator = LocalAppTranslator.current + val t = LocalAppTranslator.current val isEditable = LocalCaseEditor.current.isEditable val inputData = editor.propertyInputData PropertyFormResidentNameView(editor, isEditable, focusOnOpen) - // TODO Apply mask with dashes if input is purely numbers (and dashes) val updatePhone = remember(inputData) { { s: String -> inputData.phoneNumber = s } } val clearPhoneError = remember(inputData) { { inputData.phoneNumberError = "" } } val isPhoneError = inputData.phoneNumberError.isNotEmpty() val focusPhone = isPhoneError - val phone1Label = translator("formLabels.phone1") + val phone1Label = t("formLabels.phone1") ErrorText(inputData.phoneNumberError) - OutlinedClearableTextField( - modifier = listItemModifier - .onFocusChanged { - if (!it.hasFocus) { - inputData.formatPhoneNumber() + Row( + listItemModifier, + horizontalArrangement = listItemSpacedByHalf, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedClearableTextField( + modifier = Modifier + .weight(1f) + .onFocusChanged { + if (!it.hasFocus) { + inputData.formatPhoneNumber() + } } - } - .testTag("propertyPhone1TextField"), - label = "$phone1Label *", - value = inputData.phoneNumber, - onValueChange = updatePhone, - keyboardType = KeyboardType.Password, - isError = isPhoneError, - hasFocus = focusPhone, - onNext = clearPhoneError, - enabled = isEditable, - ) + .testTag("propertyPhone1TextField"), + label = "$phone1Label *", + value = inputData.phoneNumber, + onValueChange = updatePhone, + keyboardType = KeyboardType.Password, + isError = isPhoneError, + hasFocus = focusPhone, + onNext = clearPhoneError, + enabled = isEditable, + ) + + val phoneNumberFormatHint = t("caseForm.phone_number_format") + WithHelpDialog( + viewModel, + helpTitle = t("~~Phone number format"), + helpText = phoneNumberFormatHint, + ) { showHelp -> + HelpAction( + phoneNumberFormatHint, + showHelp, + // TODO Common dimensions + Modifier.padding(top = 8.dp), + ) + } + } val updatePhoneNotes = remember(inputData) { { s: String -> inputData.phoneNotes = s } } OutlinedClearableTextField( modifier = listItemModifier .testTag("propertyPhone1NotesTextField"), - label = translator("formLabels.phone1_notes"), + label = t("formLabels.phone1_notes"), value = inputData.phoneNotes, onValueChange = updatePhoneNotes, isError = false, @@ -106,7 +130,7 @@ internal fun PropertyFormView( } .testTag("propertyPhone2TextField"), labelResId = 0, - label = translator("formLabels.phone2"), + label = t("formLabels.phone2"), value = inputData.phoneNumberSecondary, onValueChange = updateAdditionalPhone, keyboardType = KeyboardType.Password, @@ -120,7 +144,7 @@ internal fun PropertyFormView( OutlinedClearableTextField( modifier = listItemModifier .testTag("propertyPhone2NotesTextField"), - label = translator("formLabels.phone2_notes"), + label = t("formLabels.phone2_notes"), value = inputData.phoneNotesSecondary, onValueChange = updateAdditionalPhoneNotes, isError = false, @@ -136,7 +160,7 @@ internal fun PropertyFormView( OutlinedClearableTextField( modifier = listItemModifier.testTag("propertyEmailTextField"), labelResId = 0, - label = translator("formLabels.email"), + label = t("formLabels.email"), value = inputData.email, onValueChange = updateEmail, keyboardType = KeyboardType.Email, @@ -148,11 +172,11 @@ internal fun PropertyFormView( onEnter = closeKeyboard, ) - val autoContactFrequencyLabel = translator("casesVue.auto_contact_frequency") + val autoContactFrequencyLabel = t("casesVue.auto_contact_frequency") WithHelpDialog( viewModel, helpTitle = autoContactFrequencyLabel, - helpText = translator("casesVue.auto_contact_frequency_help"), + helpText = t("casesVue.auto_contact_frequency_help"), ) { showHelp -> HelpRow( autoContactFrequencyLabel, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt index 3ccb28bbd..d4cb9cede 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt @@ -1,5 +1,6 @@ package com.crisiscleanup.feature.caseeditor.ui +import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -12,7 +13,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.coerceAtMost @@ -92,7 +92,7 @@ internal fun PropertyLocationView( ) } - val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val screenHeight = Configuration.SCREEN_HEIGHT_DP_UNDEFINED.dp val mapHeight = screenHeight.times(0.5f).coerceAtMost(240.dp) val mapModifier = Modifier.sizeIn(maxHeight = mapHeight) val cameraPositionState = rememberCameraPositionState() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt index 73e05b097..886431ddb 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt @@ -89,6 +89,7 @@ import com.crisiscleanup.core.designsystem.theme.disabledAlpha 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.listItemSpacedByHalf import com.crisiscleanup.core.designsystem.theme.neutralIconColor import com.crisiscleanup.core.designsystem.theme.primaryOrangeColor import com.crisiscleanup.core.mapmarker.ui.rememberMapProperties @@ -525,6 +526,7 @@ internal fun PropertyInfoRow( isEmail: Boolean = false, isLocation: Boolean = false, locationQuery: String = "", + subText: String = "", trailingContent: (@Composable () -> Unit)? = null, ) { Row( @@ -538,20 +540,27 @@ internal fun PropertyInfoRow( tint = neutralIconColor, ) - val style = MaterialTheme.typography.bodyLarge - val innerModifier = if (trailingContent == null) { - Modifier - } else { - Modifier.weight(1f) - } - if (isPhone) { - LinkifyPhoneText(text, innerModifier) - } else if (isEmail) { - LinkifyEmailText(text, innerModifier) - } else if (isLocation) { - LinkifyLocationText(text, locationQuery, innerModifier) - } else { - Text(text, innerModifier, style = style) + Column( + Modifier.weight(1f), + verticalArrangement = listItemSpacedByHalf, + ) { + val style = MaterialTheme.typography.bodyLarge + if (isPhone) { + LinkifyPhoneText(text) + } else if (isEmail) { + LinkifyEmailText(text) + } else if (isLocation) { + LinkifyLocationText(text, locationQuery) + } else { + Text(text, style = style) + } + + if (subText.isNotBlank()) { + Text( + subText, + style = MaterialTheme.typography.bodyMedium, + ) + } } trailingContent?.invoke() @@ -727,6 +736,9 @@ private fun LazyListScope.propertyInfoItems( ) val phoneNumbers = listOf(worksite.phone1, worksite.phone2).filterNotBlankTrim().joinToString("; ") + val phoneNotes = + listOf(worksite.phone1Notes, worksite.phone2Notes).filterNotBlankTrim() + .joinToString("\n") PropertyInfoRow( CrisisCleanupIcons.Phone, phoneNumbers, @@ -739,6 +751,7 @@ private fun LazyListScope.propertyInfoItems( .fillMaxWidth() .padding(horizontal = edgeSpacing, vertical = edgeSpacingHalf), isPhone = true, + subText = phoneNotes, ) worksite.email?.let { if (it.isNotBlank()) { diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesFilterViewModel.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesFilterViewModel.kt index 03e4f922b..acdb044f9 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesFilterViewModel.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesFilterViewModel.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -178,10 +179,14 @@ class CasesFilterViewModel @Inject constructor( fun clearFilters() { val filters = CasesFilter() changeFilters(filters) - applyFilters(filters) + viewModelScope.launch { + applyFilters(filters) + } } - fun applyFilters(filters: CasesFilter) { + suspend fun applyFilters(filters: CasesFilter) { + // TODO Disallow successive apply + // Update transient UI casesFilterRepository.changeFilters(filters) } } diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/map/CasesMapMarkerManager.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/map/CasesMapMarkerManager.kt index 1bec2ac97..2a2399bf7 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/map/CasesMapMarkerManager.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/map/CasesMapMarkerManager.kt @@ -161,7 +161,7 @@ internal class CasesMapMarkerManager( private val denseMarkCountThreshold = 15 private val denseMarkZoomThreshold = CasesConstant.MAP_MARKERS_ZOOM_LEVEL + 4 private val denseDegreeThreshold = 0.0001 - private val denseScreenOffsetScale = 0.6f + private val denseScreenOffsetScale = 1.2f suspend fun denseMarkerOffsets( marks: List, zoom: Float, @@ -210,9 +210,10 @@ internal class CasesMapMarkerManager( if (buckets.isNotEmpty()) { buckets.forEach { val count = it.size - val offsetScale = denseScreenOffsetScale + (count - 5).coerceAtLeast(0) * 0.2f if (count > 1) { var offsetDir = (PI * 0.5).toFloat() + val offsetScale = + denseScreenOffsetScale + (count - 5).coerceAtLeast(0) * 0.2f val deltaDirDegrees = (2 * PI / count).toFloat() it.forEach { index -> markOffsets[index] = Pair( diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesFilterScreen.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesFilterScreen.kt index 3a238b6bc..60883dd71 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesFilterScreen.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesFilterScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment @@ -78,6 +79,7 @@ import com.crisiscleanup.core.ui.rememberCloseKeyboard import com.crisiscleanup.core.ui.scrollFlingListener import com.crisiscleanup.feature.cases.CasesFilterViewModel import com.crisiscleanup.feature.cases.CollapsibleFilterSection +import kotlinx.coroutines.launch import kotlinx.datetime.Instant import kotlinx.datetime.toJavaInstant import java.time.ZoneId @@ -164,6 +166,7 @@ internal fun CasesFilterRoute( if (confirmAbandonFilterChange) { val closeDialog = { confirmAbandonFilterChange = false } + val coroutineScope = rememberCoroutineScope() CrisisCleanupAlertDialog( onDismissRequest = closeDialog, title = t("worksiteFilters.filter_changes"), @@ -171,8 +174,10 @@ internal fun CasesFilterRoute( CrisisCleanupTextButton( text = t("actions.apply_filters"), onClick = { - viewModel.applyFilters(filters) - onBack() + coroutineScope.launch { + viewModel.applyFilters(filters) + onBack() + } }, ) }, @@ -1066,14 +1071,17 @@ fun BottomActionBar( val applyFilters = t("actions.apply_filters") val applyText = if (hasFilters) "$applyFilters ($filterCount)" else applyFilters + val coroutineScope = rememberCoroutineScope() BusyButton( Modifier .testTag("filterApplyFiltersBtn") .weight(1f), text = applyText, onClick = { - viewModel.applyFilters(filters) - onBack() + coroutineScope.launch { + viewModel.applyFilters(filters) + onBack() + } }, ) } diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt index c3e99786d..46f5419b4 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt @@ -17,6 +17,7 @@ import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.common.subscribedReplay import com.crisiscleanup.core.common.sync.SyncPuller +import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.data.IncidentSelector import com.crisiscleanup.core.data.incidentcache.DataDownloadSpeedMonitor import com.crisiscleanup.core.data.repository.AccountDataRefresher @@ -58,6 +59,7 @@ class MenuViewModel @Inject constructor( dataDownloadSpeedMonitor: DataDownloadSpeedMonitor, private val appEnv: AppEnv, private val syncPuller: SyncPuller, + private val syncPusher: SyncPusher, @Tutorials(Menu) val menuTutorialDirector: TutorialDirector, val tutorialViewTracker: TutorialViewTracker, private val databaseVersionProvider: DatabaseVersionProvider, @@ -103,6 +105,10 @@ class MenuViewModel @Inject constructor( val databaseVersionText: String get() = if (isNotProduction) "DB ${databaseVersionProvider.databaseVersion}" else "" + val isSyncPhotosImmediate = appPreferencesRepository.userPreferences.map { + it.isSyncMediaImmediate + } + val isSharingAnalytics = appPreferencesRepository.userPreferences.map { it.allowAllAnalytics } @@ -174,6 +180,13 @@ class MenuViewModel @Inject constructor( syncPuller.appPullIncidents() } + fun syncPhotosImmediately(syncImmediate: Boolean) { + viewModelScope.launch(ioDispatcher) { + appPreferencesRepository.setSyncMediaImmediate(syncImmediate) + syncPusher.scheduleSyncMedia() + } + } + fun shareAnalytics(share: Boolean) { viewModelScope.launch(ioDispatcher) { appPreferencesRepository.setAnalytics(share) diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index a652d57dc..fdfd17778 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -130,6 +130,8 @@ private fun MenuScreen( val isAppUpdateAvailable by viewModel.isAppUpdateAvailable.collectAsStateWithLifecycle(false) + val isSyncPhotosImmediate by viewModel.isSyncPhotosImmediate.collectAsStateWithLifecycle(false) + val isSharingAnalytics by viewModel.isSharingAnalytics.collectAsStateWithLifecycle(false) val isSharingLocation by viewModel.isSharingLocation.collectAsStateWithLifecycle(false) @@ -375,6 +377,12 @@ private fun MenuScreen( ) } + toggleItem( + "~~Sync photos immediately", + isSyncPhotosImmediate, + viewModel::syncPhotosImmediately, + ) + toggleItem( "actions.share_analytics", isSharingAnalytics, diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt index 0bb4fe297..93c831400 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt @@ -595,10 +595,9 @@ class InviteTeammateViewModel @Inject constructor( } fun onSendInvites() { - if (isSendingInvite.value) { + if (!isSendingInvite.compareAndSet(expect = false, update = true)) { return } - isSendingInvite.value = true viewModelScope.launch(ioDispatcher) { try { sendInvites() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77628dc69..92840601c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,50 +3,50 @@ accompanist = "0.37.3" androidDesugarJdkLibs = "2.1.5" # AGP and tools should be updated together androidGradlePlugin = "8.11.1" -androidTools = "31.11.1" +androidTools = "31.12.0" androidMapsUtil = "3.14.0" androidMapsUtilKtx = "5.2.0" androidMaterial = "1.12.0" androidxActivity = "1.10.1" androidxAppCompat = "1.7.1" -androidxBrowser = "1.8.0" +androidxBrowser = "1.9.0" androidxCamera = "1.4.2" -androidxComposeBom = "2025.06.01" +androidxComposeBom = "2025.07.00" androidxComposeMaterial3 = "1.3.2" androidxComposeRuntimeTracing = "1.8.3" androidxConstraintLayout = "1.1.1" androidxCore = "1.16.0" androidxCoreSplashscreen = "1.0.1" androidxDataStore = "1.1.7" -androidxEspresso = "3.6.1" +androidxEspresso = "3.7.0" androidxHiltNavigationCompose = "1.2.0" -androidxLifecycle = "2.9.1" +androidxLifecycle = "2.9.2" androidxLintGradle = "1.0.0-alpha05" -androidxMacroBenchmark = "1.3.4" +androidxMacroBenchmark = "1.4.0" androidxMetrics = "1.0.0-beta02" -androidxNavigation = "2.9.1" +androidxNavigation = "2.9.3" androidxPaging = "3.3.6" androidxProfileinstaller = "1.4.1" androidxStartup = "1.2.0" -androidxSecurityCrypto = "1.1.0-beta01" -androidxTestCore = "1.6.1" -androidxTestExt = "1.2.1" -androidxTestRules = "1.6.1" -androidxTestRunner = "1.6.2" +androidxSecurityCrypto = "1.1.0" +androidxTestCore = "1.7.0" +androidxTestExt = "1.3.0" +androidxTestRules = "1.7.0" +androidxTestRunner = "1.7.0" androidxTracing = "1.3.0" androidxUiAutomator = "2.3.0" androidxWindowManager = "1.4.0" -androidxWork = "2.10.2" -apacheCommonsText = "1.13.1" +androidxWork = "2.10.3" +apacheCommonsText = "1.14.0" coil = "2.7.0" dependencyGuard = "0.5.0" -firebaseBom = "33.16.0" -firebaseCrashlyticsPlugin = "3.0.4" -firebasePerfPlugin = "1.4.2" +firebaseBom = "34.1.0" +firebaseCrashlyticsPlugin = "3.0.6" +firebasePerfPlugin = "2.0.1" gmsPlugin = "4.4.3" -googleMapsCompose = "6.6.0" -googlePlaces = "4.3.1" -hilt = "2.56.2" +googleMapsCompose = "6.7.1" +googlePlaces = "4.4.1" +hilt = "2.57" hiltExt = "1.2.0" jacoco = "0.8.12" junit4 = "4.13.2" @@ -57,13 +57,13 @@ kotlinxCoroutinesPlayServices = "1.10.2" kotlinxDatetime = "0.6.2" kotlinxSerializationJson = "1.9.0" ksp = "2.1.10-1.0.31" -libphonenumber = "9.0.5" +libphonenumber = "9.0.11" mlkitBarcodeScanning = "17.3.0" -mockk = "1.14.4" +mockk = "1.14.5" moduleGraph = "2.9.0" okhttp = "5.1.0" philJayRrule = "1.0.3" -playServicesAuth = "21.3.0" +playServicesAuth = "21.4.0" playServicesAuthPhone = "18.2.0" playServicesLocation = "21.3.0" playServicesMaps = "19.2.0" @@ -146,11 +146,11 @@ apache-commons-text = { group = "org.apache.commons", name = "commons-text", ver coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } -firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } -firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } -firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx" } +firebase-performance = { group = "com.google.firebase", name = "firebase-perf" } google-maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "googleMapsCompose" } google-places = { group = "com.google.android.libraries.places", name = "places", version.ref = "googlePlaces" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } diff --git a/sync/work/src/main/AndroidManifest.xml b/sync/work/src/main/AndroidManifest.xml index d4ca9818f..512957372 100644 --- a/sync/work/src/main/AndroidManifest.xml +++ b/sync/work/src/main/AndroidManifest.xml @@ -1,21 +1,4 @@ - - + + - - - - - - - - - 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 7a98b2d21..233f1410f 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt @@ -14,6 +14,7 @@ import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.common.sync.SyncResult import com.crisiscleanup.core.data.incidentcache.IncidentDataPullReporter import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.AppPreferencesRepository import com.crisiscleanup.core.data.repository.IncidentCacheRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.WorkTypeStatusRepository @@ -42,6 +43,7 @@ class AppSyncer @Inject constructor( private val languageRepository: LanguageTranslationsRepository, private val statusRepository: WorkTypeStatusRepository, private val worksiteChangeRepository: WorksiteChangeRepository, + private val appPreferencesRepository: AppPreferencesRepository, private val networkMonitor: NetworkMonitor, translator: KeyResourceTranslator, @ApplicationContext private val context: Context, @@ -263,7 +265,13 @@ class AppSyncer @Inject constructor( } } - override fun scheduleSyncMedia() = scheduleSyncMedia(context) + override fun scheduleSyncMedia() { + applicationScope.launch(ioDispatcher) { + val syncImmediate = + appPreferencesRepository.userPreferences.first().isSyncMediaImmediate + scheduleSyncMedia(context, syncImmediate) + } + } override fun scheduleSyncWorksites() = scheduleSyncWorksites(context) } diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/initializers/Sync.kt b/sync/work/src/main/java/com/crisiscleanup/sync/initializers/Sync.kt new file mode 100644 index 000000000..af968ce18 --- /dev/null +++ b/sync/work/src/main/java/com/crisiscleanup/sync/initializers/Sync.kt @@ -0,0 +1,14 @@ +package com.crisiscleanup.sync.initializers + +import android.content.Context + +object Sync { + // This method is initializes sync, the process that keeps the app's data current. + // It is called from the app module's Application.onCreate() and should be only done once. + fun initialize(context: Context) { + scheduleSync(context) + scheduleSyncWorksites(context) + // scheduleSyncMedia is run from MainActivityViewModel + scheduleInactiveCheckup(context) + } +} diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncInitializer.kt b/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncInitializer.kt deleted file mode 100644 index d41b508a0..000000000 --- a/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncInitializer.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.crisiscleanup.sync.initializers - -import android.content.Context -import androidx.startup.AppInitializer -import androidx.startup.Initializer -import androidx.work.WorkManagerInitializer - -object Sync { - // This method is a workaround to manually initialize the sync process instead of relying on - // automatic initialization with Androidx Startup. It is called from the app module's - // Application.onCreate() and should be only done once. - fun initialize(context: Context) { - // Manual initialization. Read https://developer.android.com/topic/libraries/app-startup#manual - AppInitializer.getInstance(context) - .initializeComponent(SyncInitializer::class.java) - } -} - -/** - * Registers work to sync the data layer (on app startup). - */ -class SyncInitializer : Initializer { - override fun create(context: Context): Sync { - scheduleSync(context) - scheduleSyncWorksites(context) - scheduleSyncMedia(context) - scheduleInactiveCheckup(context) - - return Sync - } - - override fun dependencies(): List>> = - listOf(WorkManagerInitializer::class.java) -} diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncWorkHelpers.kt b/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncWorkHelpers.kt index 781c4305a..9d14d981f 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncWorkHelpers.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncWorkHelpers.kt @@ -43,12 +43,12 @@ fun scheduleSync(context: Context) { } } -fun scheduleSyncMedia(context: Context) { +fun scheduleSyncMedia(context: Context, syncImmediate: Boolean) { WorkManager.getInstance(context).apply { enqueueUniqueWork( SYNC_MEDIA_WORK_NAME, - ExistingWorkPolicy.KEEP, - SyncMediaWorker.oneTimeSyncWork(), + ExistingWorkPolicy.REPLACE, + SyncMediaWorker.oneTimeSyncWork(syncImmediate), ) } } @@ -84,6 +84,11 @@ internal val SyncMediaConstraints .setRequiresBatteryNotLow(true) .build() +internal val SyncMediaImmediateConstraints + get() = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + /** * Foreground information for sync on lower API levels when sync workers are being * run with a foreground service diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/notification/IncidentDataSyncNotifier.kt b/sync/work/src/main/java/com/crisiscleanup/sync/notification/IncidentDataSyncNotifier.kt index 517c0f4e6..70d5981f5 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/notification/IncidentDataSyncNotifier.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/notification/IncidentDataSyncNotifier.kt @@ -107,6 +107,7 @@ internal class IncidentDataSyncNotifier @Inject constructor( stopSyncIntent, ) .setOnlyAlertOnce(true) + .setSilent(true) .build(), ) } else if (isEnded) { diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncMediaWorker.kt b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncMediaWorker.kt index 632cbcdc3..d4ff9250c 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncMediaWorker.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncMediaWorker.kt @@ -14,6 +14,7 @@ import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.common.sync.SyncResult import com.crisiscleanup.sync.initializers.SYNC_MEDIA_NOTIFICATION_ID import com.crisiscleanup.sync.initializers.SyncMediaConstraints +import com.crisiscleanup.sync.initializers.SyncMediaImmediateConstraints import com.crisiscleanup.sync.initializers.channelNotificationManager import com.crisiscleanup.sync.initializers.syncForegroundInfo import dagger.assisted.Assisted @@ -73,8 +74,8 @@ internal class SyncMediaWorker @AssistedInject constructor( } companion object { - fun oneTimeSyncWork() = OneTimeWorkRequestBuilder() - .setConstraints(SyncMediaConstraints) + fun oneTimeSyncWork(syncImmediate: Boolean) = OneTimeWorkRequestBuilder() + .setConstraints(if (syncImmediate) SyncMediaImmediateConstraints else SyncMediaConstraints) .setInputData(SyncMediaWorker::class.delegatedData()) .build() }