diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryCustomScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryCustomScreen.kt index 0c901db51d..89b08fc0f1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryCustomScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryCustomScreen.kt @@ -50,6 +50,7 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes +import kotlinx.collections.immutable.toImmutableList @WireNewConversationDestination( navArgs = ChannelHistoryCustomArgs::class, @@ -125,7 +126,7 @@ fun ChannelHistoryCustomScreenContent( val items = ChannelHistoryType.On.Specific.AmountType.entries val itemsNames = items.map { pluralStringResource(it.nameResId, amountState.text.toString().toIntOrNull() ?: 0) } WireDropDown( - items = itemsNames, + items = itemsNames.toImmutableList(), defaultItemIndex = 0, selectedItemIndex = items.indexOf(typeState), label = stringResource(R.string.channel_history_custom_time_label), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt index 42474dd04f..0d67318c18 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt @@ -75,6 +75,7 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.id.QualifiedID +import kotlinx.collections.immutable.toImmutableList @WireRootDestination( style = SlideNavigationAnimation::class, // default should be SlideNavigationAnimation @@ -173,7 +174,7 @@ fun ChangeUserColorContent( val items = Accent.entries.filter { accent -> accent != Accent.Unknown } WireDropDown( - items = items.map { stringResource(it.resourceId()) }, + items = items.map { stringResource(it.resourceId()) }.toImmutableList(), defaultItemIndex = if (accentColor == Accent.Unknown) -1 else items.indexOf(accentColor), label = null, modifier = Modifier.padding(horizontal = MaterialTheme.wireDimensions.spacing16x), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt index 9380e4fef6..72441555fc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt @@ -142,6 +142,7 @@ fun ChangeEmailContent( inputTransformation = InputTransformation.forceLowercase(), state = computeEmailErrorState(state.flowState), keyboardOptions = KeyboardOptions.DefaultEmailDone, + readOnly = state.flowState is ChangeEmailState.FlowState.Loading, onKeyboardAction = { keyboardController?.hide() }, modifier = Modifier.padding( horizontal = MaterialTheme.wireDimensions.spacing16x @@ -191,8 +192,6 @@ private fun computeEmailErrorState(state: ChangeEmailState.FlowState): WireTextF stringResource(id = R.string.settings_myaccount_email_generic_error) ) - ChangeEmailState.FlowState.Loading -> WireTextFieldState.ReadOnly - else -> WireTextFieldState.Default } diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/BackendSelectorDropDown.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/BackendSelectorDropDown.kt index 963a526256..38b4fdc82b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/BackendSelectorDropDown.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/BackendSelectorDropDown.kt @@ -48,6 +48,7 @@ import com.wire.android.ui.common.WireDropDown import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.forceLowercase +import kotlinx.collections.immutable.toImmutableList @Composable internal fun BackendSelectorDropDown() { @@ -63,7 +64,7 @@ internal fun BackendSelectorDropDown() { ) { WireDropDown( modifier = Modifier.alpha(0.5f), - items = backendConfigs.map { it.first }, + items = backendConfigs.map { it.first }.toImmutableList(), label = null, autoUpdateSelection = false, placeholder = "Change application backend", diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginContainer.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginContainer.kt index f21f3e7d37..fe469ac5b4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginContainer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginContainer.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.zIndex import com.wire.android.BuildConfig import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout import com.wire.android.ui.common.bottomsheet.WireBottomSheetDefaults @@ -82,6 +83,7 @@ fun NewAuthContainer( Surface( color = WireBottomSheetDefaults.WireSheetContainerColor, shadowElevation = scrollState.rememberTopBarElevationState().value, + modifier = Modifier.zIndex(1f) // to ensure the header is above the column content when scrolled ) { header() } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt index b72ea84c59..da01c5f7ea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt @@ -147,7 +147,7 @@ fun UserProfileInfo( targetState = userAvatarData to showPlaceholderIfNoAsset.value, label = "UserProfileInfoAvatar" ) { (userAvatarData, showPlaceholderIfNoAsset) -> - val onAvatarClickDescription = stringResource(R.string.content_description_change_it_label) + val onAvatarClickDescription = stringResource(commonR.string.content_description_change_it_label) val contentDescription = if (editableState is EditableState.IsEditable) { stringResource(R.string.content_description_self_profile_avatar) } else { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt index 7e53eee371..22a1a1377c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt @@ -105,6 +105,7 @@ import com.wire.android.ui.userprofile.self.model.OtherAccount import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.UserId +import kotlinx.collections.immutable.toImmutableList val LocalSelfUserProfileLogoutAction = staticCompositionLocalOf<((wipeData: Boolean) -> Unit)?> { null @@ -437,7 +438,7 @@ private fun CurrentSelfUserStatus( UserAvailabilityStatus.AWAY -> stringResource(UICommonR.string.user_profile_status_away) UserAvailabilityStatus.NONE -> stringResource(UICommonR.string.user_profile_status_none) } - }, + }.toImmutableList(), defaultItemIndex = items.indexOf(userStatus), label = null, modifier = Modifier.padding(horizontal = MaterialTheme.wireDimensions.spacing16x), diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f057a65319..02bae93df5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -206,8 +206,6 @@ Zurück zu Unterhaltungsdetails Anfrage akzeptieren oder ignorieren Hinweis - Auswahlliste - Dropdown schließen Benachrichtigungseinstellungen öffnen Ansicht neue Unterhaltung schließen Zurück zur Ansicht neue Unterhaltung @@ -1129,9 +1127,6 @@ Team-Einstellungen geändert Das Teilen und Empfangen von Dateien jeder Art ist jetzt aktiviert Das Teilen und Empfangen von Dateien jeder Art ist jetzt deaktiviert - - " (Standard)" - Bitte auswählen Ändern Gäste zulassen Öffnen Sie diese Unterhaltung für Personen außerhalb Ihres Teams. Diese Einstellung kann später jederzeit geändert werden. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1a8ecb45af..3b505edc49 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -685,9 +685,6 @@ Un mensaje eliminado no puede ser restaurado. Configuración del equipo cambiada Ahora está habilitada la compartición y recepción de todo tipo de archivos Ahora está deshabilitada la compartición y recepción de todo tipo de archivos - - " (predeterminado)" - Seleccionar una opción Cambiar Permitir invitados Abra esta conversación a personas fuera de su equipo. Siempre puede cambiarlo más tarde. diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 5416cbf0a4..52f64e06dc 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -459,8 +459,6 @@ Preuzmite datoteku - - Izaberi stavku Promijeni Dopustite goste diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 335a628a93..fe307e3844 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -180,10 +180,7 @@ Vissza a beszélgetés részleteihez Vissza a beszélgetés részleteihez a kérelem elfogadása vagy elutasítása - megváltoztatás Riasztás - Lenyitás - lenyíló elem összecsukása értesítési beállítások megnyitása Az új beszélgetés lap bezárása Vissza az új beszélgetés lapra @@ -968,9 +965,6 @@ A csapatbeállítások módosultak Bármilyen típusú fájl megosztása és fogadása mostantól engedélyezve Bármilyen típusú fájl megosztása és fogadása mostantól tiltva - - " (alapértelmezett)" - Jelöljön ki egy elemet Módosítás Vendégek engedélyezése Megnyitja a beszélgetést a csapatán kívüli személyek számára is. Ezt később bármikor megváltoztathatja. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index bb4981423b..01fa47e8bc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -717,9 +717,6 @@ Rispondendo qui, verrà riagganciata l\'altra chiamata. Impostazioni del team modificate La condivisione e la ricezione di file di qualsiasi tipo sono ora abilitate La condivisione e la ricezione di file di qualsiasi tipo sono ora disabilitate - - " (predefinito)" - Seleziona un elemento Cambia Consenti ospiti Apri questa conversazione a persone esterne al tuo team. Puoi sempre cambiarla successivamente. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index f73642b424..3dd130787d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -612,9 +612,6 @@ Dołączenie do tego połączenia spowoduje zakończenie tam Ustawienia zespołu zmienione Udostępnianie i odbieranie plików dowolnego typu są teraz włączone Udostępnianie i odbieranie plików dowolnego typu są teraz wyłączone - - " (domyślnie)" - Wybierz Zmień Zezwól na gości Otwórz tę rozmowę dla osób spoza twojego zespołu. Zawsze możesz to zmienić później. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index eeac134d76..20e7b98438 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -178,10 +178,7 @@ Voltar para os detalhes da conversa Voltar para os detalhes da conversa aceitar ou ignorar a solicitação - alterar isto Alerta - Menu suspenso - fechar menu suspenso abrir configurações de notificação Fechar visualização de novas conversas Voltar à visualização de nova conversa @@ -916,9 +913,6 @@ Uma mensagem excluída não pode ser restaurada. Configurações do time mudaram Compartilhar e receber arquivos de todos os tipos está habilitado Compartilhar e receber arquivos de todos os tipos está desabilitado - - " (padrão)" - Selecione um item Trocar Permitir convidados Abrir esta conversa para pessoas fora do seu time. Você pode sempre alterar isso depois. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 45af6d0e8d..7e072b6217 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -213,10 +213,7 @@ Вернуться к сведениям о беседе Вернуться к сведениям о беседе принять или игнорировать запрос - изменить Оповещение - Раскрывающийся список - закрыть раскрывающийся список открыть настройки уведомлений Закрыть просмотр новой беседы Вернуться к просмотру новой беседы @@ -1189,9 +1186,6 @@ Настройки команды изменены Обмен и получение файлов любого типа теперь включены Обмен и получение файлов любого типа теперь отключены - - " (по умолчанию)" - Выбрать элемент Изменить Разрешить гостей Открыть эту беседу для пользователей не из вашей команды. Вы всегда сможете изменить это позже. diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 528317fa26..9ff757cded 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -180,10 +180,7 @@ සංවාද විස්තර වෙත ආපසු යන්න සංවාද විස්තර වෙත ආපසු යන්න ඉල්ලීම පිළිගන්න හෝ නොසලකා හරින්න - එය වෙනස් කරන්න අනතුරු ඇඟවීම - පතන - පතන ලැයිස්තුව වසන්න දැනුම්දීම් සැකසුම් විවෘත කරන්න නව සංවාද දසුන වසන්න නව සංවාද දසුනට ආපසු යන්න @@ -982,9 +979,6 @@ කණ්ඩායමේ සැකසුම් සංශෝධිතයි ඕනෑම වර්ගයක ගොනු බෙදාගැනීම සහ ලැබීම සබල කර ඇත ඕනෑම වර්ගයක ගොනු බෙදාගැනීම සහ ලැබීම අබල කර ඇත - - " (පෙරනිමි)" - අථකයක් තෝරන්න සංශෝධනය අමුත්තන්ට ඉඩදෙන්න ඔබගේ කණ්ඩායමෙන් පිටත පුද්ගලයින්ට මෙම සංවාදය විවෘත කරන්න. මෙය පසුව වෙනස් කිරීමට හැකිය. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index dd05d13587..dc39eda50f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -180,10 +180,7 @@ Konuşma detaylarına geri dön Konuşma detaylarına geri dön isteği kabul et veya yok say - değiştir Uyarı - Açılır liste - açılır listeyi kapat bildirim ayarlarını aç Yeni konuşma görünümünü kapat Yeni konuşma görünümüne geri dön diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 4fe6a1bd66..260d1c82dd 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -154,9 +154,6 @@ Отримання та надсилання файлів будь-якого типу було увімкнено Отримання та надсилання файлів будь-якого типу було вимкнено - - " (за замовчуванням)" - Виберіть елемент Змінити Дозволити гостям Відкрийте цю бесіду людям, які не належать до вашої команди. Ви завжди можете змінити це пізніше. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b097bb865b..ac82a57b66 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -221,10 +221,7 @@ Go back to conversation details Go back to conversation details accept or ignore the request - change it Alert - Dropdown - close dropdown open notification settings Close new conversation view Go back to new conversation view @@ -1194,9 +1191,6 @@ Team Settings Changed Sharing and receiving files of any type is now enabled Sharing and receiving files of any type is now disabled - - " (default)" - Select an Item Change Allow guests Open this conversation to people outside your team. You can always change it later. diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index 79a3a1cf72..6e0ef40ecd 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -642,6 +642,14 @@ public fun com.wire.android.ui.CallFeedbackDialog(sheetState: com.wire.android.u - onSkipClicked: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) +@Composable +public fun com.wire.android.ui.CrossBackendLoginBlockedDialog(shouldShow: kotlin.Boolean, onDismiss: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - shouldShow: STABLE (primitive type) + - onDismiss: STABLE (function type) + @Composable public fun com.wire.android.ui.CustomBackendDialog(state: com.wire.android.ui.common.dialogs.CustomServerDialogState?, onDismiss: kotlin.Function0, onConfirm: kotlin.Function1, onTryAgain: kotlin.Function2): kotlin.Unit skippable: true @@ -1303,7 +1311,7 @@ public fun com.wire.android.ui.authentication.devices.remove.RemoveDeviceVerific - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable -private fun com.wire.android.ui.authentication.login.LoginContent(onBackPressed: kotlin.Function0, onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1, loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, ssoLoginResult: com.wire.android.util.deeplink.DeepLinkResult.SSOLogin?, ssoCodeAutoLogin: com.wire.android.ui.authentication.login.SSOCodeAutoLogin?): kotlin.Unit +private fun com.wire.android.ui.authentication.login.LoginContent(onBackPressed: kotlin.Function0, onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1, loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, ssoLoginResult: com.wire.android.util.deeplink.DeepLinkResult.SSOLogin?, ssoCodeAutoLogin: com.wire.android.ui.authentication.login.SSOCodeAutoLogin?): kotlin.Unit skippable: false restartable: true params: @@ -1333,7 +1341,7 @@ public fun com.wire.android.ui.authentication.login.LoginScreen(navigator: com.w - loginEmailViewModel: UNSTABLE (has mutable properties or unstable members) @Composable -private fun com.wire.android.ui.authentication.login.MainLoginContent(onBackPressed: kotlin.Function0, onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1, loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, ssoLoginResult: com.wire.android.util.deeplink.DeepLinkResult.SSOLogin?, ssoCodeAutoLogin: com.wire.android.ui.authentication.login.SSOCodeAutoLogin?): kotlin.Unit +private fun com.wire.android.ui.authentication.login.MainLoginContent(onBackPressed: kotlin.Function0, onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1, loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, ssoLoginResult: com.wire.android.util.deeplink.DeepLinkResult.SSOLogin?, ssoCodeAutoLogin: com.wire.android.ui.authentication.login.SSOCodeAutoLogin?): kotlin.Unit skippable: false restartable: true params: @@ -1393,7 +1401,7 @@ private fun com.wire.android.ui.authentication.login.email.LoginEmailContent(scr - fillMaxHeight: STABLE (primitive type) @Composable -public fun com.wire.android.ui.authentication.login.email.LoginEmailScreen(onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, scrollState: androidx.compose.foundation.ScrollState, fillMaxHeight: kotlin.Boolean): kotlin.Unit +public fun com.wire.android.ui.authentication.login.email.LoginEmailScreen(onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, scrollState: androidx.compose.foundation.ScrollState, fillMaxHeight: kotlin.Boolean): kotlin.Unit skippable: false restartable: true params: @@ -1403,6 +1411,17 @@ public fun com.wire.android.ui.authentication.login.email.LoginEmailScreen(onSuc - scrollState: STABLE (marked @Stable or @Immutable) - fillMaxHeight: STABLE (primitive type) +@Composable +private fun com.wire.android.ui.authentication.login.email.LoginEmailStateNavigationAndDialogs(state: com.wire.android.ui.authentication.login.LoginState, domainClaimedByOrg: com.wire.android.ui.authentication.login.DomainClaimedByOrg?, onClearLoginErrors: kotlin.Function0, onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1): kotlin.Unit + skippable: true + restartable: true + params: + - state: STABLE (class with no mutable properties) + - domainClaimedByOrg: STABLE (class with no mutable properties) + - onClearLoginErrors: STABLE (function type) + - onSuccess: STABLE (function type) + - onRemoveDeviceNeeded: STABLE (function type) + @Composable public fun com.wire.android.ui.authentication.login.email.LoginEmailVerificationCodeScreen(viewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel): kotlin.Unit skippable: false @@ -1474,7 +1493,7 @@ private fun com.wire.android.ui.authentication.login.sso.LoginButton(loading: ko - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.wire.android.ui.authentication.login.sso.LoginSSOContent(scrollState: androidx.compose.foundation.ScrollState, loginSSOState: com.wire.android.ui.authentication.login.sso.LoginSSOState, ssoCodeTextState: androidx.compose.foundation.text.input.TextFieldState, onErrorDialogDismiss: kotlin.Function0, onRemoveDeviceOpen: kotlin.Function1, onLoginButtonClick: kotlin.Function0, onCustomServerDialogDismiss: kotlin.Function0, onCustomServerDialogConfirm: kotlin.Function0): kotlin.Unit +private fun com.wire.android.ui.authentication.login.sso.LoginSSOContent(scrollState: androidx.compose.foundation.ScrollState, loginSSOState: com.wire.android.ui.authentication.login.sso.LoginSSOState, ssoCodeTextState: androidx.compose.foundation.text.input.TextFieldState, onErrorDialogDismiss: kotlin.Function0, onRemoveDeviceOpen: kotlin.Function1, onLoginButtonClick: kotlin.Function0, onCustomServerDialogDismiss: kotlin.Function0, onCustomServerDialogConfirm: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: @@ -1488,7 +1507,7 @@ private fun com.wire.android.ui.authentication.login.sso.LoginSSOContent(scrollS - onCustomServerDialogConfirm: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.login.sso.LoginSSOScreen(onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1, loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, ssoLoginResult: com.wire.android.util.deeplink.DeepLinkResult.SSOLogin?, ssoCodeAutoLogin: com.wire.android.ui.authentication.login.SSOCodeAutoLogin?, loginSSOViewModel: com.wire.android.ui.authentication.login.sso.LoginSSOViewModel, scrollState: androidx.compose.foundation.ScrollState): kotlin.Unit +public fun com.wire.android.ui.authentication.login.sso.LoginSSOScreen(onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1, loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, ssoLoginResult: com.wire.android.util.deeplink.DeepLinkResult.SSOLogin?, ssoCodeAutoLogin: com.wire.android.ui.authentication.login.SSOCodeAutoLogin?, loginSSOViewModel: com.wire.android.ui.authentication.login.sso.LoginSSOViewModel, scrollState: androidx.compose.foundation.ScrollState): kotlin.Unit skippable: false restartable: true params: @@ -2659,23 +2678,6 @@ public fun com.wire.android.ui.common.CopyButton(onCopyClicked: kotlin.Function0 - contentDescription: STABLE (primitive type) - state: STABLE (class with no mutable properties) -@Composable -private fun com.wire.android.ui.common.DropdownItem(text: kotlin.String, leadingCompose: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, isSelected: kotlin.Boolean, onClick: kotlin.Function0): kotlin.Unit - skippable: true - restartable: true - params: - - text: STABLE (String is immutable) - - leadingCompose: STABLE (composable function type) - - isSelected: STABLE (primitive type) - - onClick: STABLE (function type) - -@Composable -private fun com.wire.android.ui.common.LeadingIcon(convent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit - skippable: true - restartable: true - params: - - convent: STABLE (composable function type) - @Composable public fun com.wire.android.ui.common.LegalHoldIndicator(modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -2721,25 +2723,6 @@ public fun com.wire.android.ui.common.MLSVerifiedIcon(modifier: androidx.compose - modifier: STABLE (marked @Stable or @Immutable) - contentDescriptionId: STABLE (primitive type) -@Composable -private fun com.wire.android.ui.common.MenuPopUp(shape: androidx.compose.foundation.shape.RoundedCornerShape, textFieldWidth: androidx.compose.ui.geometry.Size, expanded: kotlin.Boolean, borderColor: androidx.compose.ui.graphics.Color, leadingCompose: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, selectedIndex: kotlin.Int, items: kotlin.collections.List, showDefaultTextIndicator: kotlin.Boolean, defaultItemIndex: kotlin.Int, selectionText: kotlin.String, arrowRotation: kotlin.Float, hidePopUp: kotlin.Function0, onChange: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit - skippable: false - restartable: true - params: - - shape: STABLE (known stable type) - - textFieldWidth: STABLE (marked @Stable or @Immutable) - - expanded: STABLE (primitive type) - - borderColor: STABLE (marked @Stable or @Immutable) - - leadingCompose: STABLE (composable function type) - - selectedIndex: STABLE (primitive type) - - items: RUNTIME (requires runtime check) - - showDefaultTextIndicator: STABLE (primitive type) - - defaultItemIndex: STABLE (primitive type) - - selectionText: STABLE (String is immutable) - - arrowRotation: STABLE (primitive type) - - hidePopUp: STABLE (function type) - - onChange: STABLE (function type) - @Composable public fun com.wire.android.ui.common.PageLoadingIndicator(text: kotlin.String, modifier: androidx.compose.ui.Modifier, prefixIconResId: kotlin.Int?): kotlin.Unit skippable: true @@ -2773,17 +2756,6 @@ public fun com.wire.android.ui.common.ProteusVerifiedIcon(modifier: androidx.com - modifier: STABLE (marked @Stable or @Immutable) - contentDescriptionId: STABLE (primitive type) -@Composable -private fun com.wire.android.ui.common.SelectionField(leadingCompose: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, selectedIndex: kotlin.Int, text: kotlin.String, arrowRotation: kotlin.Float, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - leadingCompose: STABLE (composable function type) - - selectedIndex: STABLE (primitive type) - - text: STABLE (String is immutable) - - arrowRotation: STABLE (primitive type) - - modifier: STABLE (marked @Stable or @Immutable) - @Composable public fun com.wire.android.ui.common.SettingUpWireScreenContent(modifier: androidx.compose.ui.Modifier, topBarTitleResId: kotlin.Int, iconResId: kotlin.Int, title: kotlin.String?, message: androidx.compose.ui.text.AnnotatedString, type: com.wire.android.ui.common.SettingUpWireScreenType): kotlin.Unit skippable: true @@ -2814,23 +2786,6 @@ public fun com.wire.android.ui.common.UnderConstructionScreen(screenName: kotlin - screenName: STABLE (String is immutable) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -internal fun com.wire.android.ui.common.WireDropDown(items: kotlin.collections.List, label: kotlin.String?, modifier: androidx.compose.ui.Modifier, defaultItemIndex: kotlin.Int, selectedItemIndex: kotlin.Int, autoUpdateSelection: kotlin.Boolean, showDefaultTextIndicator: kotlin.Boolean, leadingCompose: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, onChangeClickDescription: kotlin.String, placeholder: kotlin.String, onSelected: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit - skippable: false - restartable: true - params: - - items: RUNTIME (requires runtime check) - - label: STABLE (class with no mutable properties) - - modifier: STABLE (marked @Stable or @Immutable) - - defaultItemIndex: STABLE (primitive type) - - selectedItemIndex: STABLE (primitive type) - - autoUpdateSelection: STABLE (primitive type) - - showDefaultTextIndicator: STABLE (primitive type) - - leadingCompose: STABLE (composable function type) - - onChangeClickDescription: STABLE (String is immutable) - - placeholder: STABLE (String is immutable) - - onSelected: STABLE (function type) - @Composable public fun com.wire.android.ui.common.WireRadioButton(checked: kotlin.Boolean, modifier: androidx.compose.ui.Modifier, onButtonChecked: kotlin.Function0?, enabled: kotlin.Boolean): kotlin.Unit skippable: true @@ -6756,12 +6711,13 @@ private fun com.wire.android.ui.home.conversations.messages.item.buildContent(is - isWireCellsEnabled: STABLE (primitive type) @Composable -private fun com.wire.android.ui.home.conversations.messages.item.buildContent(expandable: kotlin.Boolean, learnMoreLinkResId: kotlin.Int?, iconResId: kotlin.Int, iconTintColor: androidx.compose.ui.graphics.Color?, iconSize: androidx.compose.ui.unit.Dp, additionalVerticalPaddings: androidx.compose.ui.unit.Dp, backgroundColor: androidx.compose.ui.graphics.Color?, annotatedStringBuilder: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \): com.wire.android.ui.home.conversations.messages.item.SystemMessageContent +private fun com.wire.android.ui.home.conversations.messages.item.buildContent(expandable: kotlin.Boolean, learnMoreLinkResId: kotlin.Int?, learnMoreTextResId: kotlin.Int, iconResId: kotlin.Int, iconTintColor: androidx.compose.ui.graphics.Color?, iconSize: androidx.compose.ui.unit.Dp, additionalVerticalPaddings: androidx.compose.ui.unit.Dp, backgroundColor: androidx.compose.ui.graphics.Color?, annotatedStringBuilder: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \): com.wire.android.ui.home.conversations.messages.item.SystemMessageContent skippable: true restartable: true params: - expandable: STABLE (primitive type) - learnMoreLinkResId: STABLE (class with no mutable properties) + - learnMoreTextResId: STABLE (primitive type) - iconResId: STABLE (primitive type) - iconTintColor: STABLE (marked @Stable or @Immutable) - iconSize: STABLE (marked @Stable or @Immutable) @@ -11928,9 +11884,3 @@ public fun com.wire.android.util.ui.WireScrollableThemePreview(content: @[Compos params: - content: STABLE (composable function type) -@Composable -private fun com.wire.android.util.ui.createAnnotatedString(data: kotlin.collections.List): androidx.compose.ui.text.AnnotatedString - skippable: false - restartable: true - params: - - data: RUNTIME (requires runtime check) diff --git a/core/ui-common/build.gradle.kts b/core/ui-common/build.gradle.kts index dc62658bde..0379c07f79 100644 --- a/core/ui-common/build.gradle.kts +++ b/core/ui-common/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(libs.ktx.serialization) implementation(libs.bundlizer.core) implementation(libs.coroutines.android) + implementation(libs.ktx.immutableCollections) val composeBom = platform(libs.compose.bom) implementation(composeBom) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireCheckIcon.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireCheckIcon.kt index 06f235e1d7..1e643daf58 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireCheckIcon.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireCheckIcon.kt @@ -32,7 +32,7 @@ import com.wire.android.ui.theme.wireDimensions @Composable fun WireCheckIcon(modifier: Modifier = Modifier, @StringRes contentDescription: Int = R.string.content_description_check) { Icon( - painter = painterResource(id = R.drawable.ic_check_circle), + painter = painterResource(id = R.drawable.ic_check_circle_filled), contentDescription = stringResource(contentDescription), modifier = modifier.size(MaterialTheme.wireDimensions.wireIconButtonSize), tint = MaterialTheme.wireColorScheme.positive diff --git a/app/src/main/kotlin/com/wire/android/ui/common/WireDropDown.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDropDown.kt similarity index 87% rename from app/src/main/kotlin/com/wire/android/ui/common/WireDropDown.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDropDown.kt index df85986a12..ceaad83de8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/WireDropDown.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDropDown.kt @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2024 Wire Swiss GmbH + * Copyright (C) 2026 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -26,6 +26,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth @@ -62,8 +63,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize -import com.wire.android.R -import com.wire.android.ui.common.R as commonR import com.wire.android.ui.common.textfield.WireLabel import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.theme.wireColorScheme @@ -71,16 +70,19 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.EMPTY import com.wire.kalium.logic.data.conversation.CreateConversationParam +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList @Composable -internal fun WireDropDown( - items: List, +fun WireDropDown( + items: ImmutableList, label: String?, modifier: Modifier = Modifier, defaultItemIndex: Int = -1, selectedItemIndex: Int = defaultItemIndex, autoUpdateSelection: Boolean = true, showDefaultTextIndicator: Boolean = true, + showSelectionFieldWhenExpanded: Boolean = true, leadingCompose: @Composable ((index: Int) -> Unit)? = null, onChangeClickDescription: String = stringResource(R.string.content_description_change_it_label), placeholder: String = stringResource(R.string.wire_dropdown_placeholder), @@ -88,7 +90,7 @@ internal fun WireDropDown( ) { var expanded by remember { mutableStateOf(false) } var selectedIndex by remember(selectedItemIndex) { mutableStateOf(selectedItemIndex) } - var selectionFieldWidth by remember { mutableStateOf(Size.Zero) } + var selectionFieldSize by remember { mutableStateOf(Size.Zero) } val arrowRotation: Float by animateFloatAsState(if (expanded) 180f else 0f) val selectionText = if (selectedIndex != -1) { items[selectedIndex] + LocalContext.current.defaultTextIndicator( @@ -124,7 +126,7 @@ internal fun WireDropDown( .onGloballyPositioned { coordinates -> // This value is used to assign to // the DropDown the same width - selectionFieldWidth = coordinates.size.toSize() + selectionFieldSize = coordinates.size.toSize() } .clickable(onClickLabel = onChangeClickDescription) { expanded = true }, leadingCompose = leadingCompose, @@ -135,13 +137,14 @@ internal fun WireDropDown( MenuPopUp( shape = shape, - textFieldWidth = selectionFieldWidth, + textFieldSize = selectionFieldSize, expanded = expanded, borderColor = borderColor, leadingCompose = leadingCompose, selectedIndex = selectedIndex, items = items, showDefaultTextIndicator = showDefaultTextIndicator, + showSelectionField = showSelectionFieldWhenExpanded, defaultItemIndex = defaultItemIndex, selectionText = selectionText, arrowRotation = arrowRotation, @@ -159,13 +162,14 @@ internal fun WireDropDown( @Composable private fun MenuPopUp( shape: RoundedCornerShape, - textFieldWidth: Size, + textFieldSize: Size, expanded: Boolean, borderColor: Color, leadingCompose: @Composable ((index: Int) -> Unit)?, selectedIndex: Int, - items: List, + items: ImmutableList, showDefaultTextIndicator: Boolean, + showSelectionField: Boolean, defaultItemIndex: Int, selectionText: String, arrowRotation: Float, @@ -178,28 +182,30 @@ private fun MenuPopUp( // we want PopUp to cover the selection field, so we set this offset. // "- 8.dp" is because DropdownMenu has inner top padding, which can't be changed, // so without this additional 8.dp selection text will "jump" while opening/closing menu. - val popUpTopOffset = with(LocalDensity.current) { -textFieldWidth.height.toDp() - 8.dp } + val popUpTopOffset = with(LocalDensity.current) { -textFieldSize.height.toDp() - 8.dp } DropdownMenu( expanded = expanded, onDismissRequest = hidePopUp, offset = DpOffset(0.dp, popUpTopOffset), modifier = Modifier - .width(with(LocalDensity.current) { textFieldWidth.width.toDp() }) + .width(with(LocalDensity.current) { textFieldSize.width.toDp() }) .background(color = MaterialTheme.wireColorScheme.secondaryButtonEnabled) .border(width = 1.dp, color = borderColor, shape) .semantics { paneTitle = dropdownDescription } ) { - SelectionField( - leadingCompose = leadingCompose, - selectedIndex = selectedIndex, - text = selectionText, - arrowRotation = arrowRotation, - modifier = Modifier.clickable(onClickLabel = stringResource(R.string.content_description_close_dropdown)) { - hidePopUp() - } - ) + if (showSelectionField) { + SelectionField( + leadingCompose = leadingCompose, + selectedIndex = selectedIndex, + text = selectionText, + arrowRotation = arrowRotation, + modifier = Modifier.clickable(onClickLabel = stringResource(R.string.content_description_close_dropdown)) { + hidePopUp() + } + ) + } List(items.size) { index -> HorizontalDivider( @@ -232,12 +238,9 @@ private fun SelectionField( Row( modifier .padding( - start = dimensions().spacing16x, - end = dimensions().spacing16x, - top = dimensions().spacing12x, - bottom = dimensions().spacing12x + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing12x, ) - ) { leadingCompose?.let { LeadingIcon { it(selectedIndex) } @@ -255,7 +258,7 @@ private fun SelectionField( } ) Icon( - painter = painterResource(commonR.drawable.ic_expand_more), + painter = painterResource(R.drawable.ic_arrow_drop_down), contentDescription = null, tint = MaterialTheme.wireColorScheme.secondaryText, modifier = Modifier @@ -277,7 +280,7 @@ private fun DropdownItem( isSelected: Boolean, onClick: () -> Unit ) { - val selectLabel = stringResource(commonR.string.content_description_select_label) + val selectLabel = stringResource(R.string.content_description_select_label) val closeDropdownLabel = stringResource(R.string.content_description_close_dropdown) return DropdownMenuItem( text = { @@ -299,6 +302,10 @@ private fun DropdownItem( } }, onClick = onClick, + contentPadding = PaddingValues( + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing12x, + ), modifier = Modifier .semantics { if (isSelected) { @@ -333,7 +340,7 @@ private fun RowScope.LeadingIcon(convent: @Composable () -> Unit) { @Preview fun PreviewWireDropdownPreviewWithLabel() { WireDropDown( - items = CreateConversationParam.Protocol.entries.map { it.name }, + items = CreateConversationParam.Protocol.entries.map { it.name }.toImmutableList(), defaultItemIndex = 0, selectedItemIndex = 0, label = "Protocol", diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index 648da699a1..0011eeb3a8 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -95,6 +95,7 @@ fun WireTextField( onTap: (() -> Unit)? = null, testTag: String = String.EMPTY, validateKeyboardOptions: Boolean = true, + readOnly: Boolean = false, enabled: Boolean = state !is WireTextFieldState.Disabled, ) { if (validateKeyboardOptions) { @@ -150,7 +151,7 @@ fun WireTextField( inputTransformation = inputTransformation, outputTransformation = outputTransformation, scrollState = scrollState, - readOnly = state is WireTextFieldState.ReadOnly, + readOnly = readOnly, enabled = enabled, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), interactionSource = interactionSource, diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt index dd2b576f1a..092830d718 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -204,6 +205,8 @@ private fun InnerTextLayout( text = placeholderText, style = placeholderTextStyle, color = colors.placeholderColor(style).value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier .align(placeholderAlignment.toAlignment()) .clearAndSetSemantics {} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt index fce9a95cd6..d5d97a81ff 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt @@ -31,7 +31,6 @@ sealed class WireTextFieldState { data object Success : WireTextFieldState() data object Disabled : WireTextFieldState() - data object ReadOnly : WireTextFieldState() fun icon(): Int? = when (this) { is Error -> R.drawable.ic_error_outline diff --git a/core/ui-common/src/main/res/drawable/ic_check_circle_filled.xml b/core/ui-common/src/main/res/drawable/ic_check_circle_filled.xml new file mode 100644 index 0000000000..983b14ff4d --- /dev/null +++ b/core/ui-common/src/main/res/drawable/ic_check_circle_filled.xml @@ -0,0 +1,26 @@ + + + + diff --git a/core/ui-common/src/main/res/values-de/strings.xml b/core/ui-common/src/main/res/values-de/strings.xml index e2823d7ed6..59a2c81bf2 100644 --- a/core/ui-common/src/main/res/values-de/strings.xml +++ b/core/ui-common/src/main/res/values-de/strings.xml @@ -106,4 +106,9 @@ Abwählen Mehr anzeigen Weniger anzeigen + Bitte auswählen + Auswahlliste + Dropdown schließen + + " (Standard)" diff --git a/core/ui-common/src/main/res/values-es/strings.xml b/core/ui-common/src/main/res/values-es/strings.xml index 292fa33700..d4b3eec86f 100644 --- a/core/ui-common/src/main/res/values-es/strings.xml +++ b/core/ui-common/src/main/res/values-es/strings.xml @@ -21,4 +21,7 @@ + Seleccionar una opción + + " (predeterminado)" diff --git a/core/ui-common/src/main/res/values-hr/strings.xml b/core/ui-common/src/main/res/values-hr/strings.xml index 292fa33700..da940aa44d 100644 --- a/core/ui-common/src/main/res/values-hr/strings.xml +++ b/core/ui-common/src/main/res/values-hr/strings.xml @@ -21,4 +21,6 @@ + + Izaberi stavku diff --git a/core/ui-common/src/main/res/values-hu/strings.xml b/core/ui-common/src/main/res/values-hu/strings.xml index 292fa33700..9dc7a5193c 100644 --- a/core/ui-common/src/main/res/values-hu/strings.xml +++ b/core/ui-common/src/main/res/values-hu/strings.xml @@ -21,4 +21,10 @@ + megváltoztatás + Jelöljön ki egy elemet + Lenyitás + lenyíló elem összecsukása + + " (alapértelmezett)" diff --git a/core/ui-common/src/main/res/values-it/strings.xml b/core/ui-common/src/main/res/values-it/strings.xml index 292fa33700..d172592811 100644 --- a/core/ui-common/src/main/res/values-it/strings.xml +++ b/core/ui-common/src/main/res/values-it/strings.xml @@ -21,4 +21,7 @@ + Seleziona un elemento + + " (predefinito)" diff --git a/core/ui-common/src/main/res/values-pl/strings.xml b/core/ui-common/src/main/res/values-pl/strings.xml index 292fa33700..38baa9f69c 100644 --- a/core/ui-common/src/main/res/values-pl/strings.xml +++ b/core/ui-common/src/main/res/values-pl/strings.xml @@ -21,4 +21,7 @@ + Wybierz + + " (domyślnie)" diff --git a/core/ui-common/src/main/res/values-pt/strings.xml b/core/ui-common/src/main/res/values-pt/strings.xml index 292fa33700..40ca0d4901 100644 --- a/core/ui-common/src/main/res/values-pt/strings.xml +++ b/core/ui-common/src/main/res/values-pt/strings.xml @@ -21,4 +21,10 @@ + alterar isto + Selecione um item + Menu suspenso + fechar menu suspenso + + " (padrão)" diff --git a/core/ui-common/src/main/res/values-ru/strings.xml b/core/ui-common/src/main/res/values-ru/strings.xml index bf6149e557..782d444f92 100644 --- a/core/ui-common/src/main/res/values-ru/strings.xml +++ b/core/ui-common/src/main/res/values-ru/strings.xml @@ -114,4 +114,10 @@ отменить выбор Развернуть Свернуть + изменить + Выбрать элемент + Раскрывающийся список + закрыть раскрывающийся список + + " (по умолчанию)" diff --git a/core/ui-common/src/main/res/values-si/strings.xml b/core/ui-common/src/main/res/values-si/strings.xml index 292fa33700..1bf6d0ed70 100644 --- a/core/ui-common/src/main/res/values-si/strings.xml +++ b/core/ui-common/src/main/res/values-si/strings.xml @@ -21,4 +21,10 @@ + එය වෙනස් කරන්න + අථකයක් තෝරන්න + පතන + පතන ලැයිස්තුව වසන්න + + " (පෙරනිමි)" diff --git a/core/ui-common/src/main/res/values-tr/strings.xml b/core/ui-common/src/main/res/values-tr/strings.xml index 7171691c30..90d76fa527 100644 --- a/core/ui-common/src/main/res/values-tr/strings.xml +++ b/core/ui-common/src/main/res/values-tr/strings.xml @@ -33,4 +33,7 @@ + değiştir + Açılır liste + açılır listeyi kapat diff --git a/core/ui-common/src/main/res/values-uk/strings.xml b/core/ui-common/src/main/res/values-uk/strings.xml index 292fa33700..d82393e5e1 100644 --- a/core/ui-common/src/main/res/values-uk/strings.xml +++ b/core/ui-common/src/main/res/values-uk/strings.xml @@ -21,4 +21,7 @@ + Виберіть елемент + + " (за замовчуванням)" diff --git a/core/ui-common/src/main/res/values/strings.xml b/core/ui-common/src/main/res/values/strings.xml index c6b30d5517..7cee9f5d84 100644 --- a/core/ui-common/src/main/res/values/strings.xml +++ b/core/ui-common/src/main/res/values/strings.xml @@ -124,4 +124,12 @@ Show More Show Less Select + + + change it + Select an Item + Dropdown + close dropdown + + " (default)" diff --git a/core/ui-common/stability/ui-common-debug.stability b/core/ui-common/stability/ui-common-debug.stability index c4fab2d995..29c11c7f65 100644 --- a/core/ui-common/stability/ui-common-debug.stability +++ b/core/ui-common/stability/ui-common-debug.stability @@ -96,6 +96,16 @@ private fun com.wire.android.ui.common.DialogButtonsSection(dismissButtonPropert - optionButton2Properties: STABLE (class with no mutable properties) - buttonsHorizontalAlignment: STABLE (primitive type) +@Composable +private fun com.wire.android.ui.common.DropdownItem(text: kotlin.String, leadingCompose: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, isSelected: kotlin.Boolean, onClick: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - text: STABLE (String is immutable) + - leadingCompose: STABLE (composable function type) + - isSelected: STABLE (primitive type) + - onClick: STABLE (function type) + @Composable public fun com.wire.android.ui.common.HandleActions(actionsFlow: kotlinx.coroutines.flow.Flow, onAction: kotlin.Function1): kotlin.Unit skippable: false @@ -111,6 +121,13 @@ public fun com.wire.android.ui.common.Icon(modifier: androidx.compose.ui.Modifie params: - modifier: STABLE (marked @Stable or @Immutable) +@Composable +private fun com.wire.android.ui.common.LeadingIcon(convent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit + skippable: true + restartable: true + params: + - convent: STABLE (composable function type) + @Composable public fun com.wire.android.ui.common.LoadingWireTabRow(modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -126,6 +143,26 @@ public fun com.wire.android.ui.common.MembershipQualifierLabel(membership: com.w - membership: STABLE (marked @Stable or @Immutable) - modifier: STABLE (marked @Stable or @Immutable) +@Composable +private fun com.wire.android.ui.common.MenuPopUp(shape: androidx.compose.foundation.shape.RoundedCornerShape, textFieldSize: androidx.compose.ui.geometry.Size, expanded: kotlin.Boolean, borderColor: androidx.compose.ui.graphics.Color, leadingCompose: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, selectedIndex: kotlin.Int, items: kotlinx.collections.immutable.ImmutableList, showDefaultTextIndicator: kotlin.Boolean, showSelectionField: kotlin.Boolean, defaultItemIndex: kotlin.Int, selectionText: kotlin.String, arrowRotation: kotlin.Float, hidePopUp: kotlin.Function0, onChange: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit + skippable: true + restartable: true + params: + - shape: STABLE (known stable type) + - textFieldSize: STABLE (marked @Stable or @Immutable) + - expanded: STABLE (primitive type) + - borderColor: STABLE (marked @Stable or @Immutable) + - leadingCompose: STABLE (composable function type) + - selectedIndex: STABLE (primitive type) + - items: STABLE (known stable type) + - showDefaultTextIndicator: STABLE (primitive type) + - showSelectionField: STABLE (primitive type) + - defaultItemIndex: STABLE (primitive type) + - selectionText: STABLE (String is immutable) + - arrowRotation: STABLE (primitive type) + - hidePopUp: STABLE (function type) + - onChange: STABLE (function type) + @Composable public fun com.wire.android.ui.common.MoreOptionIcon(onButtonClicked: kotlin.Function0, modifier: androidx.compose.ui.Modifier, state: com.wire.android.ui.common.button.WireButtonState, contentDescription: kotlin.Int): kotlin.Unit skippable: true @@ -186,6 +223,17 @@ public fun com.wire.android.ui.common.SearchBarInput(placeholderText: kotlin.Str - semanticDescription: STABLE (class with no mutable properties) - onTap: STABLE (function type) +@Composable +private fun com.wire.android.ui.common.SelectionField(leadingCompose: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, selectedIndex: kotlin.Int, text: kotlin.String, arrowRotation: kotlin.Float, modifier: androidx.compose.ui.Modifier): kotlin.Unit + skippable: true + restartable: true + params: + - leadingCompose: STABLE (composable function type) + - selectedIndex: STABLE (primitive type) + - text: STABLE (String is immutable) + - arrowRotation: STABLE (primitive type) + - modifier: STABLE (marked @Stable or @Immutable) + @Composable public fun com.wire.android.ui.common.StatusBox(statusText: kotlin.String, modifier: androidx.compose.ui.Modifier, textColor: androidx.compose.ui.graphics.Color, badgeColor: androidx.compose.ui.graphics.Color, withBorder: kotlin.Boolean): kotlin.Unit skippable: true @@ -339,6 +387,24 @@ public fun com.wire.android.ui.common.WireDialogContent(title: kotlin.String?, m - centerContent: STABLE (primitive type) - content: STABLE (composable function type) +@Composable +public fun com.wire.android.ui.common.WireDropDown(items: kotlinx.collections.immutable.ImmutableList, label: kotlin.String?, modifier: androidx.compose.ui.Modifier, defaultItemIndex: kotlin.Int, selectedItemIndex: kotlin.Int, autoUpdateSelection: kotlin.Boolean, showDefaultTextIndicator: kotlin.Boolean, showSelectionFieldWhenExpanded: kotlin.Boolean, leadingCompose: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, onChangeClickDescription: kotlin.String, placeholder: kotlin.String, onSelected: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit + skippable: true + restartable: true + params: + - items: STABLE (known stable type) + - label: STABLE (class with no mutable properties) + - modifier: STABLE (marked @Stable or @Immutable) + - defaultItemIndex: STABLE (primitive type) + - selectedItemIndex: STABLE (primitive type) + - autoUpdateSelection: STABLE (primitive type) + - showDefaultTextIndicator: STABLE (primitive type) + - showSelectionFieldWhenExpanded: STABLE (primitive type) + - leadingCompose: STABLE (composable function type) + - onChangeClickDescription: STABLE (String is immutable) + - placeholder: STABLE (String is immutable) + - onSelected: STABLE (function type) + @Composable private fun com.wire.android.ui.common.WireIndicator(modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -1850,7 +1916,7 @@ public fun com.wire.android.ui.common.textfield.WirePasswordTextField(textState: - testTag: STABLE (String is immutable) @Composable -public fun com.wire.android.ui.common.textfield.WireTextField(textState: androidx.compose.foundation.text.input.TextFieldState, modifier: androidx.compose.ui.Modifier, inputModifier: androidx.compose.ui.Modifier, placeholderText: kotlin.String?, labelText: kotlin.String?, labelMandatoryIcon: kotlin.Boolean, descriptionText: kotlin.String?, semanticDescription: kotlin.String?, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, trailingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, state: com.wire.android.ui.common.textfield.WireTextFieldState, autoFillType: com.wire.android.ui.common.textfield.WireAutoFillType, lineLimits: androidx.compose.foundation.text.input.TextFieldLineLimits, inputTransformation: androidx.compose.foundation.text.input.InputTransformation, outputTransformation: androidx.compose.foundation.text.input.OutputTransformation?, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, onKeyboardAction: androidx.compose.foundation.text.input.KeyboardActionHandler?, scrollState: androidx.compose.foundation.ScrollState, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource, textStyle: androidx.compose.ui.text.TextStyle, placeholderTextStyle: androidx.compose.ui.text.TextStyle, placeholderAlignment: androidx.compose.ui.Alignment.Horizontal, inputMinHeight: androidx.compose.ui.unit.Dp, shape: androidx.compose.ui.graphics.Shape, colors: com.wire.android.ui.common.textfield.WireTextFieldColors, onSelectedLineIndexChanged: kotlin.Function1, onLineBottomYCoordinateChanged: kotlin.Function1, onInputSizeChanged: kotlin.Function1, onTap: kotlin.Function0?, testTag: kotlin.String, validateKeyboardOptions: kotlin.Boolean, enabled: kotlin.Boolean): kotlin.Unit +public fun com.wire.android.ui.common.textfield.WireTextField(textState: androidx.compose.foundation.text.input.TextFieldState, modifier: androidx.compose.ui.Modifier, inputModifier: androidx.compose.ui.Modifier, placeholderText: kotlin.String?, labelText: kotlin.String?, labelMandatoryIcon: kotlin.Boolean, descriptionText: kotlin.String?, semanticDescription: kotlin.String?, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, trailingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, state: com.wire.android.ui.common.textfield.WireTextFieldState, autoFillType: com.wire.android.ui.common.textfield.WireAutoFillType, lineLimits: androidx.compose.foundation.text.input.TextFieldLineLimits, inputTransformation: androidx.compose.foundation.text.input.InputTransformation, outputTransformation: androidx.compose.foundation.text.input.OutputTransformation?, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, onKeyboardAction: androidx.compose.foundation.text.input.KeyboardActionHandler?, scrollState: androidx.compose.foundation.ScrollState, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource, textStyle: androidx.compose.ui.text.TextStyle, placeholderTextStyle: androidx.compose.ui.text.TextStyle, placeholderAlignment: androidx.compose.ui.Alignment.Horizontal, inputMinHeight: androidx.compose.ui.unit.Dp, shape: androidx.compose.ui.graphics.Shape, colors: com.wire.android.ui.common.textfield.WireTextFieldColors, onSelectedLineIndexChanged: kotlin.Function1, onLineBottomYCoordinateChanged: kotlin.Function1, onInputSizeChanged: kotlin.Function1, onTap: kotlin.Function0?, testTag: kotlin.String, validateKeyboardOptions: kotlin.Boolean, readOnly: kotlin.Boolean, enabled: kotlin.Boolean): kotlin.Unit skippable: true restartable: true params: @@ -1885,6 +1951,7 @@ public fun com.wire.android.ui.common.textfield.WireTextField(textState: android - onTap: STABLE (function type) - testTag: STABLE (String is immutable) - validateKeyboardOptions: STABLE (primitive type) + - readOnly: STABLE (primitive type) - enabled: STABLE (primitive type) @Composable diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/model/MeetingItem.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/model/MeetingItem.kt index 1cac7e3e3c..2e7aaf2f96 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/model/MeetingItem.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/model/MeetingItem.kt @@ -19,6 +19,7 @@ package com.wire.android.feature.meetings.model import android.os.Parcelable import androidx.annotation.StringRes +import androidx.compose.runtime.Stable import com.wire.android.feature.meetings.R import com.wire.android.model.UserAvatarData import com.wire.kalium.logic.data.id.ConversationId @@ -29,17 +30,20 @@ import kotlin.time.Duration sealed interface MeetingListItem +@Stable data class MeetingItem( val meetingId: String, val conversationId: ConversationId, val belongingType: BelongingType, - val repeatingInterval: RepeatingInterval?, // null for one-time meetings + val repeatingInterval: RepeatingInterval, val title: String, val status: Status, val selfRole: SelfRole, ) : MeetingListItem { + @Stable @Parcelize enum class RepeatingInterval(@StringRes val nameResId: Int) : Parcelable { + Never(R.string.meeting_repeating_never), Daily(R.string.meeting_repeating_daily), Weekly(R.string.meeting_repeating_weekly), BiWeekly(R.string.meeting_repeating_biweekly), @@ -47,6 +51,7 @@ data class MeetingItem( Annually(R.string.meeting_repeating_annually) } + @Stable sealed interface BelongingType { data class Group(val name: String) : BelongingType data class Channel(val name: String, val isPrivateChannel: Boolean) : BelongingType @@ -54,8 +59,10 @@ data class MeetingItem( data class Groupless(val avatars: ImmutableList, val limit: Int = GROUPLESS_AVATARS_LIMIT) : BelongingType } + @Stable sealed interface Status { val startTime: Instant + data class Scheduled( override val startTime: Instant, // scheduled start time val endTime: Instant, // scheduled end time @@ -75,11 +82,13 @@ data class MeetingItem( } } + @Stable data class OngoingCallStatus( val currentCallStartedTime: Instant, // time when the current call started (there can be many calls one after another in a meeting) val isSelfUserAttending: Boolean // is the current user attending the ongoing call ) + @Stable enum class SelfRole { Admin, Member } } 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 6fb88d571c..74ce997b18 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 @@ -42,5 +42,8 @@ class MeetingsViewModelFactory @Inject constructor( internal fun meetingOptionsMenuViewModel() = MeetingOptionsMenuViewModelImpl(getMeeting = getMeeting) - internal fun newMeetingViewModel(savedStateHandle: SavedStateHandle) = NewMeetingViewModelImpl(savedStateHandle) + internal fun newMeetingViewModel(savedStateHandle: SavedStateHandle) = NewMeetingViewModelImpl( + savedStateHandle = savedStateHandle, + currentTimeProvider = currentTimeProvider + ) } 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 51bafd687b..483df3f3e2 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 @@ -20,23 +20,29 @@ package com.wire.android.feature.meetings.ui.create import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -49,9 +55,15 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign import com.ramcosta.composedestinations.generated.meetings.destinations.NewMeetingParticipantsScreenDestination import com.wire.android.feature.meetings.R +import com.wire.android.feature.meetings.model.MeetingItem import com.wire.android.feature.meetings.ui.create.NewMeetingViewModel.Companion.MEETING_NAME_MAX_COUNT import com.wire.android.feature.meetings.ui.util.PreviewMultipleThemes import com.wire.android.model.Contact @@ -60,11 +72,18 @@ import com.wire.android.navigation.WireNavigator import com.wire.android.navigation.annotation.features.meetings.WireNewMeetingDestination 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.WireDropDown import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.datetime.FutureSelectableDates +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.rememberTopBarElevationState import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.textfield.DefaultEmailDone @@ -75,13 +94,25 @@ import com.wire.android.ui.common.textfield.maxLengthWithCallback import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.typography +import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions +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.user.ConnectionState import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentSet +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.hours import com.wire.android.ui.common.R as commonR @WireNewMeetingDestination( @@ -103,9 +134,10 @@ fun NewMeetingScreen( onParticipantsClicked = { navigator.navigate(NavigationCommand(NewMeetingParticipantsScreenDestination)) }, - onCreateClicked = { - newMeetingViewModel.createMeeting() - } + onCreateClicked = newMeetingViewModel::createMeeting, + onStartTimeChanged = newMeetingViewModel::updateStartTime, + onEndTimeChanged = newMeetingViewModel::updateEndTime, + onRepeatingIntervalChanged = newMeetingViewModel::updateRepeatingInterval, ) HandleActions(newMeetingViewModel.actions) { action -> @@ -124,12 +156,16 @@ fun NewMeetingContent( onBackPressed: () -> Unit = {}, onParticipantsClicked: () -> Unit = {}, onCreateClicked: () -> Unit = {}, + onStartTimeChanged: (startTime: Instant) -> Unit = {}, + onEndTimeChanged: (endTime: Instant) -> Unit = {}, + onRepeatingIntervalChanged: (interval: MeetingItem.RepeatingInterval) -> Unit = {}, ) { + val scrollState = rememberScrollState() WireScaffold( modifier = modifier, topBar = { WireCenterAlignedTopAppBar( - elevation = dimensions().spacing0x, + elevation = scrollState.rememberTopBarElevationState().value, title = stringResource(type.title), onNavigationPressed = onBackPressed, navigationIconType = NavigationIconType.Back( @@ -142,16 +178,41 @@ fun NewMeetingContent( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .padding(internalPadding) + .verticalScroll(scrollState) .padding( - top = dimensions().spacing24x, - start = dimensions().spacing16x, - end = dimensions().spacing16x, + vertical = dimensions().spacing24x, + horizontal = dimensions().spacing16x, ) ) { TitleInput( titleState = titleState, titleError = state.titleError, ) + if (type == NewMeetingType.Schedule) { + VerticalSpace.x24() + TimeInput( + time = state.startTime, + timeError = state.startTimeError, + onTimeChanged = onStartTimeChanged, + label = stringResource(R.string.new_meeting_starts_input_label), + datePlaceholder = stringResource(R.string.new_meeting_start_date_input_placeholder), + timePlaceholder = stringResource(R.string.new_meeting_start_time_input_placeholder), + ) + VerticalSpace.x8() + TimeInput( + time = state.endTime, + timeError = state.endTimeError, + onTimeChanged = onEndTimeChanged, + label = stringResource(R.string.new_meeting_ends_input_label), + datePlaceholder = stringResource(R.string.new_meeting_end_date_input_placeholder), + timePlaceholder = stringResource(R.string.new_meeting_end_time_input_placeholder), + ) + VerticalSpace.x8() + RepeatingIntervalDropDown( + repeatingInterval = state.repeatingInterval, + onRepeatingIntervalChanged = onRepeatingIntervalChanged, + ) + } VerticalSpace.x24() ParticipantsInput( participants = state.confirmedContacts, @@ -266,13 +327,14 @@ private fun ParticipantsInput( textFieldState.setTextAndPlaceCursorAtEnd(participants.joinToString(", ") { it.name }) } + val semanticDescription = stringResource(R.string.new_meeting_participants_input_placeholder) WireTextField( textState = textFieldState, placeholderText = stringResource(R.string.new_meeting_participants_input_placeholder), labelText = stringResource(R.string.new_meeting_participants_input_label).uppercase(), - semanticDescription = stringResource(R.string.new_meeting_participants_input_placeholder), keyboardOptions = KeyboardOptions.DefaultEmailDone, - state = WireTextFieldState.ReadOnly, + state = WireTextFieldState.Default, + readOnly = true, onInputSizeChanged = { innerTextWidthPx = it.width }, outputTransformation = truncationTransformation, onTap = onClick, @@ -282,20 +344,202 @@ private fun ParticipantsInput( contentDescription = null, tint = colorsScheme().onSurfaceVariant, modifier = Modifier - .padding(dimensions().spacing16x) + .padding(start = dimensions().spacing4x, end = dimensions().spacing16x) .size(dimensions().spacing16x) ) + }, + inputModifier = Modifier.clearAndSetSemantics { + contentDescription = semanticDescription + role = Role.Button } ) } +@Composable +private fun TimeInput( + time: Instant, + timeError: NewMeetingState.TimeError?, + onTimeChanged: (Instant) -> Unit, + label: String, + datePlaceholder: String, + timePlaceholder: String, +) { + val dateTextFieldState = rememberTextFieldState(DateAndTimeParsers.meetingDate(time)) + val timeTextFieldState = rememberTextFieldState(DateAndTimeParsers.meetingTime(time)) + val datePickerDialogState = rememberVisibilityState() + val timePickerDialogState = rememberVisibilityState() + + LaunchedEffect(time) { + dateTextFieldState.setTextAndPlaceCursorAtEnd(DateAndTimeParsers.meetingDate(time)) + timeTextFieldState.setTextAndPlaceCursorAtEnd(DateAndTimeParsers.meetingTime(time)) + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(-dimensions().spacing1x) // Pulls elements together by 1dp + ) { + WireTextField( + textState = dateTextFieldState, + placeholderText = datePlaceholder, + labelText = label.uppercase(), + keyboardOptions = KeyboardOptions.DefaultEmailDone, + state = if (timeError != null) WireTextFieldState.Error() else WireTextFieldState.Default, + readOnly = true, + shape = RoundedCornerShape( + topStart = dimensions().textFieldCornerSize, + bottomStart = dimensions().textFieldCornerSize, + topEnd = dimensions().spacing0x, + bottomEnd = dimensions().spacing0x, + ), + onTap = { + datePickerDialogState.show(Unit) + }, + trailingIcon = { + Icon( + painter = painterResource(commonR.drawable.ic_calendar), + contentDescription = null, + tint = colorsScheme().onSurfaceVariant, + modifier = Modifier + .padding(start = dimensions().spacing4x, end = dimensions().spacing16x) + .size(dimensions().spacing16x) + ) + }, + modifier = Modifier.weight(2f), + inputModifier = Modifier.clearAndSetSemantics { + contentDescription = datePlaceholder + role = Role.Button + } + ) + WireTextField( + textState = timeTextFieldState, + labelText = String.EMPTY, // Time input doesn't have a label as the date input's label already describes the field + keyboardOptions = KeyboardOptions.DefaultEmailDone, + state = if (timeError != null) WireTextFieldState.Error() else WireTextFieldState.Default, + readOnly = true, + shape = RoundedCornerShape( + topStart = dimensions().spacing0x, + bottomStart = dimensions().spacing0x, + topEnd = dimensions().textFieldCornerSize, + bottomEnd = dimensions().textFieldCornerSize, + ), + onTap = { + timePickerDialogState.show(Unit) + }, + trailingIcon = { + Icon( + painter = painterResource(commonR.drawable.ic_arrow_drop_down), + contentDescription = null, + tint = colorsScheme().onSurfaceVariant, + modifier = Modifier + .padding(start = dimensions().spacing4x, end = dimensions().spacing16x) + .size(dimensions().spacing16x) + ) + }, + modifier = Modifier.weight(1f), + inputModifier = Modifier.clearAndSetSemantics { + contentDescription = timePlaceholder + role = Role.Button + } + ) + } + AnimatedVisibility(visible = timeError != null) { + Text( + text = when (timeError) { + is NewMeetingState.TimeError.StartTimeInPastError -> stringResource(R.string.new_meeting_start_in_past_error) + is NewMeetingState.TimeError.EndTimeInPastError -> stringResource(R.string.new_meeting_end_in_past_error) + is NewMeetingState.TimeError.EndTimeBeforeStartTimeError -> stringResource(R.string.new_meeting_end_before_start_error) + else -> String.EMPTY + }, + style = MaterialTheme.wireTypography.label04, + textAlign = TextAlign.Start, + color = colorsScheme().error, + modifier = Modifier.padding(top = dimensions().spacing4x) + ) + } + } + VisibilityState(status = datePickerDialogState) { + WireDatePickerDialog( + title = datePlaceholder, + selectedDateMillis = time.toEpochMilliseconds(), + selectableDates = FutureSelectableDates(), + onDateSelected = { millis -> + if (millis != null) { + val timeZone = TimeZone.currentSystemDefault() + val timeDateTime = time.toLocalDateTime(timeZone) + val dateDateTime = Instant.fromEpochMilliseconds(millis).toLocalDateTime(timeZone) + val combinedDateTime = LocalDateTime( + year = dateDateTime.year, + monthNumber = dateDateTime.monthNumber, + dayOfMonth = dateDateTime.dayOfMonth, + hour = timeDateTime.hour, + minute = timeDateTime.minute, + second = 0, + nanosecond = 0 + ) + onTimeChanged(combinedDateTime.toInstant(timeZone)) + datePickerDialogState.dismiss() + } + }, + onDismiss = datePickerDialogState::dismiss, + ) + } + VisibilityState(status = timePickerDialogState) { + WireTimePickerDialog( + title = timePlaceholder, + selectedTime = time.toEpochMilliseconds().asTimePickerResult(), + onTimeSelected = { timePickerResult -> + val timeZone = TimeZone.currentSystemDefault() + val dateDateTime = time.toLocalDateTime(timeZone) + val combinedDateTime = LocalDateTime( + year = dateDateTime.year, + monthNumber = dateDateTime.monthNumber, + dayOfMonth = dateDateTime.dayOfMonth, + hour = timePickerResult.hour, + minute = timePickerResult.minute, + second = 0, + nanosecond = 0 + ) + onTimeChanged(combinedDateTime.toInstant(timeZone)) + timePickerDialogState.dismiss() + }, + onDismiss = timePickerDialogState::dismiss, + ) + } +} + +@Composable +private fun RepeatingIntervalDropDown( + repeatingInterval: MeetingItem.RepeatingInterval, + onRepeatingIntervalChanged: (MeetingItem.RepeatingInterval) -> Unit, + items: List = MeetingItem.RepeatingInterval.entries, +) { + val resources = LocalResources.current + WireDropDown( + items = remember(resources) { + items.map { resources.getString(it.nameResId) }.toImmutableList() + }, + defaultItemIndex = items.indexOf(repeatingInterval), + label = stringResource(R.string.new_meeting_repeats_input_label).uppercase(), + autoUpdateSelection = false, + showDefaultTextIndicator = false, + showSelectionFieldWhenExpanded = false, + onChangeClickDescription = stringResource(R.string.content_description_new_meeting_repeating_options) + ) { selectedIndex -> + onRepeatingIntervalChanged(items[selectedIndex]) + } +} + @PreviewMultipleThemes @Composable fun PreviewNewMeetingScreen_MeetNow() = WireTheme { NewMeetingContent( titleState = rememberTextFieldState("Meeting with 9 users"), type = NewMeetingType.MeetNow, - state = NewMeetingState(confirmedContacts = buildContacts(names.size), continueButtonEnabled = true), + state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( + confirmedContacts = buildContacts(names.size), + continueButtonEnabled = true, + ), ) } @@ -305,7 +549,11 @@ fun PreviewNewMeetingScreen_Schedule() = WireTheme { NewMeetingContent( titleState = rememberTextFieldState(), type = NewMeetingType.Schedule, - state = NewMeetingState(), + state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( + startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()), + endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()).plus(1.hours), + repeatingInterval = MeetingItem.RepeatingInterval.Weekly, + ), ) } 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 326d95c21e..8e8e9d078d 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 @@ -25,18 +25,31 @@ 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.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.model.Contact 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 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.launch +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.hours interface NewMeetingViewModel : ActionsManager { + val currentTimeProvider: CurrentTimeProvider val type: NewMeetingType val titleTextState: TextFieldState val state: NewMeetingState @@ -44,6 +57,9 @@ interface NewMeetingViewModel : ActionsManager { fun updateSelectedContact(selected: Boolean, contact: Contact) {} fun confirmSelectedContacts() {} fun resetSelectedContacts() {} + fun updateStartTime(startTime: Instant) {} + fun updateEndTime(endTime: Instant) {} + fun updateRepeatingInterval(interval: MeetingItem.RepeatingInterval) {} fun createMeeting() {} companion object { @@ -54,25 +70,28 @@ interface NewMeetingViewModel : ActionsManager { class NewMeetingViewModelPreview( override val type: NewMeetingType ) : NewMeetingViewModel { + override val currentTimeProvider: CurrentTimeProvider = CurrentTimeProvider.Preview override val titleTextState: TextFieldState = TextFieldState() - override val state: NewMeetingState = NewMeetingState() + override val state: NewMeetingState = initialState(currentTimeProvider) } class NewMeetingViewModelImpl( - savedStateHandle: SavedStateHandle + savedStateHandle: SavedStateHandle, + override val currentTimeProvider: CurrentTimeProvider, ) : ActionsViewModel(), NewMeetingViewModel { val navArgs: NewMeetingNavArgs = savedStateHandle.navArgs() override val type: NewMeetingType = navArgs.type override val titleTextState: TextFieldState = TextFieldState() - override var state: NewMeetingState by mutableStateOf(NewMeetingState()) + override var state: NewMeetingState by mutableStateOf(initialState(currentTimeProvider)) private set init { viewModelScope.launch { - titleTextState.textAsFlow().collectLatest { - if (state.titleError != null) validateTitle() - validateContinueButton() - } + titleTextState.textAsFlow() + .drop(1) // drop initial value to avoid showing error on start + .collectLatest { + validateTitle() + } } } @@ -93,8 +112,18 @@ class NewMeetingViewModelImpl( state = state.copy(selectedContacts = state.confirmedContacts) } - private fun validateContinueButton() { - state = state.copy(continueButtonEnabled = titleTextState.text.isNotEmpty()) + override fun updateStartTime(startTime: Instant) { + state = state.copy(startTime = startTime) + validateStartAndEndTime() + } + + override fun updateEndTime(endTime: Instant) { + state = state.copy(endTime = endTime) + validateStartAndEndTime() + } + + override fun updateRepeatingInterval(interval: MeetingItem.RepeatingInterval) { + state = state.copy(repeatingInterval = interval) } private fun validateTitle(): Boolean { @@ -104,30 +133,93 @@ class NewMeetingViewModelImpl( titleTextState.text.length > MEETING_NAME_MAX_COUNT -> NewMeetingState.TitleError.TitleExceedsLimitError else -> null } - ) + ).withContinueButtonState() return state.titleError == null } + private fun validateStartAndEndTime(): Boolean { + 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 + } + + private fun NewMeetingState.withContinueButtonState(): NewMeetingState = copy( + continueButtonEnabled = titleTextState.text.isNotEmpty() && + titleError == null && + startTimeError == null && + endTimeError == null + ) + override fun createMeeting() { - if (validateTitle()) { + val titleValid = validateTitle() + val startAndEndTimeValid = validateStartAndEndTime() + if (titleValid && startAndEndTimeValid) { // TODO implement meeting creation sendAction(NewMeetingViewActions.Success) } } } +internal fun getNextFullHour(now: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant { + val localNow = now.toLocalDateTime(timeZone) + val hasPassedTime = localNow.minute > 0 || localNow.second > 0 || localNow.nanosecond > 0 + val targetDateTime = if (hasPassedTime) { + val futureHour = now.plus(1, DateTimeUnit.HOUR, timeZone) + val localFuture = futureHour.toLocalDateTime(timeZone) + LocalDateTime( + year = localFuture.year, + monthNumber = localFuture.monthNumber, + dayOfMonth = localFuture.dayOfMonth, + hour = localFuture.hour, + minute = 0, + second = 0, + nanosecond = 0 + ) + } else { + localNow + } + return targetDateTime.toInstant(timeZone) +} + @Stable data class NewMeetingState( val selectedContacts: ImmutableSet = persistentSetOf(), val confirmedContacts: ImmutableSet = persistentSetOf(), val continueButtonEnabled: Boolean = false, val titleError: TitleError? = null, + val startTime: Instant, + val startTimeError: TimeError? = null, + val endTime: Instant, + val endTimeError: TimeError? = null, + val repeatingInterval: MeetingItem.RepeatingInterval = MeetingItem.RepeatingInterval.Never, ) { @Stable sealed interface TitleError { data object TitleEmptyError : TitleError data object TitleExceedsLimitError : TitleError } + + sealed interface TimeError { + data object StartTimeInPastError : TimeError + data object EndTimeInPastError : TimeError + data object EndTimeBeforeStartTimeError : TimeError + } + + companion object { + fun initialState(currentTimeProvider: CurrentTimeProvider): NewMeetingState { + val startTime = getNextFullHour(currentTimeProvider()) + return NewMeetingState(startTime = startTime, endTime = startTime.plus(1.hours)) + } + } } sealed interface NewMeetingViewActions { diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingItem.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingItem.kt index 98539b635f..374e94e08d 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingItem.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingItem.kt @@ -198,14 +198,14 @@ private fun MeetingOngoingDurationTimeSublineText(startedTime: Instant) { } @Composable -private fun RepeatingIntervalInfoLabel(repeatingInterval: RepeatingInterval?) { - repeatingInterval?.let { +private fun RepeatingIntervalInfoLabel(repeatingInterval: RepeatingInterval) { + if (repeatingInterval != RepeatingInterval.Never) { WireItemLabel(text = stringResource(repeatingInterval.nameResId), textStyle = typography().label01) } } @Composable -private fun MeetingTimeInfoRow(status: Status, repeatingInterval: RepeatingInterval?) { +private fun MeetingTimeInfoRow(status: Status, repeatingInterval: RepeatingInterval) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(dimensions().spacing3x) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/mock/MeetingItemMocks.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/mock/MeetingItemMocks.kt index c210b84217..a1f71858da 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/mock/MeetingItemMocks.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/mock/MeetingItemMocks.kt @@ -53,7 +53,7 @@ val CurrentTimeProvider.endedPrivateChannelMeeting conversationId = ConversationId("cid1", "domain"), title = "Ended Private Channel Meeting", belongingType = BelongingType.Channel(name = "Private Channel Name", isPrivateChannel = true), - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, selfRole = MeetingItem.SelfRole.Admin, status = Status.Ended( startTime = currentTime().fullMinutes().minus(1.days).minus(120.minutes), @@ -67,7 +67,7 @@ val CurrentTimeProvider.ongoingAttendingOneOnOneMeeting conversationId = ConversationId("cid2", "domain"), title = "Ongoing Attending 1:1 Meeting", belongingType = BelongingType.OneOnOne(username = "John Doe", avatar = UserAvatarData()), - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, selfRole = MeetingItem.SelfRole.Admin, status = Status.Ongoing( startTime = currentTime().fullMinutes().minus(15.minutes), @@ -94,7 +94,7 @@ val CurrentTimeProvider.grouplessOngoingMeeting meetingId = "id3", conversationId = ConversationId("cid3", "domain"), title = "Groupless Ongoing Meeting", - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, belongingType = BelongingType.Groupless(avatars = avatars, limit = 5), selfRole = MeetingItem.SelfRole.Admin, status = Status.Ongoing( @@ -108,7 +108,7 @@ val CurrentTimeProvider.scheduledChannelMeetingStartingSoon meetingId = "id4", conversationId = ConversationId("cid4", "domain"), title = "Scheduled Channel Meeting Starting Soon", - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, belongingType = BelongingType.Channel(name = "Channel Name", isPrivateChannel = false), selfRole = MeetingItem.SelfRole.Admin, status = Status.Scheduled( @@ -138,7 +138,7 @@ val CurrentTimeProvider.pastMeetingMocks meetingId = "past1", conversationId = ConversationId("cid", "domain"), title = "Ended Groupless Meeting", - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, belongingType = BelongingType.Groupless(avatars = avatars, limit = 5), selfRole = MeetingItem.SelfRole.Admin, status = Status.Ended( @@ -150,7 +150,7 @@ val CurrentTimeProvider.pastMeetingMocks meetingId = "past2", conversationId = ConversationId("cid", "domain"), title = "Ended Channel Meeting", - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, belongingType = BelongingType.Channel(name = "Channel Name", isPrivateChannel = false), selfRole = MeetingItem.SelfRole.Admin, status = Status.Ended( @@ -162,7 +162,7 @@ val CurrentTimeProvider.pastMeetingMocks meetingId = "past3", conversationId = ConversationId("cid", "domain"), title = "Ended 1:1 Meeting", - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, belongingType = BelongingType.OneOnOne(username = "John Doe", avatar = UserAvatarData()), selfRole = MeetingItem.SelfRole.Admin, status = Status.Ended( @@ -174,7 +174,7 @@ val CurrentTimeProvider.pastMeetingMocks meetingId = "past4", conversationId = ConversationId("cid", "domain"), title = "Ended Group Meeting", - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, belongingType = BelongingType.Group(name = "Group Name"), selfRole = MeetingItem.SelfRole.Admin, status = Status.Ended( @@ -186,7 +186,7 @@ val CurrentTimeProvider.pastMeetingMocks meetingId = "past5", conversationId = ConversationId("cid", "domain"), title = "Ended Channel Meeting", - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, belongingType = BelongingType.Channel(name = "Channel Name", isPrivateChannel = true), selfRole = MeetingItem.SelfRole.Admin, status = Status.Ended( @@ -198,7 +198,7 @@ val CurrentTimeProvider.pastMeetingMocks meetingId = "past6", conversationId = ConversationId("cid", "domain"), title = "Ended Groupless Meeting", - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, belongingType = BelongingType.Groupless(avatars = avatars.take(2).toPersistentList(), limit = 5), selfRole = MeetingItem.SelfRole.Admin, status = Status.Scheduled( @@ -242,7 +242,7 @@ val CurrentTimeProvider.nextMeetingMocks meetingId = "next4", conversationId = ConversationId("cid", "domain"), title = "Scheduled Groupless Meeting", - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, belongingType = BelongingType.Groupless(avatars = avatars.take(2).toPersistentList(), limit = 5), selfRole = MeetingItem.SelfRole.Admin, status = Status.Scheduled( @@ -330,7 +330,7 @@ data class Meeting( val title: String, val startTime: Instant, val endTime: Instant?, - val repeatingInterval: MeetingItem.RepeatingInterval?, + val repeatingInterval: MeetingItem.RepeatingInterval, val ongoingCallStatus: MeetingItem.OngoingCallStatus?, val selfRole: SelfRole, ) diff --git a/features/meetings/src/main/res/values/strings.xml b/features/meetings/src/main/res/values/strings.xml index 5c54be3ee3..052bd6171c 100644 --- a/features/meetings/src/main/res/values/strings.xml +++ b/features/meetings/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ NEXT PAST + Never Daily Weekly Biweekly @@ -64,6 +65,18 @@ Close select participants view Please enter a meeting name Meeting name should not exceed 64 characters + Starts + Select start date + Select start time + Ends + Select end date + Select end time + Start time cannot be in the past + End time cannot be in the past + End time cannot be before start time + Repeats + Select repeat option + Allow guests +%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 new file mode 100644 index 0000000000..5c12e1b0ad --- /dev/null +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt @@ -0,0 +1,291 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.meetings.ui.create + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +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.model.MeetingItem +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.user.ConnectionState +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.datetime.Instant +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 +import kotlin.time.Duration.Companion.hours + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class, SnapshotExtension::class) +class NewMeetingViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @BeforeEach + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun givenScheduleTypeAndCurrentTime_whenViewModelIsCreated_thenStateIsInitialized() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val (_, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(NewMeetingType.Schedule) + .withCurrentTimeProvider { currentTime } + ) + + assertEquals(NewMeetingType.Schedule, viewModel.type) + assertEquals(currentTime, viewModel.state.startTime) + assertEquals(currentTime + 1.hours, viewModel.state.endTime) + assertFalse(viewModel.state.continueButtonEnabled) + assertNull(viewModel.state.titleError) + assertNull(viewModel.state.startTimeError) + assertNull(viewModel.state.endTimeError) + } + + @Test + fun givenSelectedContacts_whenContactsAreConfirmedAndReset_thenStateKeepsConfirmedContacts() = runTest(dispatcher) { + val contact = contact("contact-1") + val otherContact = contact("contact-2") + val (_, viewModel) = arrangeViewModel() + + viewModel.updateSelectedContact(selected = true, contact = contact) + viewModel.updateSelectedContact(selected = true, contact = otherContact) + assertEquals(setOf(contact, otherContact), viewModel.state.selectedContacts.toSet()) + assertEquals(emptySet(), viewModel.state.confirmedContacts.toSet()) + + viewModel.confirmSelectedContacts() + assertEquals(setOf(contact, otherContact), viewModel.state.confirmedContacts.toSet()) + + viewModel.updateSelectedContact(selected = false, contact = otherContact) + assertEquals(setOf(contact), viewModel.state.selectedContacts.toSet()) + + viewModel.resetSelectedContacts() + assertEquals(setOf(contact, otherContact), viewModel.state.selectedContacts.toSet()) + } + + @Test + fun givenRepeatingInterval_whenIntervalIsUpdated_thenStateIsUpdated() = runTest(dispatcher) { + val (_, viewModel) = arrangeViewModel() + + viewModel.updateRepeatingInterval(MeetingItem.RepeatingInterval.Weekly) + + assertEquals(MeetingItem.RepeatingInterval.Weekly, viewModel.state.repeatingInterval) + } + + @Test + fun givenInitialEmptyTitle_whenViewModelIsCreated_thenTitleErrorIsNotShownAndContinueIsDisabled() = runTest(dispatcher) { + val (_, viewModel) = arrangeViewModel() + + assertNull(viewModel.state.titleError) + assertFalse(viewModel.state.continueButtonEnabled) + } + + @Test + fun givenValidTitle_whenTitleChanges_thenContinueIsEnabled() = runTest(dispatcher) { + val (_, viewModel) = arrangeViewModel() + + enterTitle(viewModel, "Weekly sync") + + assertNull(viewModel.state.titleError) + assertEquals(true, viewModel.state.continueButtonEnabled) + } + + @Test + fun givenTitleIsClearedAfterInput_whenTitleChanges_thenEmptyTitleErrorIsShown() = runTest(dispatcher) { + val (_, viewModel) = arrangeViewModel() + + enterTitle(viewModel, "Weekly sync") + enterTitle(viewModel, "") + + assertEquals(NewMeetingState.TitleError.TitleEmptyError, viewModel.state.titleError) + assertFalse(viewModel.state.continueButtonEnabled) + } + + @Test + fun givenTitleExceedsLimit_whenTitleChanges_thenTitleExceedsLimitErrorIsShown() = runTest(dispatcher) { + val (_, viewModel) = arrangeViewModel() + + enterTitle(viewModel, "a".repeat(NewMeetingViewModel.MEETING_NAME_MAX_COUNT + 1)) + + assertEquals(NewMeetingState.TitleError.TitleExceedsLimitError, viewModel.state.titleError) + assertFalse(viewModel.state.continueButtonEnabled) + } + + @Test + fun givenStartTimeInPast_whenStartTimeChanges_thenStartTimeInPastErrorIsShown() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val (_, viewModel) = arrangeViewModel(Arrangement(dispatcher).withCurrentTimeProvider { currentTime }) + + enterTitle(viewModel, "Weekly sync") + viewModel.updateStartTime(currentTime - 1.hours) + advanceUntilIdle() + + assertEquals(NewMeetingState.TimeError.StartTimeInPastError, viewModel.state.startTimeError) + assertFalse(viewModel.state.continueButtonEnabled) + } + + @Test + fun givenEndTimeInPast_whenEndTimeChanges_thenEndTimeInPastErrorIsShown() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val (_, viewModel) = arrangeViewModel(Arrangement(dispatcher).withCurrentTimeProvider { currentTime }) + + enterTitle(viewModel, "Weekly sync") + viewModel.updateEndTime(currentTime - 1.hours) + advanceUntilIdle() + + assertEquals(NewMeetingState.TimeError.EndTimeInPastError, viewModel.state.endTimeError) + assertFalse(viewModel.state.continueButtonEnabled) + } + + @Test + fun givenEndTimeBeforeStartTime_whenEndTimeChanges_thenEndTimeBeforeStartTimeErrorIsShown() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val (_, viewModel) = arrangeViewModel(Arrangement(dispatcher).withCurrentTimeProvider { currentTime }) + + enterTitle(viewModel, "Weekly sync") + viewModel.updateStartTime(currentTime + 2.hours) + viewModel.updateEndTime(currentTime + 1.hours) + advanceUntilIdle() + + assertEquals(NewMeetingState.TimeError.EndTimeBeforeStartTimeError, viewModel.state.endTimeError) + assertFalse(viewModel.state.continueButtonEnabled) + } + + @Test + fun givenInvalidTimesBecomeValid_whenTimesChange_thenErrorsAreClearedAndContinueIsEnabled() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val (_, viewModel) = arrangeViewModel(Arrangement(dispatcher).withCurrentTimeProvider { currentTime }) + + enterTitle(viewModel, "Weekly sync") + viewModel.updateStartTime(currentTime + 2.hours) + viewModel.updateEndTime(currentTime + 1.hours) + advanceUntilIdle() + viewModel.updateEndTime(currentTime + 3.hours) + advanceUntilIdle() + + assertNull(viewModel.state.startTimeError) + assertNull(viewModel.state.endTimeError) + assertEquals(true, viewModel.state.continueButtonEnabled) + } + + @Test + fun givenValidData_whenCreateMeetingIsCalled_thenSuccessActionIsSent() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val (_, viewModel) = arrangeViewModel(Arrangement(dispatcher).withCurrentTimeProvider { currentTime }) + + enterTitle(viewModel, "Weekly sync") + + viewModel.actions.test { + viewModel.createMeeting() + advanceUntilIdle() + + assertEquals(NewMeetingViewActions.Success, awaitItem()) + } + } + + @Test + fun givenInvalidTitle_whenCreateMeetingIsCalled_thenTitleErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { + val (_, viewModel) = arrangeViewModel() + + viewModel.actions.test { + viewModel.createMeeting() + advanceUntilIdle() + + expectNoEvents() + assertEquals(NewMeetingState.TitleError.TitleEmptyError, viewModel.state.titleError) + assertFalse(viewModel.state.continueButtonEnabled) + } + } + + private fun TestScope.arrangeViewModel( + arrangement: Arrangement = Arrangement(dispatcher) + ): Pair = + arrangement.arrange().also { runCurrent() } + + private fun TestScope.enterTitle(viewModel: NewMeetingViewModel, title: String) { + viewModel.titleTextState.setTextAndPlaceCursorAtEnd(title) + advanceUntilIdle() + } + + private fun contact(id: String) = Contact( + id = id, + domain = "wire.com", + name = "Contact $id", + handle = id, + membership = Membership.Standard, + connectionState = ConnectionState.ACCEPTED, + ) + + private class Arrangement(private val dispatcher: TestDispatcher) { + var currentTimeProvider = CurrentTimeProvider { + Instant.fromEpochMilliseconds(dispatcher.scheduler.currentTime) + } + + @MockK + private lateinit var savedStateHandle: SavedStateHandle + + private var newMeetingType: NewMeetingType = NewMeetingType.MeetNow + + init { + MockKAnnotations.init(this) + every { + savedStateHandle.navArgs() + } answers { NewMeetingNavArgs(type = newMeetingType) } + } + + fun withNewMeetingType(type: NewMeetingType) = apply { + newMeetingType = type + } + fun withCurrentTimeProvider(currentTime: () -> Instant) = apply { + currentTimeProvider = CurrentTimeProvider(currentTime) + } + + fun arrange() = this to NewMeetingViewModelImpl( + savedStateHandle = savedStateHandle, + currentTimeProvider = currentTimeProvider, + ) + } +} diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/list/MeetingListViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/list/MeetingListViewModelTest.kt index 8932ea50c0..c4c5b25401 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/list/MeetingListViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/list/MeetingListViewModelTest.kt @@ -152,7 +152,7 @@ class MeetingListViewModelTest { title = "Meeting", startTime = startTime, endTime = startTime + 30.minutes, - repeatingInterval = null, + repeatingInterval = MeetingItem.RepeatingInterval.Never, ongoingCallStatus = null, selfRole = MeetingItem.SelfRole.Admin, ) diff --git a/features/meetings/stability/meetings-debug.stability b/features/meetings/stability/meetings-debug.stability index ecf91e9adf..9f51941cf8 100644 --- a/features/meetings/stability/meetings-debug.stability +++ b/features/meetings/stability/meetings-debug.stability @@ -36,7 +36,7 @@ public fun com.wire.android.feature.meetings.ui.NewMeetingBottomSheet(sheetState - onScheduleClick: STABLE (function type) @Composable -public fun com.wire.android.feature.meetings.ui.create.NewMeetingContent(state: com.wire.android.feature.meetings.ui.create.NewMeetingState, titleState: androidx.compose.foundation.text.input.TextFieldState, type: com.wire.android.feature.meetings.ui.create.NewMeetingType, modifier: androidx.compose.ui.Modifier, onBackPressed: kotlin.Function0, onParticipantsClicked: kotlin.Function0, onCreateClicked: kotlin.Function0): kotlin.Unit +public fun com.wire.android.feature.meetings.ui.create.NewMeetingContent(state: com.wire.android.feature.meetings.ui.create.NewMeetingState, titleState: androidx.compose.foundation.text.input.TextFieldState, type: com.wire.android.feature.meetings.ui.create.NewMeetingType, modifier: androidx.compose.ui.Modifier, onBackPressed: kotlin.Function0, onParticipantsClicked: kotlin.Function0, onCreateClicked: kotlin.Function0, onStartTimeChanged: kotlin.Function1<@[ParameterName(name = \, onEndTimeChanged: kotlin.Function1<@[ParameterName(name = \, onRepeatingIntervalChanged: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit skippable: true restartable: true params: @@ -47,6 +47,9 @@ public fun com.wire.android.feature.meetings.ui.create.NewMeetingContent(state: - onBackPressed: STABLE (function type) - onParticipantsClicked: STABLE (function type) - onCreateClicked: STABLE (function type) + - onStartTimeChanged: STABLE (function type) + - onEndTimeChanged: STABLE (function type) + - onRepeatingIntervalChanged: STABLE (function type) @Composable public fun com.wire.android.feature.meetings.ui.create.NewMeetingParticipantsScreen(navigator: com.wire.android.feature.meetings.navigation.MeetingNavigator, newMeetingViewModel: com.wire.android.feature.meetings.ui.create.NewMeetingViewModel): kotlin.Unit @@ -73,6 +76,15 @@ private fun com.wire.android.feature.meetings.ui.create.ParticipantsInput(partic - participants: STABLE (known stable type) - onClick: STABLE (function type) +@Composable +private fun com.wire.android.feature.meetings.ui.create.RepeatingIntervalDropDown(repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval, onRepeatingIntervalChanged: kotlin.Function1, items: kotlin.collections.List): kotlin.Unit + skippable: false + restartable: true + params: + - repeatingInterval: STABLE (marked @Stable or @Immutable) + - onRepeatingIntervalChanged: STABLE (function type) + - items: RUNTIME (requires runtime check) + @Composable private fun com.wire.android.feature.meetings.ui.create.SelectButton(onClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier, buttonModifier: androidx.compose.ui.Modifier, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, elevation: androidx.compose.ui.unit.Dp): kotlin.Unit skippable: true @@ -84,6 +96,18 @@ private fun com.wire.android.feature.meetings.ui.create.SelectButton(onClick: ko - leadingIcon: STABLE (composable function type) - elevation: STABLE (marked @Stable or @Immutable) +@Composable +private fun com.wire.android.feature.meetings.ui.create.TimeInput(time: kotlinx.datetime.Instant, timeError: com.wire.android.feature.meetings.ui.create.NewMeetingState.TimeError?, onTimeChanged: kotlin.Function1, label: kotlin.String, datePlaceholder: kotlin.String, timePlaceholder: kotlin.String): kotlin.Unit + skippable: true + restartable: true + params: + - time: STABLE (matched by stability configuration) + - timeError: STABLE (class with no mutable properties) + - onTimeChanged: STABLE (function type) + - label: STABLE (String is immutable) + - datePlaceholder: STABLE (String is immutable) + - timePlaceholder: STABLE (String is immutable) + @Composable private fun com.wire.android.feature.meetings.ui.create.TitleInput(titleState: androidx.compose.foundation.text.input.TextFieldState, titleError: com.wire.android.feature.meetings.ui.create.NewMeetingState.TitleError?): kotlin.Unit skippable: true @@ -127,7 +151,7 @@ private fun com.wire.android.feature.meetings.ui.list.MeetingBelongingInfoRow(co restartable: true params: - conversationId: STABLE (matched by stability configuration) - - type: STABLE (class with no mutable properties) + - type: STABLE (marked @Stable or @Immutable) @Composable public fun com.wire.android.feature.meetings.ui.list.MeetingHeader(header: com.wire.android.feature.meetings.model.MeetingHeader, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -139,10 +163,10 @@ public fun com.wire.android.feature.meetings.ui.list.MeetingHeader(header: com.w @Composable public fun com.wire.android.feature.meetings.ui.list.MeetingItem(meeting: com.wire.android.feature.meetings.model.MeetingItem, modifier: androidx.compose.ui.Modifier, openMeetingOptions: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit - skippable: false + skippable: true restartable: true params: - - meeting: UNSTABLE (has mutable properties or unstable members) + - meeting: STABLE (marked @Stable or @Immutable) - modifier: STABLE (marked @Stable or @Immutable) - openMeetingOptions: STABLE (function type) @@ -185,10 +209,10 @@ private fun com.wire.android.feature.meetings.ui.list.MeetingMoreButton(onMenuCl @Composable private fun com.wire.android.feature.meetings.ui.list.MeetingOngoingAttendingRow(status: com.wire.android.feature.meetings.model.MeetingItem.Status, onJoinClick: kotlin.Function0): kotlin.Unit - skippable: false + skippable: true restartable: true params: - - status: UNSTABLE (has mutable properties or unstable members) + - status: STABLE (marked @Stable or @Immutable) - onJoinClick: STABLE (function type) @Composable @@ -214,12 +238,12 @@ private fun com.wire.android.feature.meetings.ui.list.MeetingStartingInPrimaryBo - startTime: STABLE (matched by stability configuration) @Composable -private fun com.wire.android.feature.meetings.ui.list.MeetingTimeInfoRow(status: com.wire.android.feature.meetings.model.MeetingItem.Status, repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval?): kotlin.Unit - skippable: false +private fun com.wire.android.feature.meetings.ui.list.MeetingTimeInfoRow(status: com.wire.android.feature.meetings.model.MeetingItem.Status, repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval): kotlin.Unit + skippable: true restartable: true params: - - status: UNSTABLE (has mutable properties or unstable members) - - repeatingInterval: STABLE (class with no mutable properties) + - status: STABLE (marked @Stable or @Immutable) + - repeatingInterval: STABLE (marked @Stable or @Immutable) @Composable private fun com.wire.android.feature.meetings.ui.list.PrimaryBodyText(text: kotlin.String): kotlin.Unit @@ -229,11 +253,11 @@ private fun com.wire.android.feature.meetings.ui.list.PrimaryBodyText(text: kotl - text: STABLE (String is immutable) @Composable -private fun com.wire.android.feature.meetings.ui.list.RepeatingIntervalInfoLabel(repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval?): kotlin.Unit +private fun com.wire.android.feature.meetings.ui.list.RepeatingIntervalInfoLabel(repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval): kotlin.Unit skippable: true restartable: true params: - - repeatingInterval: STABLE (class with no mutable properties) + - repeatingInterval: STABLE (marked @Stable or @Immutable) @Composable private fun com.wire.android.feature.meetings.ui.list.SublineText(text: kotlin.String): kotlin.Unit @@ -279,10 +303,10 @@ public fun com.wire.android.feature.meetings.ui.newMeetingViewModel(viewModelSto @Composable private fun com.wire.android.feature.meetings.ui.options.MeetingOptionsModalContent(meeting: com.wire.android.feature.meetings.model.MeetingItem, onStartMeeting: kotlin.Function0, onCreateConversation: kotlin.Function0, onCopyLink: kotlin.Function0, onEditMeeting: kotlin.Function0, onDeleteMeetingForMe: kotlin.Function0, onDeleteMeetingForEveryone: kotlin.Function0): kotlin.Unit - skippable: false + skippable: true restartable: true params: - - meeting: UNSTABLE (has mutable properties or unstable members) + - meeting: STABLE (marked @Stable or @Immutable) - onStartMeeting: STABLE (function type) - onCreateConversation: STABLE (function type) - onCopyLink: STABLE (function type)