From 00aa7d10135c0e85199d015f6b7a0bcc3b7ac8cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Mon, 3 Aug 2026 15:55:05 +0200 Subject: [PATCH 1/5] feat: edit meeting [WPB-25677] --- .../android/di/accountScoped/MeetingModule.kt | 9 + .../ui/home/meetings/MeetingsScreen.kt | 3 + .../feature/meetings/mapper/MeetingMapper.kt | 5 +- .../feature/meetings/ui/AllMeetingsScreen.kt | 4 +- .../meetings/ui/MeetingsViewModelFactory.kt | 15 +- .../ui/create/NewMeetingErrorDialog.kt | 1 + .../meetings/ui/create/NewMeetingNavArgs.kt | 51 +-- .../meetings/ui/create/NewMeetingScreen.kt | 42 ++- .../meetings/ui/create/NewMeetingViewModel.kt | 102 ++++-- .../ui/options/MeetingOptionsMenuViewModel.kt | 12 +- .../options/MeetingOptionsModalSheetLayout.kt | 9 +- .../meetings/src/main/res/values/strings.xml | 6 +- .../ui/create/NewMeetingViewModelTest.kt | 297 +++++++++++++++--- .../MeetingOptionsMenuViewModelTest.kt | 32 +- kalium | 2 +- 15 files changed, 487 insertions(+), 103 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MeetingModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MeetingModule.kt index 6c04c1fd6d4..9b8f5e21517 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MeetingModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MeetingModule.kt @@ -22,6 +22,7 @@ import com.wire.android.di.KaliumCoreLogic import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.meeting.CreateNewMeetingUseCase +import com.wire.kalium.logic.feature.meeting.GetNextMeetingOccurrenceUseCase import com.wire.kalium.logic.feature.meeting.GetPaginatedMeetingOccurrencesUseCase import com.wire.kalium.logic.feature.meeting.MeetingScope import com.wire.kalium.logic.feature.meeting.ObserveMeetingOccurrenceUseCase @@ -52,4 +53,12 @@ class MeetingModule { @Provides fun provideCreateNewMeetingUseCase(meetingScope: MeetingScope): CreateNewMeetingUseCase = meetingScope.createNewMeeting + + @Provides + fun provideUpdateMeetingUseCase(meetingScope: MeetingScope): com.wire.kalium.logic.feature.meeting.UpdateMeetingUseCase = + meetingScope.updateMeeting + + @Provides + fun provideGetNextMeetingOccurrenceUseCase(meetingScope: MeetingScope): GetNextMeetingOccurrenceUseCase = + meetingScope.getNextMeetingOccurrence } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt index 31b3c849f60..7c9fcabc7c6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt @@ -64,6 +64,9 @@ fun MeetingsScreen( ) ) }, + editMeeting = { meetingId -> + homeStateHolder.navigator.navigate(NavigationCommand(NewMeetingScreenDestination(NewMeetingType.Edit(meetingId)))) + }, ) viewModel.callManager.actions.HandleActions() diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/mapper/MeetingMapper.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/mapper/MeetingMapper.kt index 5b402637861..3c06202af37 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/mapper/MeetingMapper.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/mapper/MeetingMapper.kt @@ -23,6 +23,7 @@ import com.wire.android.model.ImageAsset import com.wire.android.model.UserAvatarData import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallStatus +import com.wire.kalium.logic.data.meeting.Meeting import com.wire.kalium.logic.data.meeting.MeetingOccurrence import kotlinx.collections.immutable.toImmutableList import kotlinx.datetime.Instant @@ -35,7 +36,7 @@ fun MeetingOccurrence.toMeetingItem(time: Instant, ongoingCallStatus: MeetingIte meetingId = meeting.meetingId, conversationId = meeting.conversationId, belongingType = toBelongingType(), - repeatingInterval = meeting.recurrence?.let { MeetingItem.RepeatingInterval(it.frequency, it.interval.toInt()) }, + repeatingInterval = meeting.recurrence?.toRepeatingInterval(), title = meeting.title, status = when { occurrenceStartTime > time -> Status.Scheduled( @@ -57,6 +58,8 @@ fun MeetingOccurrence.toMeetingItem(time: Instant, ongoingCallStatus: MeetingIte selfRole = selfRole.toItemSelfRole() ) +fun Meeting.Recurrence.toRepeatingInterval(): MeetingItem.RepeatingInterval = MeetingItem.RepeatingInterval(frequency, interval.toInt()) + fun MeetingOccurrence.SelfRole.toItemSelfRole(): MeetingItem.SelfRole = when (this) { MeetingOccurrence.SelfRole.Creator -> MeetingItem.SelfRole.Creator MeetingOccurrence.SelfRole.Member -> MeetingItem.SelfRole.Member diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/AllMeetingsScreen.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/AllMeetingsScreen.kt index 23e55c14376..21f9592c291 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/AllMeetingsScreen.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/AllMeetingsScreen.kt @@ -48,6 +48,7 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.UIText import com.wire.android.util.ui.rememberLazyListStateProvider import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.MeetingId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch @@ -62,6 +63,7 @@ fun AllMeetingsScreen( startCall: (conversationId: ConversationId) -> Unit = {}, joinCall: (conversationId: ConversationId) -> Unit = {}, returnToCall: (conversationId: ConversationId) -> Unit = {}, + editMeeting: (meetingId: MeetingId) -> Unit = {}, ) { Column( modifier = Modifier.fillMaxSize() @@ -109,7 +111,7 @@ fun AllMeetingsScreen( ) } - MeetingOptionsModalSheetLayout(sheetState = meetingOptionsSheetState) + MeetingOptionsModalSheetLayout(sheetState = meetingOptionsSheetState, editMeeting = editMeeting) } } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt index 5faf0c38cda..e4004b059ee 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt @@ -22,14 +22,19 @@ import com.wire.android.feature.meetings.ui.create.NewMeetingViewModelImpl import com.wire.android.feature.meetings.ui.list.MeetingListViewModelImpl import com.wire.android.feature.meetings.ui.options.MeetingOptionsMenuViewModelImpl import com.wire.android.feature.meetings.ui.usecase.GetPaginatedFlowOfMeetingsUseCase +import com.wire.android.mapper.ContactMapper import com.wire.android.util.CurrentTimeProvider import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.feature.call.usecase.ObserveActiveCallsUseCase +import com.wire.kalium.logic.feature.conversation.ObserveConversationMembersUseCase import com.wire.kalium.logic.feature.meeting.CreateNewMeetingUseCase import com.wire.kalium.logic.feature.meeting.DeleteMeetingUseCase +import com.wire.kalium.logic.feature.meeting.GetNextMeetingOccurrenceUseCase import com.wire.kalium.logic.feature.meeting.ObserveMeetingOccurrenceUseCase +import com.wire.kalium.logic.feature.meeting.UpdateMeetingUseCase import dev.zacsweers.metro.Inject +@Suppress("LongParameterList") class MeetingsViewModelFactory @Inject constructor( private val currentTimeProvider: CurrentTimeProvider, private val dispatcher: DispatcherProvider, @@ -38,6 +43,10 @@ class MeetingsViewModelFactory @Inject constructor( private val observeActiveCalls: ObserveActiveCallsUseCase, private val deleteMeetingUseCase: DeleteMeetingUseCase, private val createNewMeeting: CreateNewMeetingUseCase, + private val updateMeeting: UpdateMeetingUseCase, + private val getNextMeetingOccurrence: GetNextMeetingOccurrenceUseCase, + private val observeConversationMembers: ObserveConversationMembersUseCase, + private val contactMapper: ContactMapper, ) { internal fun meetingListViewModel(type: MeetingsTabItem) = MeetingListViewModelImpl( type = type, @@ -55,6 +64,10 @@ class MeetingsViewModelFactory @Inject constructor( internal fun newMeetingViewModel(savedStateHandle: SavedStateHandle) = NewMeetingViewModelImpl( savedStateHandle = savedStateHandle, currentTimeProvider = currentTimeProvider, - createNewMeeting = createNewMeeting + createNewMeeting = createNewMeeting, + updateMeeting = updateMeeting, + getNextMeetingOccurrence = getNextMeetingOccurrence, + observeConversationMembers = observeConversationMembers, + contactMapper = contactMapper, ) } 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 074a60193c5..8c470c994f3 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 @@ -36,6 +36,7 @@ fun NewMeetingErrorDialog( 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 } WireDialog( title = stringResource(titleResId), diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingNavArgs.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingNavArgs.kt index 363a410d0f5..dd5d6666f1a 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingNavArgs.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingNavArgs.kt @@ -17,31 +17,46 @@ */ package com.wire.android.feature.meetings.ui.create -import android.os.Parcelable import androidx.annotation.DrawableRes import androidx.annotation.StringRes import com.wire.android.feature.meetings.R -import kotlinx.parcelize.Parcelize +import com.wire.kalium.logic.data.id.MeetingId +import kotlinx.serialization.Serializable import com.wire.android.ui.common.R as CommonUiR data class NewMeetingNavArgs( val type: NewMeetingType ) -@Parcelize -enum class NewMeetingType( - @StringRes val title: Int, - @StringRes val action: Int, - @DrawableRes val icon: Int, -) : Parcelable { - MeetNow( - title = R.string.new_meeting_now, - action = R.string.new_meeting_action_start, - icon = CommonUiR.drawable.ic_video_call, - ), - Schedule( - title = R.string.new_meeting_schedule, - action = R.string.new_meeting_action_schedule, - icon = CommonUiR.drawable.ic_calendar, - ) +@Serializable +sealed interface NewMeetingType { + @get:StringRes + val title: Int + + @get:StringRes + val action: Int + + @get:DrawableRes + val icon: Int + + @Serializable + data object MeetNow : NewMeetingType { + override val title = R.string.new_meeting_now + override val action = R.string.new_meeting_action_start + override val icon = CommonUiR.drawable.ic_video_call + } + + @Serializable + data object Schedule : NewMeetingType { + override val title = R.string.new_meeting_schedule + override val action = R.string.new_meeting_action_schedule + override val icon = CommonUiR.drawable.ic_calendar + } + + @Serializable + data class Edit(val id: MeetingId) : NewMeetingType { + override val title = R.string.new_meeting_edit + override val action = R.string.new_meeting_action_edit + override val icon = CommonUiR.drawable.ic_calendar + } } 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 37902776d66..86cae75079f 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 @@ -21,6 +21,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -87,6 +88,7 @@ import com.wire.android.ui.common.datetime.WireDatePickerDialog import com.wire.android.ui.common.datetime.WireTimePickerDialog import com.wire.android.ui.common.datetime.asTimePickerResult import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.rememberTopBarElevationState import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.spacers.VerticalSpace @@ -107,6 +109,7 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CurrentTimeProvider import com.wire.android.util.DateAndTimeParsers import com.wire.android.util.EMPTY +import com.wire.kalium.logic.data.id.MeetingId import com.wire.kalium.logic.data.user.ConnectionState import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableList @@ -138,7 +141,7 @@ fun NewMeetingScreen( onParticipantsClicked = { navigator.navigate(NavigationCommand(NewMeetingParticipantsScreenDestination)) }, - onCreateClicked = newMeetingViewModel::createMeeting, + onCreateClicked = newMeetingViewModel::submit, onStartTimeChanged = newMeetingViewModel::updateStartTime, onEndTimeChanged = newMeetingViewModel::updateEndTime, onRepeatingIntervalChanged = newMeetingViewModel::updateRepeatingInterval, @@ -202,7 +205,7 @@ fun NewMeetingContent( titleError = state.titleError, readOnly = state.isSubmitting, ) - if (type == NewMeetingType.Schedule) { + if (type != NewMeetingType.MeetNow) { VerticalSpace.x24() TimeInput( time = state.startTime, @@ -233,15 +236,29 @@ fun NewMeetingContent( onClick = onParticipantsClicked, ) } - if (state.isSubmitting) { + if (state.isSubmitting || state.isDataLoading) { Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { - detectTapGestures { /* consume the touch while submitting data */ } + detectTapGestures { /* consume the touch while submitting or loading data */ } }, ) } + AnimatedVisibility(state.isDataLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(colorsScheme().background), + contentAlignment = Alignment.Center + ) { + WireCircularProgressIndicator( + size = dimensions().spacing32x, + strokeWidth = dimensions().spacing4x, + progressColor = MaterialTheme.wireColorScheme.onBackground, + ) + } + } }, bottomBar = { Surface( @@ -572,6 +589,7 @@ fun PreviewNewMeetingScreen_MeetNow() = WireTheme { state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( confirmedContacts = buildContacts(names.size), continueButtonEnabled = true, + isDataLoading = false, ), ) } @@ -586,6 +604,22 @@ fun PreviewNewMeetingScreen_Schedule() = WireTheme { startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()), endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()).plus(1.hours), repeatingInterval = MeetingItem.RepeatingInterval.Supported.first(), + isDataLoading = false, + ), + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewNewMeetingScreen_Edit() = WireTheme { + NewMeetingContent( + titleState = rememberTextFieldState(), + type = NewMeetingType.Edit(MeetingId("meetingId", "domain")), + state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( + startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()), + endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()).plus(1.hours), + repeatingInterval = MeetingItem.RepeatingInterval.Supported.first(), + isDataLoading = false, ), ) } 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 a7625c0b399..0a08b1e640d 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 @@ -18,6 +18,7 @@ package com.wire.android.feature.meetings.ui.create import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,9 +26,11 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.meetings.navArgs +import com.wire.android.feature.meetings.mapper.toRepeatingInterval import com.wire.android.feature.meetings.model.MeetingItem import com.wire.android.feature.meetings.ui.create.NewMeetingState.Companion.initialState import com.wire.android.feature.meetings.ui.create.NewMeetingViewModel.Companion.MEETING_NAME_MAX_COUNT +import com.wire.android.mapper.ContactMapper import com.wire.android.model.Contact import com.wire.android.ui.common.ActionsManager import com.wire.android.ui.common.ActionsViewModel @@ -35,13 +38,18 @@ import com.wire.android.ui.common.textfield.textAsFlow import com.wire.android.util.CurrentTimeProvider import com.wire.kalium.logic.data.meeting.CreateMeeting import com.wire.kalium.logic.data.meeting.Meeting +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.meeting.CreateNewMeetingUseCase +import com.wire.kalium.logic.feature.meeting.GetNextMeetingOccurrenceUseCase +import com.wire.kalium.logic.feature.meeting.UpdateMeetingUseCase import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.Instant @@ -64,7 +72,7 @@ interface NewMeetingViewModel : ActionsManager { fun updateStartTime(startTime: Instant) {} fun updateEndTime(endTime: Instant) {} fun updateRepeatingInterval(interval: MeetingItem.RepeatingInterval?) {} - fun createMeeting() {} + fun submit() {} fun dismissCreationError() {} companion object { @@ -84,6 +92,10 @@ class NewMeetingViewModelImpl( savedStateHandle: SavedStateHandle, override val currentTimeProvider: CurrentTimeProvider, private val createNewMeeting: CreateNewMeetingUseCase, + private val updateMeeting: UpdateMeetingUseCase, + private val getNextMeetingOccurrence: GetNextMeetingOccurrenceUseCase, + private val observeConversationMembers: ObserveConversationMembersUseCase, + private val contactMapper: ContactMapper, ) : ActionsViewModel(), NewMeetingViewModel { val navArgs: NewMeetingNavArgs = savedStateHandle.navArgs() override val type: NewMeetingType = navArgs.type @@ -92,6 +104,33 @@ class NewMeetingViewModelImpl( private set init { + observeTitleChanges() + getNextMeetingOccurrenceData() + } + + private fun getNextMeetingOccurrenceData() { + viewModelScope.launch { + val meetingType = navArgs.type + if (meetingType is NewMeetingType.Edit) { + getNextMeetingOccurrence(meetingType.id, currentTimeProvider())?.let { nextMeetingOccurrence -> + val otherContacts = observeConversationMembers(nextMeetingOccurrence.meeting.conversationId).firstOrNull()?.let { + it.map { it.user }.filterIsInstance().map { contactMapper.fromOtherUser(it) }.toPersistentSet() + } ?: persistentSetOf() + titleTextState.setTextAndPlaceCursorAtEnd(nextMeetingOccurrence.meeting.title) + state = state.copy( + startTime = nextMeetingOccurrence.occurrenceStartTime, + endTime = nextMeetingOccurrence.occurrenceEndTime, + repeatingInterval = nextMeetingOccurrence.meeting.recurrence?.toRepeatingInterval(), + selectedContacts = otherContacts, + confirmedContacts = otherContacts, + ) + } + } + state = state.copy(isDataLoading = false) + } + } + + private fun observeTitleChanges() { viewModelScope.launch { titleTextState.textAsFlow() .drop(1) // drop initial value to avoid showing error on start @@ -169,7 +208,7 @@ class NewMeetingViewModelImpl( endTimeError == null ) - override fun createMeeting() { + override fun submit() { viewModelScope.launch { val titleValid = validateTitle() val startAndEndTimeValid = when (type) { @@ -178,23 +217,39 @@ class NewMeetingViewModelImpl( true // for "meet now", we set the start time to the current time and end time to +1 hour, so it's already valid } - NewMeetingType.Schedule -> validateStartAndEndTime() + NewMeetingType.Schedule, is NewMeetingType.Edit -> validateStartAndEndTime() } - if (titleValid && startAndEndTimeValid && !state.isSubmitting) { + if (titleValid && startAndEndTimeValid) { state = state.copy(isSubmitting = true, continueButtonEnabled = false) - val creationResult = createNewMeeting( - CreateMeeting( - title = titleTextState.text.trim().toString(), - startTime = state.startTime, - endTime = state.endTime, - recurrence = state.repeatingInterval?.let { Meeting.Recurrence(it.frequency, it.interval.toLong(), null) }, - otherParticipants = state.confirmedContacts.map { UserId(it.id, it.domain) } + val creationResult = when (type) { + NewMeetingType.MeetNow, NewMeetingType.Schedule -> createNewMeeting( + createMeeting = CreateMeeting( + title = titleTextState.text.trim().toString(), + startTime = state.startTime, + endTime = state.endTime, + recurrence = state.repeatingInterval?.let { Meeting.Recurrence(it.frequency, it.interval.toLong(), null) }, + otherParticipants = state.confirmedContacts.map { UserId(it.id, it.domain) } + ) + ) + + is NewMeetingType.Edit -> updateMeeting( + meetingId = type.id, + meeting = CreateMeeting( + title = titleTextState.text.trim().toString(), + startTime = state.startTime, + endTime = state.endTime, + recurrence = state.repeatingInterval?.let { Meeting.Recurrence(it.frequency, it.interval.toLong(), null) }, + otherParticipants = state.confirmedContacts.map { UserId(it.id, it.domain) } + ) ) - ) + } state = state.copy(isSubmitting = false, continueButtonEnabled = true) when (creationResult) { - is CreateNewMeetingUseCase.Result.Success -> sendAction(NewMeetingViewActions.Success) - is CreateNewMeetingUseCase.Result.Failure -> state = state.copy(creationError = NewMeetingState.CreationError.Other) + is CreateNewMeetingUseCase.Result.Success, + is UpdateMeetingUseCase.Result.Success -> sendAction(NewMeetingViewActions.Success) + + is CreateNewMeetingUseCase.Result.Failure, + is UpdateMeetingUseCase.Result.Failure -> state = state.copy(creationError = NewMeetingState.CreationError.Other) } } } @@ -209,14 +264,14 @@ internal fun getNextFullHour(now: Instant, timeZone: TimeZone = TimeZone.current val futureHour = now.plus(1, DateTimeUnit.HOUR, timeZone) val localFuture = futureHour.toLocalDateTime(timeZone) return LocalDateTime( - year = localFuture.year, - monthNumber = localFuture.monthNumber, - dayOfMonth = localFuture.dayOfMonth, - hour = localFuture.hour, - minute = 0, - second = 0, - nanosecond = 0 - ).toInstant(timeZone) + year = localFuture.year, + monthNumber = localFuture.monthNumber, + dayOfMonth = localFuture.dayOfMonth, + hour = localFuture.hour, + minute = 0, + second = 0, + nanosecond = 0 + ).toInstant(timeZone) } @Stable @@ -232,6 +287,7 @@ data class NewMeetingState( val repeatingInterval: MeetingItem.RepeatingInterval? = null, val creationError: CreationError? = null, val isSubmitting: Boolean = false, + val isDataLoading: Boolean = false, ) { @Stable sealed interface TitleError { @@ -252,7 +308,7 @@ data class NewMeetingState( companion object { fun initialState(currentTimeProvider: CurrentTimeProvider): NewMeetingState { val startTime = getNextFullHour(currentTimeProvider()) - return NewMeetingState(startTime = startTime, endTime = startTime.plus(1.hours)) + return NewMeetingState(startTime = startTime, endTime = startTime.plus(1.hours), isDataLoading = true) } } } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModel.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModel.kt index dee01ee1149..89bf00c1b31 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModel.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModel.kt @@ -75,13 +75,15 @@ class MeetingOptionsMenuViewModelImpl( flowOf(occurrenceId) .flatMapConcat { occurrenceId -> observeMeetingOccurrenceUseCase.invoke(occurrenceId).map { - when { - it != null -> MeetingOptionsMenuState.Meeting( + it?.let { + val isEnded = it.occurrenceEndTime > Clock.System.now() + MeetingOptionsMenuState.Meeting( meetingId = it.meeting.meetingId, title = it.meeting.title, selfRole = it.selfRole.toItemSelfRole(), + editMeetingEnabled = it.selfRole == MeetingOccurrence.SelfRole.Creator && isEnded, deleteOption = when { - it.occurrenceStartTime < Clock.System.now() -> MeetingOptionsMenuState.Meeting.DeleteOption.None + isEnded -> MeetingOptionsMenuState.Meeting.DeleteOption.None else -> when (it.selfRole) { MeetingOccurrence.SelfRole.Creator -> MeetingOptionsMenuState.Meeting.DeleteOption.ForEveryone // for now, we don't show delete option for members as "delete for me" is not yet implemented @@ -89,9 +91,7 @@ class MeetingOptionsMenuViewModelImpl( } }, ) - - else -> MeetingOptionsMenuState.NotAvailable - } + } ?: MeetingOptionsMenuState.NotAvailable } } .distinctUntilChanged() diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsModalSheetLayout.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsModalSheetLayout.kt index 85005febd57..edad428b8a6 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsModalSheetLayout.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsModalSheetLayout.kt @@ -47,12 +47,14 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.theme.WireTheme import com.wire.android.util.CurrentTimeProvider +import com.wire.kalium.logic.data.id.MeetingId import com.wire.android.ui.common.R as UICommonR @Composable @SuppressLint("ComposeModifierMissing") fun MeetingOptionsModalSheetLayout( sheetState: WireModalSheetState, + editMeeting: (MeetingId) -> Unit = {}, viewModel: MeetingOptionsMenuViewModel = when { LocalInspectionMode.current -> MeetingOptionsMenuViewModelPreview(CurrentTimeProvider.Preview) else -> meetingOptionsMenuListViewModel() @@ -73,7 +75,12 @@ fun MeetingOptionsModalSheetLayout( DeleteMeetingDialogState(forEveryone = true, meetingId = state.meetingId, meetingTitle = state.title) ) } - } + }, + onEditMeeting = { + sheetState.hide { + editMeeting(state.meetingId) + } + }, ).also { sheetState.updateContent() } diff --git a/features/meetings/src/main/res/values/strings.xml b/features/meetings/src/main/res/values/strings.xml index 3a5cfab61f9..66ebf04d66e 100644 --- a/features/meetings/src/main/res/values/strings.xml +++ b/features/meetings/src/main/res/values/strings.xml @@ -49,9 +49,11 @@ New Meeting Select participants Meet Now - Schedule a Meetingv + Schedule a Meeting + Edit Meeting Start Meeting Schedule + Confirm Changes Title Enter meeting title Participants @@ -83,6 +85,8 @@ Something went wrong while starting the meeting. Please try again. Could not schedule meeting Something went wrong while scheduling the meeting. Please try again. + Could not update meeting + Something went wrong while updating the meeting. Please try again. +%1$d more 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 8f7f1cc359a..0309d61b7f8 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 @@ -24,15 +24,27 @@ import com.ramcosta.composedestinations.generated.meetings.navArgs import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension +import com.wire.android.feature.meetings.mapper.toRepeatingInterval import com.wire.android.feature.meetings.model.MeetingItem +import com.wire.android.framework.TestUser +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.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.conversation.MemberDetails +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.MeetingId import com.wire.kalium.logic.data.meeting.CreateMeeting import com.wire.kalium.logic.data.meeting.Meeting +import com.wire.kalium.logic.data.meeting.MeetingOccurrence 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.meeting.CreateNewMeetingUseCase +import com.wire.kalium.logic.feature.meeting.GetNextMeetingOccurrenceUseCase +import com.wire.kalium.logic.feature.meeting.UpdateMeetingUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -40,6 +52,7 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestScope @@ -49,10 +62,10 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.datetime.Instant +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -218,14 +231,18 @@ class NewMeetingViewModelTest { } @Test - fun givenMeetNowTypeWithValidData_whenCreateMeetingIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { + fun givenMeetNowTypeWithValidData_whenSubmitIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val (arrangement, viewModel) = arrangeViewModel(Arrangement(dispatcher).withCurrentTimeProvider { currentTime }) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withCurrentTimeProvider { currentTime } + .withCreateMeetingResult(CreateNewMeetingUseCase.Result.Success) + ) enterTitle(viewModel, " Quick sync ") viewModel.actions.test { - viewModel.createMeeting() + viewModel.submit() advanceUntilIdle() coVerify(exactly = 1) { @@ -249,44 +266,26 @@ class NewMeetingViewModelTest { } @Test - fun givenScheduleTypeWithValidData_whenCreateMeetingIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { + fun givenScheduleTypeWithValidData_whenSubmitIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val startTime = currentTime + 2.hours - val endTime = currentTime + 3.hours - val contact = contact("contact-1") - val repeatingInterval = MeetingItem.RepeatingInterval.Supported.first() + val createMeeting = CREATE_MEETING.copy(startTime = currentTime + 2.hours, endTime = currentTime + 3.hours) val (arrangement, viewModel) = arrangeViewModel( Arrangement(dispatcher) .withNewMeetingType(NewMeetingType.Schedule) .withCurrentTimeProvider { currentTime } + .withCreateMeetingResult(CreateNewMeetingUseCase.Result.Success) ) - - enterTitle(viewModel, " Weekly sync ") - viewModel.updateStartTime(startTime) - viewModel.updateEndTime(endTime) - viewModel.updateSelectedContact(selected = true, contact = contact) + enterTitle(viewModel, createMeeting.title) + viewModel.updateStartTime(createMeeting.startTime) + viewModel.updateEndTime(createMeeting.endTime) + viewModel.updateSelectedContact(selected = true, contact = CONTACT) viewModel.confirmSelectedContacts() - viewModel.updateRepeatingInterval(repeatingInterval) + viewModel.updateRepeatingInterval(createMeeting.recurrence?.toRepeatingInterval()) viewModel.actions.test { - viewModel.createMeeting() + viewModel.submit() advanceUntilIdle() - - coVerify(exactly = 1) { - arrangement.createNewMeeting( - CreateMeeting( - title = "Weekly sync", - startTime = startTime, - endTime = endTime, - recurrence = Meeting.Recurrence( - frequency = repeatingInterval.frequency, - interval = repeatingInterval.interval.toLong(), - until = null - ), - otherParticipants = listOf(UserId(contact.id, contact.domain)) - ) - ) - } + coVerify(exactly = 1) { arrangement.createNewMeeting(createMeeting) } assertFalse(viewModel.state.isSubmitting) assertNull(viewModel.state.creationError) assertEquals(NewMeetingViewActions.Success, awaitItem()) @@ -295,16 +294,17 @@ class NewMeetingViewModelTest { } @Test - fun givenCreationFails_whenCreateMeetingIsCalled_thenCreationErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { + fun givenCreationFails_whenSubmitIsCalled_thenErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { val (arrangement, viewModel) = arrangeViewModel( Arrangement(dispatcher) + .withNewMeetingType(NewMeetingType.MeetNow) .withCreateMeetingResult(CreateNewMeetingUseCase.Result.Failure) ) enterTitle(viewModel, "Weekly sync") viewModel.actions.test { - viewModel.createMeeting() + viewModel.submit() advanceUntilIdle() coVerify(exactly = 1) { arrangement.createNewMeeting(any()) } @@ -316,14 +316,90 @@ class NewMeetingViewModelTest { } @Test - fun givenInvalidTitle_whenCreateMeetingIsCalled_thenTitleErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { + fun givenEditTypeWithValidData_whenSubmitIsCalled_thenMeetingIsEditedAndSuccessActionIsSent() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val contact = contact("contact-1") + val createMeeting = CREATE_MEETING.copy(startTime = currentTime + 2.hours, endTime = currentTime + 3.hours) + 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.Success) + .withCurrentTimeProvider { currentTime } + ) + + enterTitle(viewModel, createMeeting.title) + viewModel.updateStartTime(createMeeting.startTime) + viewModel.updateEndTime(createMeeting.endTime) + viewModel.updateSelectedContact(selected = true, contact = contact) + viewModel.confirmSelectedContacts() + viewModel.updateRepeatingInterval(createMeeting.recurrence?.toRepeatingInterval()) + + viewModel.actions.test { + viewModel.submit() + advanceUntilIdle() + coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, createMeeting) } + assertFalse(viewModel.state.isSubmitting) + assertNull(viewModel.state.creationError) + assertEquals(NewMeetingViewActions.Success, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun givenEditionFails_whenSubmitIsCalled_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) + ) + + enterTitle(viewModel, "Weekly sync") + + viewModel.actions.test { + viewModel.submit() + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, any()) } + expectNoEvents() + assertFalse(viewModel.state.isSubmitting) + assertEquals(true, viewModel.state.continueButtonEnabled) + assertEquals(NewMeetingState.CreationError.Other, viewModel.state.creationError) + } + } + + @Test + fun givenInvalidTitle_whenSubmitIsCalled_thenTitleErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { val (arrangement, viewModel) = arrangeViewModel() viewModel.actions.test { - viewModel.createMeeting() + viewModel.submit() advanceUntilIdle() coVerify(exactly = 0) { arrangement.createNewMeeting(any()) } + coVerify(exactly = 0) { arrangement.updateMeeting(any(), any()) } expectNoEvents() assertEquals(NewMeetingState.TitleError.TitleEmptyError, viewModel.state.titleError) assertFalse(viewModel.state.continueButtonEnabled) @@ -343,16 +419,96 @@ class NewMeetingViewModelTest { viewModel.updateStartTime(currentTime - 1.hours) viewModel.actions.test { - viewModel.createMeeting() + viewModel.submit() advanceUntilIdle() coVerify(exactly = 0) { arrangement.createNewMeeting(any()) } + coVerify(exactly = 0) { arrangement.updateMeeting(any(), any()) } expectNoEvents() assertEquals(NewMeetingState.TimeError.StartTimeInPastError, viewModel.state.startTimeError) assertFalse(viewModel.state.continueButtonEnabled) } } + @Test + fun givenEditTypeAndNextOccurrenceExists_whenViewModelIsCreated_thenStateIsInitializedFromNextOccurrence() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + 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 editType = NewMeetingType.Edit(nextOccurrence.meeting.meetingId) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withCurrentTimeProvider { currentTime } + .withNextMeetingOccurrence(nextOccurrence) + ) + + coVerify(exactly = 1) { arrangement.getNextMeetingOccurrence(editType.id, currentTime) } + assertEquals(editType, viewModel.type) + assertEquals(nextOccurrence.meeting.title, viewModel.titleTextState.text.toString()) + assertEquals(nextOccurrence.occurrenceStartTime, viewModel.state.startTime) + assertEquals(nextOccurrence.occurrenceEndTime, viewModel.state.endTime) + assertEquals(nextOccurrence.meeting.recurrence?.frequency, viewModel.state.repeatingInterval?.frequency) + assertEquals(nextOccurrence.meeting.recurrence?.interval?.toInt(), viewModel.state.repeatingInterval?.interval) + assertFalse(viewModel.state.isDataLoading) + } + + @Suppress("UnusedFlow") + @Test + fun givenEditTypeAndNextOccurrenceExists_whenViewModelIsCreated_thenParticipantsAreInitializedFromOccurrenceConversation() = + runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val editType = NewMeetingType.Edit(MEETING_OCCURRENCE.meeting.meetingId) + val firstUser = TestUser.OTHER_USER.copy(id = UserId("contact-1", "domain")) + val secondUser = TestUser.OTHER_USER.copy(id = UserId("contact-2", "domain")) + val firstContact = contact("contact-1") + val secondContact = contact("contact-2") + val conversationMembers = listOf( + MemberDetails(firstUser, Conversation.Member.Role.Member), + MemberDetails(secondUser, Conversation.Member.Role.Admin), + ) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withCurrentTimeProvider { currentTime } + .withNextMeetingOccurrence(MEETING_OCCURRENCE) + .withConversationMembers(MEETING_OCCURRENCE.meeting.conversationId, conversationMembers) + .withMappedContact(firstUser, firstContact) + .withMappedContact(secondUser, secondContact) + ) + + coVerify(exactly = 1) { arrangement.observeConversationMembers(MEETING_OCCURRENCE.meeting.conversationId) } + assertEquals(setOf(firstContact, secondContact), viewModel.state.selectedContacts.toSet()) + assertEquals(setOf(firstContact, secondContact), viewModel.state.confirmedContacts.toSet()) + assertFalse(viewModel.state.isDataLoading) + } + + @Test + fun givenEditTypeAndNextOccurrenceDoesNotExist_whenViewModelIsCreated_thenStateStopsLoadingWithInitialTimes() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val editType = NewMeetingType.Edit(MeetingId("meeting-id", "domain")) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withCurrentTimeProvider { currentTime } + .withNextMeetingOccurrence(null) + ) + + coVerify(exactly = 1) { arrangement.getNextMeetingOccurrence(editType.id, currentTime) } + assertEquals(editType, viewModel.type) + assertEquals("", viewModel.titleTextState.text.toString()) + assertEquals(currentTime + 1.hours, viewModel.state.startTime) + assertEquals(currentTime + 2.hours, viewModel.state.endTime) + assertFalse(viewModel.state.isDataLoading) + } + private fun TestScope.arrangeViewModel( arrangement: Arrangement = Arrangement(dispatcher) ): Pair = @@ -383,6 +539,18 @@ class NewMeetingViewModelTest { @MockK lateinit var createNewMeeting: CreateNewMeetingUseCase + @MockK + lateinit var updateMeeting: UpdateMeetingUseCase + + @MockK + lateinit var getNextMeetingOccurrence: GetNextMeetingOccurrenceUseCase + + @MockK + lateinit var observeConversationMembers: ObserveConversationMembersUseCase + + @MockK + lateinit var contactMapper: ContactMapper + private var newMeetingType: NewMeetingType = NewMeetingType.MeetNow init { @@ -390,23 +558,76 @@ class NewMeetingViewModelTest { every { savedStateHandle.navArgs() } answers { NewMeetingNavArgs(type = newMeetingType) } - coEvery { createNewMeeting(any()) } returns CreateNewMeetingUseCase.Result.Success + coEvery { getNextMeetingOccurrence(any(), any()) } returns null + coEvery { observeConversationMembers(any()) } returns flowOf(emptyList()) } fun withNewMeetingType(type: NewMeetingType) = apply { newMeetingType = type } + fun withCurrentTimeProvider(currentTime: () -> Instant) = apply { currentTimeProvider = CurrentTimeProvider(currentTime) } + fun withCreateMeetingResult(result: CreateNewMeetingUseCase.Result) = apply { coEvery { createNewMeeting(any()) } returns result } + fun withUpdateMeetingResult(meetingId: MeetingId, result: UpdateMeetingUseCase.Result) = apply { + coEvery { updateMeeting(meetingId, any()) } returns result + } + + fun withNextMeetingOccurrence(nextMeetingOccurrence: MeetingOccurrence?) = apply { + coEvery { getNextMeetingOccurrence(any(), any()) } returns nextMeetingOccurrence + } + + fun withConversationMembers(conversationId: ConversationId, members: List) = apply { + coEvery { observeConversationMembers(conversationId) } returns flowOf(members) + } + + fun withMappedContact(otherUser: OtherUser, contact: Contact) = apply { + every { contactMapper.fromOtherUser(otherUser) } returns contact + } + fun arrange() = this to NewMeetingViewModelImpl( savedStateHandle = savedStateHandle, currentTimeProvider = currentTimeProvider, createNewMeeting = createNewMeeting, + updateMeeting = updateMeeting, + getNextMeetingOccurrence = getNextMeetingOccurrence, + observeConversationMembers = observeConversationMembers, + contactMapper = contactMapper, ) } + + private val MEETING_OCCURRENCE = MeetingOccurrence( + meeting = Meeting( + meetingId = MeetingId("meeting-id", "domain"), + conversationId = ConversationId("conversation-id", "domain"), + creatorId = UserId("creator-id", "domain"), + title = "Daily", + startTime = Instant.parse("2026-01-01T09:00:00Z"), + endTime = Instant.parse("2026-01-01T10:00:00Z"), + recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), + ), + selfRole = MeetingOccurrence.SelfRole.Creator, + conversationName = "Daily", + conversationType = MeetingOccurrence.ConversationType.Group, + occurrenceId = "occurrence-id", + occurrenceStartTime = Instant.parse("2026-01-02T09:00:00Z"), + occurrenceEndTime = Instant.parse("2026-01-02T10:00:00Z"), + ) + private val CONTACT = contact("contact-1") + private val CREATE_MEETING = CreateMeeting( + title = "Weekly sync", + startTime = Instant.parse("2026-01-01T09:00:00Z"), + endTime = Instant.parse("2026-01-01T10:00:00Z"), + recurrence = Meeting.Recurrence( + frequency = MeetingItem.RepeatingInterval.Supported.first().frequency, + interval = MeetingItem.RepeatingInterval.Supported.first().interval.toLong(), + until = null + ), + otherParticipants = listOf(UserId(CONTACT.id, CONTACT.domain)) + ) } diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt index 0c35b5fd2a8..97e5c0300f2 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt @@ -68,8 +68,12 @@ class MeetingOptionsMenuViewModelTest { } @Test - fun givenFutureMeeting_andSelfUserIsCreator_whenObserving_thenDeleteForEveryoneIsAvailable() = runTest(dispatcher) { - val meeting = meeting(selfRole = MeetingOccurrence.SelfRole.Creator, occurrenceStartTime = Clock.System.now() + 1.hours) + fun givenFutureMeeting_andSelfUserIsCreator_whenObserving_thenEditAndDeleteForEveryoneIsAvailable() = runTest(dispatcher) { + val meeting = meeting( + selfRole = MeetingOccurrence.SelfRole.Creator, + occurrenceStartTime = Clock.System.now() + 1.hours, + occurrenceEndTime = Clock.System.now() + 2.hours, + ) val (_, viewModel) = Arrangement() .withObservedMeeting(meeting) .arrange() @@ -80,14 +84,19 @@ class MeetingOptionsMenuViewModelTest { assertInstanceOf(awaitItem()).also { assertEquals(MeetingOptionsMenuState.Meeting.DeleteOption.ForEveryone, it.deleteOption) + assertEquals(true, it.editMeetingEnabled) } cancelAndConsumeRemainingEvents() } } @Test - fun givenFutureMeeting_andSelfUserIsMember_whenObserving_thenDeleteIsNotAvailable() = runTest(dispatcher) { - val meeting = meeting(selfRole = MeetingOccurrence.SelfRole.Member, occurrenceStartTime = Clock.System.now() + 1.hours) + fun givenFutureMeeting_andSelfUserIsMember_whenObserving_thenEditAndDeleteIsNotAvailable() = runTest(dispatcher) { + val meeting = meeting( + selfRole = MeetingOccurrence.SelfRole.Member, + occurrenceStartTime = Clock.System.now() + 1.hours, + occurrenceEndTime = Clock.System.now() + 2.hours + ) val (_, viewModel) = Arrangement() .withObservedMeeting(meeting) .arrange() @@ -98,14 +107,19 @@ class MeetingOptionsMenuViewModelTest { assertInstanceOf(awaitItem()).also { assertEquals(MeetingOptionsMenuState.Meeting.DeleteOption.None, it.deleteOption) + assertEquals(false, it.editMeetingEnabled) } cancelAndConsumeRemainingEvents() } } @Test - fun givenPastMeeting_whenObserving_thenDeleteIsNotAvailable() = runTest(dispatcher) { - val meeting = meeting(selfRole = MeetingOccurrence.SelfRole.Creator, occurrenceStartTime = Clock.System.now() - 1.hours) + fun givenPastMeeting_andSelfUserIsCreator_whenObserving_thenEditAndDeleteIsNotAvailable() = runTest(dispatcher) { + val meeting = meeting( + selfRole = MeetingOccurrence.SelfRole.Creator, + occurrenceStartTime = Clock.System.now() - 2.hours, + occurrenceEndTime = Clock.System.now() - 1.hours, + ) val (_, viewModel) = Arrangement() .withObservedMeeting(meeting) .arrange() @@ -116,6 +130,7 @@ class MeetingOptionsMenuViewModelTest { assertInstanceOf(awaitItem()).also { assertEquals(MeetingOptionsMenuState.Meeting.DeleteOption.None, it.deleteOption) + assertEquals(false, it.editMeetingEnabled) } cancelAndConsumeRemainingEvents() } @@ -170,6 +185,7 @@ class MeetingOptionsMenuViewModelTest { private fun meeting( selfRole: MeetingOccurrence.SelfRole, occurrenceStartTime: Instant, + occurrenceEndTime: Instant = occurrenceStartTime + 30.minutes ) = MeetingOccurrence( meeting = Meeting( meetingId = MEETING_ID, @@ -177,14 +193,14 @@ class MeetingOptionsMenuViewModelTest { creatorId = UserId("creator-id", "domain"), title = MEETING_TITLE, startTime = occurrenceStartTime, - endTime = occurrenceStartTime + 30.minutes, + endTime = occurrenceEndTime, recurrence = null, ), occurrenceId = OCCURRENCE_ID, conversationName = "Meeting conversation", conversationType = MeetingOccurrence.ConversationType.Group, occurrenceStartTime = occurrenceStartTime, - occurrenceEndTime = occurrenceStartTime + 30.minutes, + occurrenceEndTime = occurrenceEndTime, selfRole = selfRole, ) diff --git a/kalium b/kalium index adf8ca3692c..1476a15a8cd 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit adf8ca3692cda94f4239c8ea64ec5d32094df4f0 +Subproject commit 1476a15a8cd09e6afde9ac84f64e74a2d7ff25fb From 35c731604a0cc1c720d312a6576cf23ceefc4b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Mon, 3 Aug 2026 16:36:18 +0200 Subject: [PATCH 2/5] use CurrentTimeProvider --- .../meetings/ui/MeetingsViewModelFactory.kt | 1 + .../ui/options/MeetingOptionsMenuViewModel.kt | 8 ++++---- .../options/MeetingOptionsMenuViewModelTest.kt | 18 +++++++++++------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt index e4004b059ee..51c2aa73d97 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt @@ -57,6 +57,7 @@ class MeetingsViewModelFactory @Inject constructor( ) internal fun meetingOptionsMenuViewModel() = MeetingOptionsMenuViewModelImpl( + currentTimeProvider = currentTimeProvider, observeMeetingOccurrenceUseCase = observeMeetingOccurrence, deleteMeetingUseCase = deleteMeetingUseCase, ) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModel.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModel.kt index 89bf00c1b31..a7c8fa20129 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModel.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModel.kt @@ -44,7 +44,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.datetime.Clock import java.util.concurrent.ConcurrentHashMap interface MeetingOptionsMenuViewModel : ActionsManager { @@ -65,6 +64,7 @@ class MeetingOptionsMenuViewModelPreview(currentTimeProvider: CurrentTimeProvide } class MeetingOptionsMenuViewModelImpl( + private val currentTimeProvider: CurrentTimeProvider, private val observeMeetingOccurrenceUseCase: ObserveMeetingOccurrenceUseCase, private val deleteMeetingUseCase: DeleteMeetingUseCase, ) : MeetingOptionsMenuViewModel, ActionsViewModel() { @@ -76,14 +76,14 @@ class MeetingOptionsMenuViewModelImpl( .flatMapConcat { occurrenceId -> observeMeetingOccurrenceUseCase.invoke(occurrenceId).map { it?.let { - val isEnded = it.occurrenceEndTime > Clock.System.now() + val hasEnded = it.occurrenceEndTime < currentTimeProvider() MeetingOptionsMenuState.Meeting( meetingId = it.meeting.meetingId, title = it.meeting.title, selfRole = it.selfRole.toItemSelfRole(), - editMeetingEnabled = it.selfRole == MeetingOccurrence.SelfRole.Creator && isEnded, + editMeetingEnabled = it.selfRole == MeetingOccurrence.SelfRole.Creator && !hasEnded, deleteOption = when { - isEnded -> MeetingOptionsMenuState.Meeting.DeleteOption.None + hasEnded -> MeetingOptionsMenuState.Meeting.DeleteOption.None else -> when (it.selfRole) { MeetingOccurrence.SelfRole.Creator -> MeetingOptionsMenuState.Meeting.DeleteOption.ForEveryone // for now, we don't show delete option for members as "delete for me" is not yet implemented diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt index 97e5c0300f2..91346d45d17 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt @@ -20,6 +20,7 @@ package com.wire.android.feature.meetings.ui.options import app.cash.turbine.test import com.wire.android.feature.meetings.R import com.wire.android.model.asSnackBarMessage +import com.wire.android.util.CurrentTimeProvider import com.wire.android.util.ui.UIText import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.data.id.ConversationId @@ -42,7 +43,6 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import kotlinx.datetime.Clock import kotlinx.datetime.Instant import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -71,8 +71,8 @@ class MeetingOptionsMenuViewModelTest { fun givenFutureMeeting_andSelfUserIsCreator_whenObserving_thenEditAndDeleteForEveryoneIsAvailable() = runTest(dispatcher) { val meeting = meeting( selfRole = MeetingOccurrence.SelfRole.Creator, - occurrenceStartTime = Clock.System.now() + 1.hours, - occurrenceEndTime = Clock.System.now() + 2.hours, + occurrenceStartTime = CURRENT_TIME + 1.hours, + occurrenceEndTime = CURRENT_TIME + 2.hours, ) val (_, viewModel) = Arrangement() .withObservedMeeting(meeting) @@ -94,8 +94,8 @@ class MeetingOptionsMenuViewModelTest { fun givenFutureMeeting_andSelfUserIsMember_whenObserving_thenEditAndDeleteIsNotAvailable() = runTest(dispatcher) { val meeting = meeting( selfRole = MeetingOccurrence.SelfRole.Member, - occurrenceStartTime = Clock.System.now() + 1.hours, - occurrenceEndTime = Clock.System.now() + 2.hours + occurrenceStartTime = CURRENT_TIME + 1.hours, + occurrenceEndTime = CURRENT_TIME + 2.hours ) val (_, viewModel) = Arrangement() .withObservedMeeting(meeting) @@ -117,8 +117,8 @@ class MeetingOptionsMenuViewModelTest { fun givenPastMeeting_andSelfUserIsCreator_whenObserving_thenEditAndDeleteIsNotAvailable() = runTest(dispatcher) { val meeting = meeting( selfRole = MeetingOccurrence.SelfRole.Creator, - occurrenceStartTime = Clock.System.now() - 2.hours, - occurrenceEndTime = Clock.System.now() - 1.hours, + occurrenceStartTime = CURRENT_TIME - 2.hours, + occurrenceEndTime = CURRENT_TIME - 1.hours, ) val (_, viewModel) = Arrangement() .withObservedMeeting(meeting) @@ -211,6 +211,8 @@ class MeetingOptionsMenuViewModelTest { @MockK lateinit var deleteMeetingUseCase: DeleteMeetingUseCase + val currentTimeProvider = CurrentTimeProvider { CURRENT_TIME } + init { MockKAnnotations.init(this) coEvery { observeMeetingOccurrenceUseCase.invoke(OCCURRENCE_ID) } returns flowOf(null) @@ -223,6 +225,7 @@ class MeetingOptionsMenuViewModelTest { coEvery { deleteMeetingUseCase.invoke(MEETING_ID) } returns result } fun arrange() = this to MeetingOptionsMenuViewModelImpl( + currentTimeProvider = currentTimeProvider, observeMeetingOccurrenceUseCase = observeMeetingOccurrenceUseCase, deleteMeetingUseCase = deleteMeetingUseCase, ) @@ -231,6 +234,7 @@ class MeetingOptionsMenuViewModelTest { private companion object { const val OCCURRENCE_ID = "occurrence-id" const val MEETING_TITLE = "Weekly sync" + val CURRENT_TIME = Instant.parse("2026-08-01T12:00:00Z") val MEETING_ID = MeetingId("meeting-id", "domain") val CONVERSATION_ID = ConversationId("conversation-id", "domain") } From e215fe26f8a611d7a3521ea16b5ef07902e8340d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Mon, 3 Aug 2026 16:39:46 +0200 Subject: [PATCH 3/5] compose stability update --- features/meetings/stability/meetings-debug.stability | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/features/meetings/stability/meetings-debug.stability b/features/meetings/stability/meetings-debug.stability index 49172921c58..847664b150a 100644 --- a/features/meetings/stability/meetings-debug.stability +++ b/features/meetings/stability/meetings-debug.stability @@ -17,7 +17,7 @@ public fun com.ramcosta.composedestinations.generated.meetings.destinations.NewM params: @Composable -public fun com.wire.android.feature.meetings.ui.AllMeetingsScreen(lazyListState: androidx.compose.foundation.lazy.LazyListState, contentPadding: androidx.compose.foundation.layout.PaddingValues, tabs: kotlinx.collections.immutable.ImmutableList, initialTab: com.wire.android.feature.meetings.ui.MeetingsTabItem, startCall: kotlin.Function1<@[ParameterName(name = \, joinCall: kotlin.Function1<@[ParameterName(name = \, returnToCall: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit +public fun com.wire.android.feature.meetings.ui.AllMeetingsScreen(lazyListState: androidx.compose.foundation.lazy.LazyListState, contentPadding: androidx.compose.foundation.layout.PaddingValues, tabs: kotlinx.collections.immutable.ImmutableList, initialTab: com.wire.android.feature.meetings.ui.MeetingsTabItem, startCall: kotlin.Function1<@[ParameterName(name = \, joinCall: kotlin.Function1<@[ParameterName(name = \, returnToCall: kotlin.Function1<@[ParameterName(name = \, editMeeting: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit skippable: true restartable: true params: @@ -28,6 +28,7 @@ public fun com.wire.android.feature.meetings.ui.AllMeetingsScreen(lazyListState: - startCall: STABLE (function type) - joinCall: STABLE (function type) - returnToCall: STABLE (function type) + - editMeeting: STABLE (function type) @Composable public fun com.wire.android.feature.meetings.ui.NewMeetingBottomSheet(sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, onMeetNowClick: kotlin.Function0, onScheduleClick: kotlin.Function0): kotlin.Unit @@ -344,10 +345,11 @@ private fun com.wire.android.feature.meetings.ui.options.MeetingOptionsModalCont - onDeleteMeetingForEveryone: STABLE (function type) @Composable -public fun com.wire.android.feature.meetings.ui.options.MeetingOptionsModalSheetLayout(sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, viewModel: com.wire.android.feature.meetings.ui.options.MeetingOptionsMenuViewModel): kotlin.Unit +public fun com.wire.android.feature.meetings.ui.options.MeetingOptionsModalSheetLayout(sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, editMeeting: kotlin.Function1, viewModel: com.wire.android.feature.meetings.ui.options.MeetingOptionsMenuViewModel): kotlin.Unit skippable: false restartable: true params: - sheetState: UNSTABLE (has mutable properties or unstable members) + - editMeeting: STABLE (function type) - viewModel: RUNTIME (requires runtime check) From c548d48e5fcf25c9a4c1c9b5f8621bd9b9476f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Tue, 4 Aug 2026 14:20:57 +0200 Subject: [PATCH 4/5] review comments --- .../meetings/ui/create/NewMeetingScreen.kt | 42 ++++- .../meetings/ui/create/NewMeetingViewModel.kt | 177 ++++++++++-------- .../meetings/src/main/res/values/strings.xml | 2 + .../ui/create/NewMeetingViewModelTest.kt | 61 +++--- 4 files changed, 177 insertions(+), 105 deletions(-) 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 86cae75079f..7f89fcb46ea 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 @@ -78,6 +78,9 @@ import com.wire.android.navigation.annotation.features.meetings.WireNewMeetingDe import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.VisibilityState +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.WireDropDown import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.button.WireButtonState @@ -141,18 +144,22 @@ fun NewMeetingScreen( onParticipantsClicked = { navigator.navigate(NavigationCommand(NewMeetingParticipantsScreenDestination)) }, - onCreateClicked = newMeetingViewModel::submit, + onCreateClicked = newMeetingViewModel::submitCreation, + onUpdateClicked = newMeetingViewModel::submitUpdate, onStartTimeChanged = newMeetingViewModel::updateStartTime, onEndTimeChanged = newMeetingViewModel::updateEndTime, onRepeatingIntervalChanged = newMeetingViewModel::updateRepeatingInterval, ) - if (newMeetingViewModel.state.creationError != null) { + if (newMeetingViewModel.state.submitError != null) { NewMeetingErrorDialog( type = newMeetingViewModel.type, onDismiss = newMeetingViewModel::dismissCreationError ) } + if (newMeetingViewModel.state.initialLoading == NewMeetingState.InitialLoadingState.Error) { + FailedToLoadEditMeetingDataError(navigateBack = navigator::navigateBack) + } HandleActions(newMeetingViewModel.actions) { action -> when (action) { @@ -170,6 +177,7 @@ fun NewMeetingContent( onBackPressed: () -> Unit = {}, onParticipantsClicked: () -> Unit = {}, onCreateClicked: () -> Unit = {}, + onUpdateClicked: () -> Unit = {}, onStartTimeChanged: (startTime: Instant) -> Unit = {}, onEndTimeChanged: (endTime: Instant) -> Unit = {}, onRepeatingIntervalChanged: (interval: MeetingItem.RepeatingInterval?) -> Unit = {}, @@ -236,7 +244,7 @@ fun NewMeetingContent( onClick = onParticipantsClicked, ) } - if (state.isSubmitting || state.isDataLoading) { + if (state.isSubmitting || state.initialLoading != NewMeetingState.InitialLoadingState.Loaded) { Box( modifier = Modifier .fillMaxSize() @@ -245,7 +253,7 @@ fun NewMeetingContent( }, ) } - AnimatedVisibility(state.isDataLoading) { + AnimatedVisibility(state.initialLoading != NewMeetingState.InitialLoadingState.Loaded) { Box( modifier = Modifier .fillMaxSize() @@ -277,7 +285,10 @@ fun NewMeetingContent( }, state = if (state.continueButtonEnabled) WireButtonState.Default else WireButtonState.Disabled, loading = state.isSubmitting, - onClick = onCreateClicked, + onClick = when (type) { + is NewMeetingType.Edit -> onUpdateClicked + NewMeetingType.MeetNow, NewMeetingType.Schedule -> onCreateClicked + }, modifier = Modifier .fillMaxWidth() .padding(dimensions().spacing16x), @@ -580,6 +591,21 @@ private fun RepeatingIntervalDropDown( } } +@Composable +private fun FailedToLoadEditMeetingDataError(navigateBack: () -> Unit) { + WireDialog( + title = stringResource(R.string.new_meeting_edit_init_failure_title), + text = stringResource(R.string.new_meeting_edit_init_failure_description), + onDismiss = navigateBack, + buttonsHorizontalAlignment = false, + optionButton1Properties = WireDialogButtonProperties( + onClick = navigateBack, + text = stringResource(commonR.string.label_ok), + type = WireDialogButtonType.Primary, + ), + ) +} + @PreviewMultipleThemes @Composable fun PreviewNewMeetingScreen_MeetNow() = WireTheme { @@ -589,7 +615,7 @@ fun PreviewNewMeetingScreen_MeetNow() = WireTheme { state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( confirmedContacts = buildContacts(names.size), continueButtonEnabled = true, - isDataLoading = false, + initialLoading = NewMeetingState.InitialLoadingState.Loaded, ), ) } @@ -604,7 +630,7 @@ fun PreviewNewMeetingScreen_Schedule() = WireTheme { startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()), endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()).plus(1.hours), repeatingInterval = MeetingItem.RepeatingInterval.Supported.first(), - isDataLoading = false, + initialLoading = NewMeetingState.InitialLoadingState.Loaded, ), ) } @@ -619,7 +645,7 @@ fun PreviewNewMeetingScreen_Edit() = WireTheme { startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()), endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()).plus(1.hours), repeatingInterval = MeetingItem.RepeatingInterval.Supported.first(), - isDataLoading = false, + initialLoading = NewMeetingState.InitialLoadingState.Loaded, ), ) } 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 0a08b1e640d..b6739967f06 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 @@ -29,6 +29,7 @@ import com.ramcosta.composedestinations.generated.meetings.navArgs import com.wire.android.feature.meetings.mapper.toRepeatingInterval import com.wire.android.feature.meetings.model.MeetingItem import com.wire.android.feature.meetings.ui.create.NewMeetingState.Companion.initialState +import com.wire.android.feature.meetings.ui.create.NewMeetingState.InitialLoadingState import com.wire.android.feature.meetings.ui.create.NewMeetingViewModel.Companion.MEETING_NAME_MAX_COUNT import com.wire.android.mapper.ContactMapper import com.wire.android.model.Contact @@ -72,7 +73,8 @@ interface NewMeetingViewModel : ActionsManager { fun updateStartTime(startTime: Instant) {} fun updateEndTime(endTime: Instant) {} fun updateRepeatingInterval(interval: MeetingItem.RepeatingInterval?) {} - fun submit() {} + fun submitCreation() {} + fun submitUpdate() {} fun dismissCreationError() {} companion object { @@ -105,28 +107,33 @@ class NewMeetingViewModelImpl( init { observeTitleChanges() - getNextMeetingOccurrenceData() + loadInitialDataForEditing() } - private fun getNextMeetingOccurrenceData() { + private fun loadInitialDataForEditing() { viewModelScope.launch { - val meetingType = navArgs.type - if (meetingType is NewMeetingType.Edit) { - getNextMeetingOccurrence(meetingType.id, currentTimeProvider())?.let { nextMeetingOccurrence -> - val otherContacts = observeConversationMembers(nextMeetingOccurrence.meeting.conversationId).firstOrNull()?.let { - it.map { it.user }.filterIsInstance().map { contactMapper.fromOtherUser(it) }.toPersistentSet() - } ?: persistentSetOf() - titleTextState.setTextAndPlaceCursorAtEnd(nextMeetingOccurrence.meeting.title) - state = state.copy( - startTime = nextMeetingOccurrence.occurrenceStartTime, - endTime = nextMeetingOccurrence.occurrenceEndTime, - repeatingInterval = nextMeetingOccurrence.meeting.recurrence?.toRepeatingInterval(), - selectedContacts = otherContacts, - confirmedContacts = otherContacts, - ) + try { + val meetingType = navArgs.type + if (meetingType is NewMeetingType.Edit) { + val meetingOccurrence = getNextMeetingOccurrence(meetingType.id, currentTimeProvider()) + if (meetingOccurrence != null) { + val otherContacts = observeConversationMembers(meetingOccurrence.meeting.conversationId).firstOrNull()?.let { + it.map { it.user }.filterIsInstance().map { contactMapper.fromOtherUser(it) }.toPersistentSet() + } ?: persistentSetOf() + titleTextState.setTextAndPlaceCursorAtEnd(meetingOccurrence.meeting.title) + state = state.copy( + startTime = meetingOccurrence.occurrenceStartTime, + endTime = meetingOccurrence.occurrenceEndTime, + repeatingInterval = meetingOccurrence.meeting.recurrence?.toRepeatingInterval(), + selectedContacts = otherContacts, + confirmedContacts = otherContacts, + ) + } } + state = state.copy(initialLoading = InitialLoadingState.Loaded) + } catch (_: Exception) { + state = state.copy(initialLoading = InitialLoadingState.Error) } - state = state.copy(isDataLoading = false) } } @@ -176,87 +183,103 @@ class NewMeetingViewModelImpl( } private fun validateTitle(): Boolean { + val titleError = when { + titleTextState.text.trim().isEmpty() -> NewMeetingState.TitleError.TitleEmptyError + titleTextState.text.trim().length > MEETING_NAME_MAX_COUNT -> NewMeetingState.TitleError.TitleExceedsLimitError + else -> null + } state = state.copy( - titleError = when { - titleTextState.text.trim().isEmpty() -> NewMeetingState.TitleError.TitleEmptyError - titleTextState.text.trim().length > MEETING_NAME_MAX_COUNT -> NewMeetingState.TitleError.TitleExceedsLimitError - else -> null - } - ).withContinueButtonState() - return state.titleError == null + titleError = titleError, + continueButtonEnabled = titleTextState.text.trim().isNotEmpty() && + titleError == null && + state.startTimeError == null && + state.endTimeError == null + ) + return titleError == null } private fun validateStartAndEndTime(): Boolean { + val startTimeError = when { + state.startTime < currentTimeProvider() -> NewMeetingState.TimeError.StartTimeInPastError + else -> null + } + val endTimeError = when { + state.endTime < currentTimeProvider() -> NewMeetingState.TimeError.EndTimeInPastError + state.endTime < state.startTime -> NewMeetingState.TimeError.EndTimeBeforeStartTimeError + else -> null + } state = state.copy( - startTimeError = when { - state.startTime < currentTimeProvider() -> NewMeetingState.TimeError.StartTimeInPastError - else -> null - }, - endTimeError = when { - state.endTime < currentTimeProvider() -> NewMeetingState.TimeError.EndTimeInPastError - state.endTime < state.startTime -> NewMeetingState.TimeError.EndTimeBeforeStartTimeError - else -> null - } - ).withContinueButtonState() - return state.startTimeError == null && state.endTimeError == null + startTimeError = startTimeError, + endTimeError = endTimeError, + continueButtonEnabled = titleTextState.text.trim().isNotEmpty() && + state.titleError == null && + startTimeError == null && + endTimeError == null + ) + return startTimeError == null && endTimeError == null } - private fun NewMeetingState.withContinueButtonState(): NewMeetingState = copy( - continueButtonEnabled = titleTextState.text.trim().isNotEmpty() && - titleError == null && - startTimeError == null && - endTimeError == null - ) - - override fun submit() { + override fun submitCreation() { viewModelScope.launch { val titleValid = validateTitle() val startAndEndTimeValid = when (type) { NewMeetingType.MeetNow -> { - state = state.copy(startTime = currentTimeProvider(), endTime = currentTimeProvider().plus(1.hours)) + val startTime = currentTimeProvider() + state = state.copy(startTime = startTime, endTime = startTime.plus(1.hours)) true // for "meet now", we set the start time to the current time and end time to +1 hour, so it's already valid } - NewMeetingType.Schedule, is NewMeetingType.Edit -> validateStartAndEndTime() + NewMeetingType.Schedule -> validateStartAndEndTime() + is NewMeetingType.Edit -> false } if (titleValid && startAndEndTimeValid) { state = state.copy(isSubmitting = true, continueButtonEnabled = false) - val creationResult = when (type) { - NewMeetingType.MeetNow, NewMeetingType.Schedule -> createNewMeeting( - createMeeting = CreateMeeting( - title = titleTextState.text.trim().toString(), - startTime = state.startTime, - endTime = state.endTime, - recurrence = state.repeatingInterval?.let { Meeting.Recurrence(it.frequency, it.interval.toLong(), null) }, - otherParticipants = state.confirmedContacts.map { UserId(it.id, it.domain) } - ) + val creationResult = createNewMeeting( + createMeeting = CreateMeeting( + title = titleTextState.text.trim().toString(), + startTime = state.startTime, + endTime = state.endTime, + recurrence = state.repeatingInterval?.let { Meeting.Recurrence(it.frequency, it.interval.toLong(), null) }, + otherParticipants = state.confirmedContacts.map { UserId(it.id, it.domain) } ) + ) + state = state.copy(isSubmitting = false, continueButtonEnabled = true) + when (creationResult) { + is CreateNewMeetingUseCase.Result.Success -> sendAction(NewMeetingViewActions.Success) + is CreateNewMeetingUseCase.Result.Failure -> state = state.copy(submitError = NewMeetingState.SubmitError.Other) + } + } + } + } - is NewMeetingType.Edit -> updateMeeting( - meetingId = type.id, - meeting = CreateMeeting( - title = titleTextState.text.trim().toString(), - startTime = state.startTime, - endTime = state.endTime, - recurrence = state.repeatingInterval?.let { Meeting.Recurrence(it.frequency, it.interval.toLong(), null) }, - otherParticipants = state.confirmedContacts.map { UserId(it.id, it.domain) } - ) + override fun submitUpdate() { + val meetingType = type as? NewMeetingType.Edit ?: return + viewModelScope.launch { + val titleValid = validateTitle() + val startAndEndTimeValid = validateStartAndEndTime() + if (titleValid && startAndEndTimeValid) { + state = state.copy(isSubmitting = true, continueButtonEnabled = false) + val updateResult = updateMeeting( + meetingId = meetingType.id, + meeting = CreateMeeting( + title = titleTextState.text.trim().toString(), + startTime = state.startTime, + endTime = state.endTime, + recurrence = state.repeatingInterval?.let { Meeting.Recurrence(it.frequency, it.interval.toLong(), null) }, + otherParticipants = state.confirmedContacts.map { UserId(it.id, it.domain) } ) - } + ) state = state.copy(isSubmitting = false, continueButtonEnabled = true) - when (creationResult) { - is CreateNewMeetingUseCase.Result.Success, + when (updateResult) { is UpdateMeetingUseCase.Result.Success -> sendAction(NewMeetingViewActions.Success) - - is CreateNewMeetingUseCase.Result.Failure, - is UpdateMeetingUseCase.Result.Failure -> state = state.copy(creationError = NewMeetingState.CreationError.Other) + is UpdateMeetingUseCase.Result.Failure -> state = state.copy(submitError = NewMeetingState.SubmitError.Other) } } } } override fun dismissCreationError() { - state = state.copy(creationError = null) + state = state.copy(submitError = null) } } @@ -285,10 +308,12 @@ data class NewMeetingState( val endTime: Instant, val endTimeError: TimeError? = null, val repeatingInterval: MeetingItem.RepeatingInterval? = null, - val creationError: CreationError? = null, + val submitError: SubmitError? = null, val isSubmitting: Boolean = false, - val isDataLoading: Boolean = false, + val initialLoading: InitialLoadingState = InitialLoadingState.Loading, ) { + enum class InitialLoadingState { Error, Loading, Loaded } + @Stable sealed interface TitleError { data object TitleEmptyError : TitleError @@ -301,14 +326,14 @@ data class NewMeetingState( data object EndTimeBeforeStartTimeError : TimeError } - sealed interface CreationError { - data object Other : CreationError // TODO Add more specific error types in the future + sealed interface SubmitError { + data object Other : SubmitError // TODO Add more specific error types in the future } companion object { fun initialState(currentTimeProvider: CurrentTimeProvider): NewMeetingState { val startTime = getNextFullHour(currentTimeProvider()) - return NewMeetingState(startTime = startTime, endTime = startTime.plus(1.hours), isDataLoading = true) + return NewMeetingState(startTime = startTime, endTime = startTime.plus(1.hours)) } } } diff --git a/features/meetings/src/main/res/values/strings.xml b/features/meetings/src/main/res/values/strings.xml index 66ebf04d66e..1a858c1c94b 100644 --- a/features/meetings/src/main/res/values/strings.xml +++ b/features/meetings/src/main/res/values/strings.xml @@ -87,6 +87,8 @@ Something went wrong while scheduling the meeting. Please try again. Could not update meeting 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. +%1$d more 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 0309d61b7f8..d8d2d0e3839 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 @@ -231,7 +231,7 @@ class NewMeetingViewModelTest { } @Test - fun givenMeetNowTypeWithValidData_whenSubmitIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { + fun givenMeetNowTypeWithValidData_whenSubmitCreationIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { val currentTime = Instant.parse("2026-01-01T12:00:00Z") val (arrangement, viewModel) = arrangeViewModel( Arrangement(dispatcher) @@ -242,7 +242,7 @@ class NewMeetingViewModelTest { enterTitle(viewModel, " Quick sync ") viewModel.actions.test { - viewModel.submit() + viewModel.submitCreation() advanceUntilIdle() coVerify(exactly = 1) { @@ -259,14 +259,14 @@ class NewMeetingViewModelTest { assertEquals(currentTime, viewModel.state.startTime) assertEquals(currentTime + 1.hours, viewModel.state.endTime) assertFalse(viewModel.state.isSubmitting) - assertNull(viewModel.state.creationError) + assertNull(viewModel.state.submitError) assertEquals(NewMeetingViewActions.Success, awaitItem()) cancelAndConsumeRemainingEvents() } } @Test - fun givenScheduleTypeWithValidData_whenSubmitIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { + fun givenScheduleTypeWithValidData_whenSubmitCreationIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { val currentTime = Instant.parse("2026-01-01T12:00:00Z") val createMeeting = CREATE_MEETING.copy(startTime = currentTime + 2.hours, endTime = currentTime + 3.hours) val (arrangement, viewModel) = arrangeViewModel( @@ -283,18 +283,18 @@ class NewMeetingViewModelTest { viewModel.updateRepeatingInterval(createMeeting.recurrence?.toRepeatingInterval()) viewModel.actions.test { - viewModel.submit() + viewModel.submitCreation() advanceUntilIdle() coVerify(exactly = 1) { arrangement.createNewMeeting(createMeeting) } assertFalse(viewModel.state.isSubmitting) - assertNull(viewModel.state.creationError) + assertNull(viewModel.state.submitError) assertEquals(NewMeetingViewActions.Success, awaitItem()) cancelAndConsumeRemainingEvents() } } @Test - fun givenCreationFails_whenSubmitIsCalled_thenErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { + fun givenCreationFails_whenSubmitCreationIsCalled_thenErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { val (arrangement, viewModel) = arrangeViewModel( Arrangement(dispatcher) .withNewMeetingType(NewMeetingType.MeetNow) @@ -304,19 +304,19 @@ class NewMeetingViewModelTest { enterTitle(viewModel, "Weekly sync") viewModel.actions.test { - viewModel.submit() + viewModel.submitCreation() advanceUntilIdle() coVerify(exactly = 1) { arrangement.createNewMeeting(any()) } expectNoEvents() assertFalse(viewModel.state.isSubmitting) assertEquals(true, viewModel.state.continueButtonEnabled) - assertEquals(NewMeetingState.CreationError.Other, viewModel.state.creationError) + assertEquals(NewMeetingState.SubmitError.Other, viewModel.state.submitError) } } @Test - fun givenEditTypeWithValidData_whenSubmitIsCalled_thenMeetingIsEditedAndSuccessActionIsSent() = runTest(dispatcher) { + fun givenEditTypeWithValidData_whenSubmitUpdateIsCalled_thenMeetingIsEditedAndSuccessActionIsSent() = runTest(dispatcher) { val currentTime = Instant.parse("2026-01-01T12:00:00Z") val contact = contact("contact-1") val createMeeting = CREATE_MEETING.copy(startTime = currentTime + 2.hours, endTime = currentTime + 3.hours) @@ -346,18 +346,18 @@ class NewMeetingViewModelTest { viewModel.updateRepeatingInterval(createMeeting.recurrence?.toRepeatingInterval()) viewModel.actions.test { - viewModel.submit() + viewModel.submitUpdate() advanceUntilIdle() coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, createMeeting) } assertFalse(viewModel.state.isSubmitting) - assertNull(viewModel.state.creationError) + assertNull(viewModel.state.submitError) assertEquals(NewMeetingViewActions.Success, awaitItem()) cancelAndConsumeRemainingEvents() } } @Test - fun givenEditionFails_whenSubmitIsCalled_thenErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { + fun givenEditionFails_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( @@ -379,23 +379,23 @@ class NewMeetingViewModelTest { enterTitle(viewModel, "Weekly sync") viewModel.actions.test { - viewModel.submit() + viewModel.submitUpdate() advanceUntilIdle() coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, any()) } expectNoEvents() assertFalse(viewModel.state.isSubmitting) assertEquals(true, viewModel.state.continueButtonEnabled) - assertEquals(NewMeetingState.CreationError.Other, viewModel.state.creationError) + assertEquals(NewMeetingState.SubmitError.Other, viewModel.state.submitError) } } @Test - fun givenInvalidTitle_whenSubmitIsCalled_thenTitleErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { + fun givenInvalidTitle_whenSubmitCreationIsCalled_thenTitleErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { val (arrangement, viewModel) = arrangeViewModel() viewModel.actions.test { - viewModel.submit() + viewModel.submitCreation() advanceUntilIdle() coVerify(exactly = 0) { arrangement.createNewMeeting(any()) } @@ -419,7 +419,7 @@ class NewMeetingViewModelTest { viewModel.updateStartTime(currentTime - 1.hours) viewModel.actions.test { - viewModel.submit() + viewModel.submitCreation() advanceUntilIdle() coVerify(exactly = 0) { arrangement.createNewMeeting(any()) } @@ -457,7 +457,7 @@ class NewMeetingViewModelTest { assertEquals(nextOccurrence.occurrenceEndTime, viewModel.state.endTime) assertEquals(nextOccurrence.meeting.recurrence?.frequency, viewModel.state.repeatingInterval?.frequency) assertEquals(nextOccurrence.meeting.recurrence?.interval?.toInt(), viewModel.state.repeatingInterval?.interval) - assertFalse(viewModel.state.isDataLoading) + assertEquals(NewMeetingState.InitialLoadingState.Loaded, viewModel.state.initialLoading) } @Suppress("UnusedFlow") @@ -487,7 +487,7 @@ class NewMeetingViewModelTest { coVerify(exactly = 1) { arrangement.observeConversationMembers(MEETING_OCCURRENCE.meeting.conversationId) } assertEquals(setOf(firstContact, secondContact), viewModel.state.selectedContacts.toSet()) assertEquals(setOf(firstContact, secondContact), viewModel.state.confirmedContacts.toSet()) - assertFalse(viewModel.state.isDataLoading) + assertEquals(NewMeetingState.InitialLoadingState.Loaded, viewModel.state.initialLoading) } @Test @@ -506,7 +506,22 @@ class NewMeetingViewModelTest { assertEquals("", viewModel.titleTextState.text.toString()) assertEquals(currentTime + 1.hours, viewModel.state.startTime) assertEquals(currentTime + 2.hours, viewModel.state.endTime) - assertFalse(viewModel.state.isDataLoading) + assertEquals(NewMeetingState.InitialLoadingState.Loaded, viewModel.state.initialLoading) + } + + @Test + fun givenEditTypeAndNextOccurrenceLoadFails_whenViewModelIsCreated_thenInitialLoadingStateIsError() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val editType = NewMeetingType.Edit(MeetingId("meeting-id", "domain")) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withCurrentTimeProvider { currentTime } + .withNextMeetingOccurrenceFailure() + ) + + coVerify(exactly = 1) { arrangement.getNextMeetingOccurrence(editType.id, currentTime) } + assertEquals(NewMeetingState.InitialLoadingState.Error, viewModel.state.initialLoading) } private fun TestScope.arrangeViewModel( @@ -582,6 +597,10 @@ class NewMeetingViewModelTest { coEvery { getNextMeetingOccurrence(any(), any()) } returns nextMeetingOccurrence } + fun withNextMeetingOccurrenceFailure() = apply { + coEvery { getNextMeetingOccurrence(any(), any()) } throws IllegalStateException("Failed to load meeting occurrence") + } + fun withConversationMembers(conversationId: ConversationId, members: List) = apply { coEvery { observeConversationMembers(conversationId) } returns flowOf(members) } From a13ea469693f5a365c3af4ab11f42ca78e77b988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 5 Aug 2026 09:59:15 +0200 Subject: [PATCH 5/5] align meeting title max count with conversations --- .../android/feature/meetings/ui/create/NewMeetingViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b6739967f06..cf71221da6d 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 @@ -78,7 +78,7 @@ interface NewMeetingViewModel : ActionsManager { fun dismissCreationError() {} companion object { - const val MEETING_NAME_MAX_COUNT = 128 + const val MEETING_NAME_MAX_COUNT = 64 } }