From 094b846805a92d5dde58a31eca2b297b5145a8ec Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 5 Feb 2024 18:19:47 -0500 Subject: [PATCH 1/5] Show message when invited account already exists for org --- .../CrisisCleanupInterceptorProvider.kt | 9 ++- .../data/repository/OrgVolunteerRepository.kt | 19 ++---- .../core/database/dao/RecentWorksiteDao.kt | 12 ++-- .../component/RegisterSuccessView.kt | 2 +- .../core/model/data/InvitationRequest.kt | 12 ++++ .../core/network/CrisisCleanupRegisterApi.kt | 7 +- .../core/network/model/NetworkError.kt | 1 + .../network/retrofit/RegisterApiClient.kt | 66 ++++++++++++++---- .../RequestOrgAccessViewModel.kt | 24 ++++--- .../caseeditor/CreateEditCaseViewModel.kt | 53 +++++++++------ .../caseeditor/ui/FullAddressSearchScreen.kt | 2 + .../caseeditor/ui/MoveLocationOnMapScreen.kt | 2 +- .../InviteTeammateViewModel.kt | 68 ++++++++++++------- 13 files changed, 188 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt index 8ab67f2b0..ffbdeefa3 100644 --- a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt +++ b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt @@ -43,9 +43,11 @@ private fun Json.parseNetworkErrors(response: String): List { +private fun Json.parseNetworkErrors(responseBody: ResponseBody): Pair> { + // TODO Large body can result in OOM. Parse in parts as necessary. val errorBody = responseBody.string() - return parseNetworkErrors(errorBody) + val errors = parseNetworkErrors(errorBody) + return Pair(errorBody, errors) } private val Request.pathsForLog: String @@ -204,12 +206,13 @@ class CrisisCleanupInterceptorProvider @Inject constructor( getHeaderKey(request, RequestHeaderKey.ThrowClientError)?.let { response.body?.let { responseBody -> logger.logCapture("Code ${response.code} message ${response.message} paths ${request.pathsForLog}") - val errors = json.parseNetworkErrors(responseBody) + val (body, errors) = json.parseNetworkErrors(responseBody) throw CrisisCleanupNetworkException( request.url.toUrl().toString(), response.code, response.message, errors, + body, ) } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt index 6320baccc..5f333aa89 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt @@ -7,8 +7,10 @@ import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.model.data.CodeInviteAccept import com.crisiscleanup.core.model.data.IncidentOrganizationInviteInfo import com.crisiscleanup.core.model.data.InvitationRequest +import com.crisiscleanup.core.model.data.InvitationRequestResult import com.crisiscleanup.core.model.data.JoinOrgInvite import com.crisiscleanup.core.model.data.JoinOrgResult +import com.crisiscleanup.core.model.data.OrgInviteResult import com.crisiscleanup.core.model.data.OrgUserInviteInfo import com.crisiscleanup.core.network.CrisisCleanupRegisterApi import kotlinx.datetime.Instant @@ -23,7 +25,7 @@ interface OrgVolunteerRepository { suspend fun getOrganizationInvite(organizationId: Long, inviterUserId: Long): JoinOrgInvite suspend fun acceptPersistentInvitation(invite: CodeInviteAccept): JoinOrgResult - suspend fun inviteToOrganization(emailAddress: String, organizationId: Long?): Boolean + suspend fun inviteToOrganization(emailAddress: String, organizationId: Long?): OrgInviteResult suspend fun createOrganization( referer: String, invite: IncidentOrganizationInviteInfo, @@ -37,11 +39,7 @@ class CrisisCleanupOrgVolunteerRepository @Inject constructor( override suspend fun requestInvitation(invite: InvitationRequest): InvitationRequestResult? { try { // TODO Handle cases where an invite was already sent to the user from the org - val result = registerApi.registerOrgVolunteer(invite) - return InvitationRequestResult( - organizationName = result.requestedOrganization, - organizationRecipient = result.requestedTo, - ) + return registerApi.registerOrgVolunteer(invite) } catch (e: Exception) { logger.logException(e) } @@ -107,13 +105,13 @@ class CrisisCleanupOrgVolunteerRepository @Inject constructor( override suspend fun inviteToOrganization( emailAddress: String, organizationId: Long?, - ): Boolean { + ): OrgInviteResult { try { return registerApi.inviteToOrganization(emailAddress, organizationId) } catch (e: Exception) { logger.logException(e) } - return false + return OrgInviteResult.Unknown } override suspend fun createOrganization( @@ -128,8 +126,3 @@ class CrisisCleanupOrgVolunteerRepository @Inject constructor( return false } } - -data class InvitationRequestResult( - val organizationName: String, - val organizationRecipient: String, -) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt index 023a0f4fc..97572693d 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt @@ -1,6 +1,9 @@ package com.crisiscleanup.core.database.dao -import androidx.room.* +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert import com.crisiscleanup.core.database.model.PopulatedRecentWorksite import com.crisiscleanup.core.database.model.RecentWorksiteEntity import kotlinx.coroutines.flow.Flow @@ -13,9 +16,10 @@ interface RecentWorksiteDao { @Transaction @Query( """ - SELECT * - FROM recent_worksites - WHERE incident_id=:incidentId + SELECT r.* + FROM recent_worksites r + INNER JOIN worksites w ON r.id=w.id + WHERE r.incident_id=:incidentId AND w.incident_id=:incidentId ORDER BY viewed_at DESC LIMIT :limit OFFSET :offset diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/RegisterSuccessView.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/RegisterSuccessView.kt index 1ea465be5..794430179 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/RegisterSuccessView.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/RegisterSuccessView.kt @@ -51,7 +51,7 @@ fun RegisterSuccessView( Spacer(Modifier.weight(1f)) - CrisisCleanupLogoRow(Modifier,true) + CrisisCleanupLogoRow(Modifier, true) Spacer(Modifier.weight(1f)) } diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt index cffa8298e..dc8956c6e 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt @@ -12,6 +12,12 @@ data class InvitationRequest( val inviterEmailAddress: String, ) +data class InvitationRequestResult( + val organizationName: String, + val organizationRecipient: String, + val isNewAccountRequest: Boolean, +) + data class IncidentOrganizationInviteInfo( val incidentId: Long, val organizationName: String, @@ -32,3 +38,9 @@ data class CodeInviteAccept( val invitationCode: String, ) + +enum class OrgInviteResult { + Invited, + Redundant, + Unknown, +} diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupRegisterApi.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupRegisterApi.kt index fab484464..32fa2cd0b 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupRegisterApi.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupRegisterApi.kt @@ -4,13 +4,14 @@ import com.crisiscleanup.core.common.event.UserPersistentInvite import com.crisiscleanup.core.model.data.CodeInviteAccept import com.crisiscleanup.core.model.data.IncidentOrganizationInviteInfo import com.crisiscleanup.core.model.data.InvitationRequest +import com.crisiscleanup.core.model.data.InvitationRequestResult import com.crisiscleanup.core.model.data.JoinOrgResult +import com.crisiscleanup.core.model.data.OrgInviteResult import com.crisiscleanup.core.model.data.OrgUserInviteInfo -import com.crisiscleanup.core.network.model.NetworkAcceptedInvitationRequest import com.crisiscleanup.core.network.model.NetworkPersistentInvitation interface CrisisCleanupRegisterApi { - suspend fun registerOrgVolunteer(invite: InvitationRequest): NetworkAcceptedInvitationRequest + suspend fun registerOrgVolunteer(invite: InvitationRequest): InvitationRequestResult? suspend fun getInvitationInfo(invite: UserPersistentInvite): OrgUserInviteInfo? @@ -25,7 +26,7 @@ interface CrisisCleanupRegisterApi { suspend fun acceptPersistentInvitation(invite: CodeInviteAccept): JoinOrgResult - suspend fun inviteToOrganization(emailAddress: String, organizationId: Long?): Boolean + suspend fun inviteToOrganization(emailAddress: String, organizationId: Long?): OrgInviteResult suspend fun registerOrganization( referer: String, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkError.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkError.kt index dd0e1a5ed..1cb6c786b 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkError.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkError.kt @@ -14,6 +14,7 @@ class CrisisCleanupNetworkException( val statusCode: Int, message: String, val errors: List, + val body: String = "", ) : IOException(message) @Serializable 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 58b8c8d6d..33ccb81b0 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 @@ -6,9 +6,12 @@ import com.crisiscleanup.core.model.data.CodeInviteAccept import com.crisiscleanup.core.model.data.ExpiredNetworkOrgInvite import com.crisiscleanup.core.model.data.IncidentOrganizationInviteInfo import com.crisiscleanup.core.model.data.InvitationRequest +import com.crisiscleanup.core.model.data.InvitationRequestResult import com.crisiscleanup.core.model.data.JoinOrgResult +import com.crisiscleanup.core.model.data.OrgInviteResult import com.crisiscleanup.core.model.data.OrgUserInviteInfo import com.crisiscleanup.core.network.CrisisCleanupRegisterApi +import com.crisiscleanup.core.network.model.CrisisCleanupNetworkException import com.crisiscleanup.core.network.model.NetworkAcceptCodeInvite import com.crisiscleanup.core.network.model.NetworkAcceptPersistentInvite import com.crisiscleanup.core.network.model.NetworkAcceptedCodeInvitationRequest @@ -26,6 +29,7 @@ import com.crisiscleanup.core.network.model.NetworkPersistentInvitation import com.crisiscleanup.core.network.model.NetworkPersistentInvitationResult import com.crisiscleanup.core.network.model.NetworkRegisterOrganizationResult import com.crisiscleanup.core.network.model.NetworkUser +import com.crisiscleanup.core.network.model.condenseMessages import com.crisiscleanup.core.network.model.profilePictureUrl import retrofit2.Retrofit import retrofit2.http.Body @@ -38,6 +42,7 @@ import javax.inject.Inject import javax.inject.Singleton private interface RegisterApi { + @ThrowClientErrorHeader @POST("invitation_requests") suspend fun requestInvitation( @Body invitationRequest: NetworkInvitationRequest, @@ -79,6 +84,7 @@ private interface RegisterApi { @Body org: NetworkCreateOrgInvitation, ): NetworkPersistentInvitationResult + @ThrowClientErrorHeader @POST("persistent_invitations/accept") suspend fun acceptPersistentInvitation( @Body acceptInvite: NetworkAcceptPersistentInvite, @@ -86,6 +92,7 @@ private interface RegisterApi { @TokenAuthenticationHeader @WrapResponseHeader("invite") + @ThrowClientErrorHeader @POST("invitations") suspend fun inviteToOrganization( @Body invite: NetworkOrganizationInvite, @@ -105,7 +112,7 @@ class RegisterApiClient @Inject constructor( ) : CrisisCleanupRegisterApi { private val networkApi = retrofit.create(RegisterApi::class.java) - override suspend fun registerOrgVolunteer(invite: InvitationRequest): NetworkAcceptedInvitationRequest { + override suspend fun registerOrgVolunteer(invite: InvitationRequest): InvitationRequestResult? { val inviteRequest = NetworkInvitationRequest( firstName = invite.firstName, lastName = invite.lastName, @@ -117,7 +124,21 @@ class RegisterApiClient @Inject constructor( requestedTo = invite.inviterEmailAddress, primaryLanguage = invite.languageId, ) - return networkApi.requestInvitation(inviteRequest) + try { + val result = networkApi.requestInvitation(inviteRequest) + return InvitationRequestResult( + organizationName = result.requestedOrganization, + organizationRecipient = result.requestedTo, + isNewAccountRequest = true, + ) + } catch (e: CrisisCleanupNetworkException) { + if (e.errors.condenseMessages.contains("already have an account")) { + return InvitationRequestResult("", "", false) + } + } + + // TODO Be explicit in result + return null } private suspend fun getUserDetails(userId: Long): UserDetails { @@ -217,22 +238,43 @@ class RegisterApiClient @Inject constructor( mobile = invite.mobile, token = invite.invitationCode, ) - val response = networkApi.acceptPersistentInvitation(payload) - // TODO Parse response.detail even when response code is 400 - return when (response.detail) { - "You have been added to the organization." -> JoinOrgResult.Success - "User already a member of this organization." -> JoinOrgResult.Redundant - else -> JoinOrgResult.Unknown + try { + val response = networkApi.acceptPersistentInvitation(payload) + return when (response.detail) { + "You have been added to the organization." -> JoinOrgResult.Success + "User already a member of this organization." -> JoinOrgResult.Redundant + else -> JoinOrgResult.Unknown + } + } catch (e: CrisisCleanupNetworkException) { + if (e.body.contains("User already a member of this organization.")) { + return JoinOrgResult.Redundant + } } + return JoinOrgResult.Unknown } override suspend fun inviteToOrganization( emailAddress: String, organizationId: Long?, - ): Boolean { - val invite = - networkApi.inviteToOrganization(NetworkOrganizationInvite(emailAddress, organizationId)) - return invite.invite?.inviteeEmail == emailAddress + ): OrgInviteResult { + try { + val invite = + networkApi.inviteToOrganization( + NetworkOrganizationInvite( + emailAddress, + organizationId, + ), + ) + if (invite.invite?.inviteeEmail == emailAddress) { + return OrgInviteResult.Invited + } + } catch (e: CrisisCleanupNetworkException) { + if (e.errors.condenseMessages.contains("is already a part of this organization")) { + return OrgInviteResult.Redundant + } + } + + return OrgInviteResult.Unknown } override suspend fun registerOrganization( 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 ea54857fb..1c9434497 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 @@ -15,11 +15,11 @@ 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.InvitationRequestResult import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.OrgVolunteerRepository import com.crisiscleanup.core.model.data.CodeInviteAccept import com.crisiscleanup.core.model.data.InvitationRequest +import com.crisiscleanup.core.model.data.InvitationRequestResult import com.crisiscleanup.core.model.data.JoinOrgResult import com.crisiscleanup.core.model.data.LanguageIdName import com.crisiscleanup.core.model.data.OrgUserInviteInfo @@ -68,7 +68,7 @@ class RequestOrgAccessViewModel @Inject constructor( private val isRequestingInvite = MutableStateFlow(false) - val requestedOrg = MutableStateFlow(null) + private val requestedOrg = MutableStateFlow(null) val isInviteRequested = MutableStateFlow(false) var requestSentTitle by mutableStateOf("") @@ -117,13 +117,18 @@ class RequestOrgAccessViewModel @Inject constructor( requestedOrg .onEach { result -> result?.let { - requestSentTitle = translator("requestAccess.request_sent") - requestSentText = translator("requestAccess.request_sent_to_org") - .replace("{organization}", result.organizationName) - .replace("{requested_to}", result.organizationRecipient) + if (it.isNewAccountRequest) { + requestSentTitle = translator("requestAccess.request_sent") + requestSentText = translator("requestAccess.request_sent_to_org") + .replace("{organization}", result.organizationName) + .replace("{requested_to}", result.organizationRecipient) + } else { + inviteInfoErrorMessage.value = + translator("requestAccess.already_in_org_error") + } } - isInviteRequested.value = result != null + isInviteRequested.value = result?.isNewAccountRequest == true } .launchIn(viewModelScope) @@ -219,7 +224,6 @@ class RequestOrgAccessViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { try { if (showEmailInput) { - // TODO Test requestedOrg.value = orgVolunteerRepository.requestInvitation( InvitationRequest( firstName = userInfo.firstName, @@ -248,9 +252,11 @@ class RequestOrgAccessViewModel @Inject constructor( ) if (inviteResult == JoinOrgResult.Success) { val inviteInfo = inviteDisplay.value?.inviteInfo + val orgName = inviteInfo?.orgName ?: "" requestedOrg.value = InvitationRequestResult( - organizationName = inviteInfo?.orgName ?: "", + organizationName = orgName, organizationRecipient = inviteInfo?.inviterEmail ?: "", + isNewAccountRequest = orgName.isNotBlank(), ) } else { var errorMessageTranslateKey = "requestAccess.join_org_error" 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 eafd27c0b..be4d2f1a3 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 @@ -474,18 +474,23 @@ class CreateEditCaseViewModel @Inject constructor( inputData: LocationInputData, changeIncident: Incident, addressChangeWorksite: Worksite, - ) = with(worksiteProvider) { + ) { + // TODO Prevent user mutations when ongoing. Test. + // Assume any address changes first before copying inputData.assumeLocationAddressChanges(addressChangeWorksite) - val copiedWorksite = copyChanges() + var copiedWorksite = copyChanges() if (copiedWorksite != EmptyWorksite) { if (copiedWorksite.isNew) { + notesFlagsEditor?.let { + copiedWorksite = transferEditingNote(it.notesFlagsInputData, copiedWorksite) + } worksiteProvider.updateIncidentChangeWorksite(copiedWorksite) changeWorksiteIncidentId.value = changeIncident.id incidentSelector.setIncident(changeIncident) } else { - takeIncidentChanged() + worksiteProvider.takeIncidentChanged() saveChangeIncident = changeIncident saveChanges(false, backOnSuccess = false) @@ -646,6 +651,28 @@ class CreateEditCaseViewModel @Inject constructor( } } + private fun transferEditingNote( + notesInputData: NotesFlagsInputData, + worksite: Worksite, + ): Worksite { + var updatedWorksite = worksite + + val editingNote = notesInputData.editingNote.trim() + var notes = worksite.notes + if (editingNote.isNotBlank() && + (notes.isEmpty() || notes.first().note.trim() != editingNote) + ) { + notes = notes.toMutableList() + .also { + val note = WorksiteNote.create().copy(note = editingNote) + it.add(0, note) + } + updatedWorksite = updatedWorksite.copy(notes = notes) + notesInputData.editingNote = "" + } + return updatedWorksite + } + private fun transferChanges(indicateInvalidSection: Boolean = false): Boolean { tryGetEditorState()?.let { editorState -> (workEditor as? EditableWorkDataEditor)?.let { workDataEditor -> @@ -659,24 +686,10 @@ class CreateEditCaseViewModel @Inject constructor( } return false } + } - (dataWriter as? NotesFlagsInputData)?.let { notesInputData -> - val editingNote = notesInputData.editingNote.trim() - var notes = worksite!!.notes - if (editingNote.isNotBlank() && - (notes.isEmpty() || notes.first().note.trim() != editingNote) - ) { - notes = notes.toMutableList() - .also { - val note = WorksiteNote.create().copy(note = editingNote) - it.add(0, note) - } - worksite = worksite!!.copy( - notes = notes, - ) - notesInputData.editingNote = "" - } - } + notesFlagsEditor?.let { + worksite = transferEditingNote(it.notesFlagsInputData, worksite!!) } val workTypeLookup = editorState.incident.workTypeLookup diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FullAddressSearchScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FullAddressSearchScreen.kt index eeaff93e3..a2c9f626f 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FullAddressSearchScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FullAddressSearchScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -75,6 +76,7 @@ internal fun FullAddressSearchInput( value = locationQuery, onValueChange = updateQuery, keyboardType = KeyboardType.Password, + keyboardCapitalization = KeyboardCapitalization.Words, isError = false, hasFocus = hasFocus, enabled = isEditable, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt index be2804bb7..081d2f9c9 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt @@ -167,7 +167,7 @@ private fun PortraitLayout( onUseMyLocation = onUseMyLocation, ) - SaveActionBar(viewModel, editor, onBack, isEditable) + SaveActionBar(viewModel, editor, onBack, isEditable, horizontalLayout = true) } else { editor.isMapLoaded = false AddressSearchResults(viewModel, editor, locationQuery, isEditable = isEditable) 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 c0920d0ca..447fa452e 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 @@ -31,6 +31,7 @@ import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.Incident import com.crisiscleanup.core.model.data.IncidentOrganizationInviteInfo import com.crisiscleanup.core.model.data.JoinOrgInvite +import com.crisiscleanup.core.model.data.OrgInviteResult import com.crisiscleanup.core.model.data.OrganizationIdName import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher @@ -569,22 +570,13 @@ class InviteTeammateViewModel @Inject constructor( private suspend fun inviteToOrgOrAffiliate( emailAddresses: List, organizationId: Long? = null, - ): Boolean { - val notInvited = mutableListOf() + ): List { + val inviteResults = mutableListOf() for (emailAddress in emailAddresses) { - val invited = orgVolunteerRepository.inviteToOrganization(emailAddress, organizationId) - if (!invited) { - notInvited.add(emailAddress) - } + val result = orgVolunteerRepository.inviteToOrganization(emailAddress, organizationId) + inviteResults.add(InviteResult(emailAddress, result)) } - - if (notInvited.isNotEmpty()) { - sendInviteErrorMessage.value = - translator("inviteTeammates.emails_not_invited_error") - .replace("{email_addresses}", notInvited.joinToString("\n ")) - return false - } - return true + return inviteResults } private fun onInviteSent(title: String, text: String) { @@ -679,7 +671,7 @@ class InviteTeammateViewModel @Inject constructor( } } - var isSentToOrgOrAffiliate = false + var inviteResults = emptyList() var isInviteSuccessful = false if (inviteToAnotherOrg.value) { if (inviteState.new) { @@ -708,24 +700,49 @@ class InviteTeammateViewModel @Inject constructor( isInviteSuccessful = true } } else if (inviteState.affiliate) { - isSentToOrgOrAffiliate = - inviteToOrgOrAffiliate(emailAddresses, selectedOtherOrg.value.id) - isInviteSuccessful = isSentToOrgOrAffiliate + inviteResults = inviteToOrgOrAffiliate(emailAddresses, selectedOtherOrg.value.id) + isInviteSuccessful = + inviteResults.any { it.inviteResult == OrgInviteResult.Invited } } else if (inviteState.nonAffiliate) { // TODO Finish when API supports a corresponding endpoint } } else { - isSentToOrgOrAffiliate = inviteToOrgOrAffiliate(emailAddresses) - isInviteSuccessful = isSentToOrgOrAffiliate + inviteResults = inviteToOrgOrAffiliate(emailAddresses) + isInviteSuccessful = inviteResults.any { it.inviteResult == OrgInviteResult.Invited } } - if (isSentToOrgOrAffiliate) { - onInviteSentToOrgOrAffiliate(emailAddresses) + val invited = inviteResults + .filter { it.inviteResult == OrgInviteResult.Invited } + .map(InviteResult::emailAddress) + if (invited.isNotEmpty()) { + onInviteSentToOrgOrAffiliate(invited) } if (!isInviteSuccessful) { + val uninvited = inviteResults + .filter { it.inviteResult == OrgInviteResult.Unknown } + .map(InviteResult::emailAddress) + val existing = inviteResults + .filter { it.inviteResult == OrgInviteResult.Redundant } + .map(InviteResult::emailAddress) + var uninvitedMessage = "" + var existingMessage = "" + if (uninvited.isNotEmpty()) { + uninvitedMessage = + translator("inviteTeammates.emails_not_invited_error") + .replace("{email_addresses}", uninvited.joinToString(", ")) + } + if (existing.isNotEmpty()) { + existingMessage = + translator("~~{email_addresses} have accounts.") + .replace("{email_addresses}", existing.joinToString(", ")) + } + + val errorMessage = listOf(uninvitedMessage, existingMessage) + .filter { it.isNotBlank() } + .joinToString("\n") sendInviteErrorMessage.value = - translator("inviteTeammates.invite_error") + errorMessage.ifBlank { translator("inviteTeammates.invite_error") } } } } @@ -749,3 +766,8 @@ data class InviteOrgState( val new: Boolean, val ownOrAffiliate: Boolean = own || affiliate, ) + +private data class InviteResult( + val emailAddress: String, + val inviteResult: OrgInviteResult, +) From 97b311ee7069eaacd34611877b98b299b703d954 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 5 Feb 2024 18:39:21 -0500 Subject: [PATCH 2/5] Skip deserialization when success is zero --- .../network/CrisisCleanupInterceptorProvider.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt index ffbdeefa3..fa8f7ff00 100644 --- a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt +++ b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt @@ -34,11 +34,13 @@ import javax.inject.Singleton private fun Json.parseNetworkErrors(response: String): List { var errors: List = emptyList() - try { - val networkErrors = decodeFromString(response) - errors = networkErrors.errors - } catch (e: Exception) { - // No errors or not formatted as expected + if (response.contains("errors")) { + try { + val networkErrors = decodeFromString(response) + errors = networkErrors.errors + } catch (e: Exception) { + // No errors or not formatted as expected + } } return errors } From baa7d0e4be834cee66aaf37f8d26068500dbfa6f Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 5 Feb 2024 19:17:29 -0500 Subject: [PATCH 3/5] Simplify uninvited messaging --- .../InviteTeammateViewModel.kt | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) 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 447fa452e..0ed25bf3e 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 @@ -720,29 +720,16 @@ class InviteTeammateViewModel @Inject constructor( if (!isInviteSuccessful) { val uninvited = inviteResults - .filter { it.inviteResult == OrgInviteResult.Unknown } - .map(InviteResult::emailAddress) - val existing = inviteResults - .filter { it.inviteResult == OrgInviteResult.Redundant } + .filter { it.inviteResult != OrgInviteResult.Invited } .map(InviteResult::emailAddress) var uninvitedMessage = "" - var existingMessage = "" if (uninvited.isNotEmpty()) { uninvitedMessage = translator("inviteTeammates.emails_not_invited_error") .replace("{email_addresses}", uninvited.joinToString(", ")) } - if (existing.isNotEmpty()) { - existingMessage = - translator("~~{email_addresses} have accounts.") - .replace("{email_addresses}", existing.joinToString(", ")) - } - - val errorMessage = listOf(uninvitedMessage, existingMessage) - .filter { it.isNotBlank() } - .joinToString("\n") sendInviteErrorMessage.value = - errorMessage.ifBlank { translator("inviteTeammates.invite_error") } + uninvitedMessage.ifBlank { translator("inviteTeammates.invite_error") } } } } From 8d51b620ceb9f9016b8959d623fb7442c265e461 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 6 Feb 2024 13:37:00 -0500 Subject: [PATCH 4/5] Open existing case from edit replaces entire back stack --- .../navigation/CrisisCleanupNavHost.kt | 12 ++---------- .../core/appnav/CommonNavigation.kt | 12 ++++++++++++ .../feature/caseeditor/ViewCaseViewModel.kt | 3 --- .../navigation/CaseEditorNavigation.kt | 19 ++++++------------- 4 files changed, 20 insertions(+), 26 deletions(-) create mode 100644 core/appnav/src/main/java/com/crisiscleanup/core/appnav/CommonNavigation.kt diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index 2e50e82c0..ac7b2c3d0 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.crisiscleanup.core.appnav.RouteConstant.casesGraphRoutePattern +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 @@ -21,7 +22,6 @@ import com.crisiscleanup.feature.caseeditor.navigation.existingCaseScreen import com.crisiscleanup.feature.caseeditor.navigation.existingCaseTransferWorkTypesScreen import com.crisiscleanup.feature.caseeditor.navigation.navigateToCaseAddFlag import com.crisiscleanup.feature.caseeditor.navigation.navigateToCaseEditor -import com.crisiscleanup.feature.caseeditor.navigation.navigateToExistingCase import com.crisiscleanup.feature.caseeditor.navigation.navigateToTransferWorkType import com.crisiscleanup.feature.caseeditor.navigation.rerouteToCaseChange import com.crisiscleanup.feature.cases.navigation.casesFilterScreen @@ -52,7 +52,7 @@ import com.crisiscleanup.feature.userfeedback.navigation.userFeedbackScreen fun CrisisCleanupNavHost( navController: NavHostController, onBack: () -> Unit, - openAuthentication: () -> Unit = {}, + openAuthentication: () -> Unit, modifier: Modifier = Modifier, startDestination: String = casesGraphRoutePattern, ) { @@ -80,14 +80,6 @@ fun CrisisCleanupNavHost( } } - val replaceRouteOpenCase = remember(navController) { - { incidentId: Long, worksiteId: Long -> - navController.popBackStack() - viewCase(incidentId, worksiteId) - true - } - } - val replaceRouteViewCase = remember(navController) { { ids: ExistingWorksiteIdentifier -> navController.rerouteToCaseChange(ids) } } diff --git a/core/appnav/src/main/java/com/crisiscleanup/core/appnav/CommonNavigation.kt b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/CommonNavigation.kt new file mode 100644 index 000000000..e0be364b9 --- /dev/null +++ b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/CommonNavigation.kt @@ -0,0 +1,12 @@ +package com.crisiscleanup.core.appnav + +import androidx.navigation.NavController +import com.crisiscleanup.core.appnav.RouteConstant.viewCaseRoute + +const val incidentIdArg = "incidentId" +const val worksiteIdArg = "worksiteId" + +fun NavController.navigateToExistingCase(incidentId: Long, worksiteId: Long) { + // Must match composable route signature + this.navigate("${viewCaseRoute}?$incidentIdArg=$incidentId&$worksiteIdArg=$worksiteId") +} diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt index 35346371f..6b038212d 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt @@ -82,7 +82,6 @@ import java.text.SimpleDateFormat import java.time.format.DateTimeFormatter import java.util.Date import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject @HiltViewModel @@ -214,8 +213,6 @@ class ViewCaseViewModel @Inject constructor( val jumpToCaseOnMapOnBack = MutableStateFlow(false) - private val previousNoteCount = AtomicInteger(0) - var addImageCategory by mutableStateOf(ImageCategory.Before) val hasCamera = packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/navigation/CaseEditorNavigation.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/navigation/CaseEditorNavigation.kt index 6f89393ff..4b356bd89 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/navigation/CaseEditorNavigation.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/navigation/CaseEditorNavigation.kt @@ -1,6 +1,5 @@ package com.crisiscleanup.feature.caseeditor.navigation -import androidx.annotation.VisibleForTesting import androidx.compose.runtime.remember import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController @@ -19,7 +18,10 @@ import com.crisiscleanup.core.appnav.RouteConstant.casesRoute import com.crisiscleanup.core.appnav.RouteConstant.viewCaseRoute import com.crisiscleanup.core.appnav.RouteConstant.viewCaseTransferWorkTypesRoute import com.crisiscleanup.core.appnav.ViewImageArgs +import com.crisiscleanup.core.appnav.incidentIdArg +import com.crisiscleanup.core.appnav.navigateToExistingCase import com.crisiscleanup.core.appnav.navigateToViewImage +import com.crisiscleanup.core.appnav.worksiteIdArg import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.EmptyWorksite @@ -32,9 +34,6 @@ import com.crisiscleanup.feature.caseeditor.ui.EditExistingCaseRoute import com.crisiscleanup.feature.caseeditor.ui.TransferWorkTypesRoute import com.crisiscleanup.feature.caseeditor.ui.addflag.CaseEditAddFlagRoute -@VisibleForTesting -internal const val incidentIdArg = "incidentId" -internal const val worksiteIdArg = "worksiteId" internal const val isFromCaseEditArg = "isFromCaseEdit" internal class CaseEditorArgs(val incidentId: Long, val worksiteId: Long?) { @@ -70,10 +69,6 @@ fun NavController.navigateToCaseEditor(incidentId: Long, worksiteId: Long? = nul this.navigate(route) } -fun NavController.navigateToExistingCase(incidentId: Long, worksiteId: Long) { - this.navigate("$viewCaseRoute?$incidentIdArg=$incidentId&$worksiteIdArg=$worksiteId") -} - fun NavGraphBuilder.caseEditorScreen( navController: NavHostController, onBackClick: () -> Unit, @@ -186,10 +181,7 @@ internal fun NavController.popRouteStartingWith(route: String) { } private fun NavController.popToWork() { - popBackStack() - while (currentBackStackEntry?.destination?.route?.let { it != casesRoute } == true) { - popBackStack() - } + popBackStack(casesRoute, false, saveState = false) } fun NavController.rerouteToNewCase(incidentId: Long) { @@ -198,7 +190,8 @@ fun NavController.rerouteToNewCase(incidentId: Long) { } fun NavController.rerouteToCaseEdit(ids: ExistingWorksiteIdentifier) { - popRouteStartingWith(caseEditorRoute) + popToWork() + navigateToExistingCase(ids.incidentId, ids.worksiteId) navigateToCaseEditor(ids.incidentId, ids.worksiteId) } From 728537ad6415bd32342b75269387793273005f3f Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 6 Feb 2024 14:41:26 -0500 Subject: [PATCH 5/5] Bump version --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 299c48d9b..56f6a9ad8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 186 + val buildVersion = 187 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}"