diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingErrorDialog.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingErrorDialog.kt index 8c470c994f..f87866f989 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingErrorDialog.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingErrorDialog.kt @@ -24,35 +24,64 @@ import com.wire.android.feature.meetings.ui.util.PreviewMultipleThemes import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.theme.WireTheme +import com.wire.kalium.logic.data.id.ConversationId import com.wire.android.ui.common.R as commonR @Composable fun NewMeetingErrorDialog( + error: NewMeetingState.SubmitError, type: NewMeetingType, + isSubmitting: Boolean, onDismiss: () -> Unit, + onRetryUpdateConversationName: (conversationId: ConversationId) -> Unit ) { // TODO: specific errors to be handled later, for now we just show single generic one - val (titleResId, descriptionResId) = when (type) { - NewMeetingType.MeetNow -> R.string.new_meeting_now_failure_title to R.string.new_meeting_now_failure_description - NewMeetingType.Schedule -> R.string.new_meeting_schedule_failure_title to R.string.new_meeting_schedule_failure_description - is NewMeetingType.Edit -> R.string.new_meeting_edit_failure_title to R.string.new_meeting_edit_failure_description + val (titleResId, descriptionResId) = when (error) { + is NewMeetingState.SubmitError.UpdateConversationNameFailure -> + R.string.new_meeting_edit_conversation_name_failure_title to R.string.new_meeting_edit_conversation_name_failure_description + + is NewMeetingState.SubmitError.Other -> when (type) { + NewMeetingType.MeetNow -> R.string.new_meeting_now_failure_title to R.string.new_meeting_now_failure_description + NewMeetingType.Schedule -> R.string.new_meeting_schedule_failure_title to R.string.new_meeting_schedule_failure_description + is NewMeetingType.Edit -> R.string.new_meeting_edit_failure_title to R.string.new_meeting_edit_failure_description + } } + WireDialog( title = stringResource(titleResId), text = stringResource(descriptionResId), onDismiss = onDismiss, buttonsHorizontalAlignment = false, - optionButton1Properties = WireDialogButtonProperties( - onClick = onDismiss, - text = stringResource(commonR.string.label_ok), - type = WireDialogButtonType.Primary, - ), + optionButton1Properties = when (error) { + is NewMeetingState.SubmitError.Other -> WireDialogButtonProperties( + onClick = onDismiss, + text = stringResource(commonR.string.label_ok), + type = WireDialogButtonType.Primary, + ) + + is NewMeetingState.SubmitError.UpdateConversationNameFailure -> WireDialogButtonProperties( + onClick = { + onRetryUpdateConversationName(error.conversationId) + }, + text = stringResource(R.string.new_meeting_edit_conversation_name_failure_action), + type = WireDialogButtonType.Primary, + state = if (isSubmitting) WireButtonState.Disabled else WireButtonState.Default, + loading = isSubmitting, + ) + } ) } @PreviewMultipleThemes @Composable private fun PreviewCreateGroupErrorDialogLackingConnection() = WireTheme { - NewMeetingErrorDialog(type = NewMeetingType.MeetNow, onDismiss = {}) + NewMeetingErrorDialog( + error = NewMeetingState.SubmitError.Other, + type = NewMeetingType.MeetNow, + isSubmitting = false, + onDismiss = {}, + onRetryUpdateConversationName = {}, + ) } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt index 7f89fcb46e..a123dd4ce3 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt @@ -151,10 +151,13 @@ fun NewMeetingScreen( onRepeatingIntervalChanged = newMeetingViewModel::updateRepeatingInterval, ) - if (newMeetingViewModel.state.submitError != null) { + newMeetingViewModel.state.submitError?.let { submitError -> NewMeetingErrorDialog( + error = submitError, type = newMeetingViewModel.type, - onDismiss = newMeetingViewModel::dismissCreationError + isSubmitting = newMeetingViewModel.state.isSubmitting, + onDismiss = newMeetingViewModel::dismissCreationError, + onRetryUpdateConversationName = newMeetingViewModel::retryUpdateConversationName, ) } if (newMeetingViewModel.state.initialLoading == NewMeetingState.InitialLoadingState.Error) { diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt index b2cb1f7a62..1f89937bb0 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt @@ -37,11 +37,14 @@ import com.wire.android.ui.common.ActionsManager import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.textfield.textAsFlow import com.wire.android.util.CurrentTimeProvider +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.meeting.Meeting import com.wire.kalium.logic.data.meeting.UpsertMeeting import com.wire.kalium.logic.data.user.OtherUser import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.ObserveConversationMembersUseCase +import com.wire.kalium.logic.feature.conversation.RenameConversationUseCase +import com.wire.kalium.logic.feature.conversation.RenamingResult import com.wire.kalium.logic.feature.meeting.CreateNewMeetingUseCase import com.wire.kalium.logic.feature.meeting.GetNextMeetingOccurrenceUseCase import com.wire.kalium.logic.feature.meeting.UpdateMeetingUseCase @@ -79,6 +82,7 @@ interface NewMeetingViewModel : ActionsManager { fun submitCreation() {} fun submitUpdate() {} fun dismissCreationError() {} + fun retryUpdateConversationName(conversationId: ConversationId) {} companion object { const val MEETING_NAME_MAX_COUNT = 64 @@ -93,6 +97,7 @@ class NewMeetingViewModelPreview( override val state: NewMeetingState = initialState(currentTimeProvider) } +@Suppress("TooManyFunctions") class NewMeetingViewModelImpl @AssistedInject constructor( @Assisted savedStateHandle: SavedStateHandle, override val currentTimeProvider: CurrentTimeProvider, @@ -100,12 +105,14 @@ class NewMeetingViewModelImpl @AssistedInject constructor( private val updateMeeting: UpdateMeetingUseCase, private val getNextMeetingOccurrence: GetNextMeetingOccurrenceUseCase, private val observeConversationMembers: ObserveConversationMembersUseCase, + private val renameConversationUseCase: RenameConversationUseCase, private val contactMapper: ContactMapper, ) : ActionsViewModel(), NewMeetingViewModel { @AssistedFactory interface Factory { fun create(savedStateHandle: SavedStateHandle): NewMeetingViewModelImpl } + val navArgs: NewMeetingNavArgs = savedStateHandle.navArgs() override val type: NewMeetingType = navArgs.type override val titleTextState: TextFieldState = TextFieldState() @@ -279,7 +286,28 @@ class NewMeetingViewModelImpl @AssistedInject constructor( state = state.copy(isSubmitting = false, continueButtonEnabled = true) when (updateResult) { is UpdateMeetingUseCase.Result.Success -> sendAction(NewMeetingViewActions.Success) - is UpdateMeetingUseCase.Result.Failure -> state = state.copy(submitError = NewMeetingState.SubmitError.Other) + is UpdateMeetingUseCase.Result.Failure -> state = state.copy( + submitError = when (updateResult) { + is UpdateMeetingUseCase.Result.Failure.UpdateConversationNameFailure -> + NewMeetingState.SubmitError.UpdateConversationNameFailure(updateResult.conversationId) + + else -> NewMeetingState.SubmitError.Other + } + ) + } + } + } + } + + override fun retryUpdateConversationName(conversationId: ConversationId) { + viewModelScope.launch { + state = state.copy(isSubmitting = true, continueButtonEnabled = false) + renameConversationUseCase(conversationId = conversationId, conversationName = titleTextState.text.trim().toString()).let { + state = state.copy(isSubmitting = false, continueButtonEnabled = true) + when (it) { + is RenamingResult.Failure -> + state = state.copy(submitError = NewMeetingState.SubmitError.UpdateConversationNameFailure(conversationId)) + RenamingResult.Success -> sendAction(NewMeetingViewActions.Success) } } } @@ -334,6 +362,7 @@ data class NewMeetingState( } sealed interface SubmitError { + data class UpdateConversationNameFailure(val conversationId: ConversationId) : SubmitError data object Other : SubmitError // TODO Add more specific error types in the future } diff --git a/features/meetings/src/main/res/values/strings.xml b/features/meetings/src/main/res/values/strings.xml index e76dad20a4..710e7bfe92 100644 --- a/features/meetings/src/main/res/values/strings.xml +++ b/features/meetings/src/main/res/values/strings.xml @@ -83,6 +83,9 @@ Something went wrong while updating the meeting. Please try again. Could not load meeting Something went wrong while loading the meeting for update. Please try again. + Could not update call name + The meeting title was updated, but the name shown in the call could not be updated. Please try again. + Retry Could not join meeting Something went wrong while joining the meeting. Please try again. diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt index 959e3bebd0..afbff0a8ae 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt @@ -31,6 +31,7 @@ import com.wire.android.mapper.ContactMapper import com.wire.android.model.Contact import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.util.CurrentTimeProvider +import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.MemberDetails import com.wire.kalium.logic.data.id.ConversationId @@ -42,6 +43,8 @@ import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.OtherUser import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.ObserveConversationMembersUseCase +import com.wire.kalium.logic.feature.conversation.RenameConversationUseCase +import com.wire.kalium.logic.feature.conversation.RenamingResult import com.wire.kalium.logic.feature.meeting.CreateNewMeetingUseCase import com.wire.kalium.logic.feature.meeting.GetNextMeetingOccurrenceUseCase import com.wire.kalium.logic.feature.meeting.UpdateMeetingUseCase @@ -373,7 +376,7 @@ class NewMeetingViewModelTest { Arrangement(dispatcher) .withNewMeetingType(editType) .withNextMeetingOccurrence(nextOccurrence) - .withUpdateMeetingResult(nextOccurrence.meeting.meetingId, UpdateMeetingUseCase.Result.Failure) + .withUpdateMeetingResult(nextOccurrence.meeting.meetingId, UpdateMeetingUseCase.Result.Failure.Other) ) enterTitle(viewModel, "Weekly sync") @@ -390,6 +393,144 @@ class NewMeetingViewModelTest { } } + @Test + fun givenConversationNameEditionFails_whenSubmitUpdateIsCalled_thenErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val editType = NewMeetingType.Edit(MeetingId("meeting-id", "domain")) + val nextOccurrence = MEETING_OCCURRENCE.copy( + meeting = MEETING_OCCURRENCE.meeting.copy( + startTime = currentTime + 1.hours, + endTime = currentTime + 2.hours, + recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), + ), + occurrenceStartTime = currentTime + 1.hours, + occurrenceEndTime = currentTime + 2.hours, + ) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withNextMeetingOccurrence(nextOccurrence) + .withUpdateMeetingResult( + nextOccurrence.meeting.meetingId, + UpdateMeetingUseCase.Result.Failure.UpdateConversationNameFailure(nextOccurrence.meeting.conversationId) + ) + ) + + enterTitle(viewModel, "Weekly sync") + + viewModel.actions.test { + viewModel.submitUpdate() + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, any()) } + expectNoEvents() + assertFalse(viewModel.state.isSubmitting) + assertEquals(true, viewModel.state.continueButtonEnabled) + assertEquals( + NewMeetingState.SubmitError.UpdateConversationNameFailure(nextOccurrence.meeting.conversationId), + viewModel.state.submitError + ) + } + } + + @Test + fun givenUpdateActionReturnsUpdateConversationNameFailure_whenSubmitUpdateIsCalled_thenReturnedConversationErrorIsShown() = + runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val editType = NewMeetingType.Edit(MeetingId("meeting-id", "domain")) + val nextOccurrence = MEETING_OCCURRENCE.copy( + meeting = MEETING_OCCURRENCE.meeting.copy( + startTime = currentTime + 1.hours, + endTime = currentTime + 2.hours, + recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), + ), + occurrenceStartTime = currentTime + 1.hours, + occurrenceEndTime = currentTime + 2.hours, + ) + val failedConversationId = ConversationId("failed-conversation-id", "domain") + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withNextMeetingOccurrence(nextOccurrence) + .withUpdateMeetingResult( + nextOccurrence.meeting.meetingId, + UpdateMeetingUseCase.Result.Failure.UpdateConversationNameFailure(failedConversationId) + ) + ) + + enterTitle(viewModel, "Weekly sync") + + viewModel.actions.test { + viewModel.submitUpdate() + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, any()) } + expectNoEvents() + assertFalse(viewModel.state.isSubmitting) + assertEquals(true, viewModel.state.continueButtonEnabled) + assertEquals( + NewMeetingState.SubmitError.UpdateConversationNameFailure(failedConversationId), + viewModel.state.submitError + ) + } + } + + @Test + fun givenRetryUpdateConversationNameSucceeds_whenRetryUpdateConversationNameIsCalled_thenSuccessActionIsSent() = runTest(dispatcher) { + val conversationId = ConversationId("conversation-id", "domain") + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withRenameConversationResult(conversationId, "Weekly sync", RenamingResult.Success) + ) + + enterTitle(viewModel, " Weekly sync ") + + viewModel.actions.test { + viewModel.retryUpdateConversationName(conversationId) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.renameConversationUseCase( + conversationId = conversationId, + conversationName = "Weekly sync" + ) + } + assertNull(viewModel.state.submitError) + assertEquals(NewMeetingViewActions.Success, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun givenRetryUpdateConversationNameFails_whenRetryUpdateConversationNameIsCalled_thenErrorIsShownAndSuccessActionIsNotSent() = + runTest(dispatcher) { + val conversationId = ConversationId("conversation-id", "domain") + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withRenameConversationResult( + conversationId = conversationId, + conversationName = "Weekly sync", + result = RenamingResult.Failure(CoreFailure.Unknown(RuntimeException("Failed to rename conversation"))) + ) + ) + + enterTitle(viewModel, " Weekly sync ") + + viewModel.actions.test { + viewModel.retryUpdateConversationName(conversationId) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.renameConversationUseCase( + conversationId = conversationId, + conversationName = "Weekly sync" + ) + } + expectNoEvents() + assertEquals(NewMeetingState.SubmitError.UpdateConversationNameFailure(conversationId), viewModel.state.submitError) + } + } + @Test fun givenInvalidTitle_whenSubmitCreationIsCalled_thenTitleErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { val (arrangement, viewModel) = arrangeViewModel() @@ -563,6 +704,9 @@ class NewMeetingViewModelTest { @MockK lateinit var observeConversationMembers: ObserveConversationMembersUseCase + @MockK + lateinit var renameConversationUseCase: RenameConversationUseCase + @MockK lateinit var contactMapper: ContactMapper @@ -593,6 +737,10 @@ class NewMeetingViewModelTest { coEvery { updateMeeting(meetingId, any()) } returns result } + fun withRenameConversationResult(conversationId: ConversationId, conversationName: String, result: RenamingResult) = apply { + coEvery { renameConversationUseCase(conversationId, conversationName) } returns result + } + fun withNextMeetingOccurrence(nextMeetingOccurrence: MeetingOccurrence?) = apply { coEvery { getNextMeetingOccurrence(any(), any()) } returns nextMeetingOccurrence } @@ -616,6 +764,7 @@ class NewMeetingViewModelTest { updateMeeting = updateMeeting, getNextMeetingOccurrence = getNextMeetingOccurrence, observeConversationMembers = observeConversationMembers, + renameConversationUseCase = renameConversationUseCase, contactMapper = contactMapper, ) } diff --git a/kalium b/kalium index 48bd475599..ebf6d4fcd9 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 48bd475599dbb09ffc4989a21babaa6a31ffa147 +Subproject commit ebf6d4fcd99e9fb775bc0a90ca08969bb6904cc4