diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt index 4ecefbab365..6292c5a5bba 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt @@ -62,7 +62,7 @@ open class MessageListPage { companion object { val inputField get() = By.res("Stream_ComposerInputField") val sendButton get() = By.res("Stream_ComposerSendButton") - val recordAudioButton get() = By.res("Stream_ComposerRecordAudioButton") + val recordAudioButton get() = By.res("Stream_ComposerAudioRecordingButton") val commandsButton get() = By.res("Stream_ComposerCommandsButton") val suggestionList get() = By.res("Stream_SuggestionList") val suggestionListTitle get() = By.res("Stream_SuggestionListTitle") diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt index 59eb921350a..08b2a1de39d 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt @@ -356,7 +356,10 @@ class MessagesActivity : ComponentActivity() { val message = composerViewModel.buildNewMessage(input, attachments) composerViewModel.sendMessage(message) }, - recordingActions = AudioRecordingActions.defaultActions(composerViewModel), + recordingActions = AudioRecordingActions.defaultActions( + viewModel = composerViewModel, + sendOnComplete = ChatTheme.messageComposerTheme.audioRecording.sendOnComplete, + ), centerContent = { modifier -> ComposerTextInput(modifier, composerState) }, trailingContent = { ComposerTrailingIcon() }, ) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index c2288f0e1d5..5b670a9a843 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -2572,37 +2572,35 @@ public final class io/getstream/chat/android/compose/ui/messages/composer/Compos } public final class io/getstream/chat/android/compose/ui/messages/composer/MessageComposerKt { - public static final fun MessageComposer (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V - public static final fun MessageComposer (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V + public static final fun MessageComposer (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V + public static final fun MessageComposer (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V } public final class io/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions { public static final field $stable I public static final field Companion Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions$Companion; - public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)V - public final fun component1 ()Lkotlin/jvm/functions/Function1; + public fun (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public final fun component1 ()Lkotlin/jvm/functions/Function0; public final fun component10 ()Lkotlin/jvm/functions/Function1; - public final fun component11 ()Lkotlin/jvm/functions/Function0; public final fun component2 ()Lkotlin/jvm/functions/Function1; public final fun component3 ()Lkotlin/jvm/functions/Function0; public final fun component4 ()Lkotlin/jvm/functions/Function0; public final fun component5 ()Lkotlin/jvm/functions/Function0; public final fun component6 ()Lkotlin/jvm/functions/Function0; - public final fun component7 ()Lkotlin/jvm/functions/Function1; + public final fun component7 ()Lkotlin/jvm/functions/Function0; public final fun component8 ()Lkotlin/jvm/functions/Function0; public final fun component9 ()Lkotlin/jvm/functions/Function1; - public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions; + public final fun copy (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions; public fun equals (Ljava/lang/Object;)Z public final fun getOnCancelRecording ()Lkotlin/jvm/functions/Function0; - public final fun getOnCompleteRecording ()Lkotlin/jvm/functions/Function1; + public final fun getOnConfirmRecording ()Lkotlin/jvm/functions/Function0; public final fun getOnDeleteRecording ()Lkotlin/jvm/functions/Function0; public final fun getOnHoldRecording ()Lkotlin/jvm/functions/Function1; public final fun getOnLockRecording ()Lkotlin/jvm/functions/Function0; public final fun getOnRecordingSliderDragStart ()Lkotlin/jvm/functions/Function1; public final fun getOnRecordingSliderDragStop ()Lkotlin/jvm/functions/Function1; - public final fun getOnSendRecording ()Lkotlin/jvm/functions/Function0; - public final fun getOnStartRecording ()Lkotlin/jvm/functions/Function1; + public final fun getOnStartRecording ()Lkotlin/jvm/functions/Function0; public final fun getOnStopRecording ()Lkotlin/jvm/functions/Function0; public final fun getOnToggleRecordingPlayback ()Lkotlin/jvm/functions/Function0; public fun hashCode ()I @@ -2610,30 +2608,25 @@ public final class io/getstream/chat/android/compose/ui/messages/composer/action } public final class io/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions$Companion { - public final fun defaultActions (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;)Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions; + public final fun defaultActions (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Z)Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions; public final fun getNone ()Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions; } -public final class io/getstream/chat/android/compose/ui/messages/composer/internal/ComposableSingletons$DefaultMessageComposerRecordingContentKt { - public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/internal/ComposableSingletons$DefaultMessageComposerRecordingContentKt; - public static field lambda-1 Lkotlin/jvm/functions/Function2; +public final class io/getstream/chat/android/compose/ui/messages/composer/internal/ComposableSingletons$AudioRecordingButtonKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/internal/ComposableSingletons$AudioRecordingButtonKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; public static field lambda-2 Lkotlin/jvm/functions/Function2; public static field lambda-3 Lkotlin/jvm/functions/Function2; public static field lambda-4 Lkotlin/jvm/functions/Function2; public static field lambda-5 Lkotlin/jvm/functions/Function2; public fun ()V - public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } -public final class io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContentKt { - public static final fun DefaultAudioRecordButton (Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;JJJLandroidx/compose/runtime/Composer;II)V - public static final fun DefaultMessageComposerRecordingContent (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Landroidx/compose/runtime/Composer;II)V -} - public final class io/getstream/chat/android/compose/ui/messages/header/ComposableSingletons$MessageListHeaderKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/header/ComposableSingletons$MessageListHeaderKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; @@ -2937,9 +2930,8 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC public abstract fun MessageAuthor (Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageBottom (Landroidx/compose/foundation/layout/ColumnScope;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageBubble-T042LqI (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Landroidx/compose/foundation/BorderStroke;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V - public abstract fun MessageComposer (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;ZLkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public abstract fun MessageComposer (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;ZLkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V public abstract fun MessageComposerAudioRecordButton (Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Landroidx/compose/runtime/Composer;I)V - public abstract fun MessageComposerAudioRecordingContent (Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageComposerCommandSuggestionItem (Lio/getstream/chat/android/models/Command;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageComposerCommandSuggestionItemCenterContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Command;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageComposerCommandSuggestionItemLeadingContent (Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/models/Command;Landroidx/compose/runtime/Composer;I)V @@ -3132,9 +3124,8 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFacto public static fun MessageAuthor (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public static fun MessageBottom (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/ColumnScope;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public static fun MessageBubble-T042LqI (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Message;JLandroidx/compose/ui/graphics/Shape;Landroidx/compose/foundation/BorderStroke;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V - public static fun MessageComposer (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;ZLkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static fun MessageComposer (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;ZLkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V public static fun MessageComposerAudioRecordButton (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Landroidx/compose/runtime/Composer;I)V - public static fun MessageComposerAudioRecordingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Landroidx/compose/runtime/Composer;I)V public static fun MessageComposerCommandSuggestionItem (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/models/Command;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public static fun MessageComposerCommandSuggestionItemCenterContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Command;Landroidx/compose/runtime/Composer;I)V public static fun MessageComposerCommandSuggestionItemLeadingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/models/Command;Landroidx/compose/runtime/Composer;I)V @@ -3628,8 +3619,8 @@ public final class io/getstream/chat/android/compose/ui/theme/ReactionOptionsThe public final class io/getstream/chat/android/compose/ui/theme/StreamColors { public static final field $stable I public static final field Companion Lio/getstream/chat/android/compose/ui/theme/StreamColors$Companion; - public synthetic fun (JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1-0d7_KjU ()J public final fun component10-0d7_KjU ()J public final fun component100-0d7_KjU ()J @@ -3645,6 +3636,7 @@ public final class io/getstream/chat/android/compose/ui/theme/StreamColors { public final fun component11-0d7_KjU ()J public final fun component110-0d7_KjU ()J public final fun component111-0d7_KjU ()J + public final fun component112-0d7_KjU ()J public final fun component12-0d7_KjU ()J public final fun component13-0d7_KjU ()J public final fun component14-0d7_KjU ()J @@ -3741,8 +3733,8 @@ public final class io/getstream/chat/android/compose/ui/theme/StreamColors { public final fun component97-0d7_KjU ()J public final fun component98-0d7_KjU ()J public final fun component99-0d7_KjU ()J - public final fun copy-hP-tm4k (JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ)Lio/getstream/chat/android/compose/ui/theme/StreamColors; - public static synthetic fun copy-hP-tm4k$default (Lio/getstream/chat/android/compose/ui/theme/StreamColors;JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJIIIILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/StreamColors; + public final fun copy-UxbjAG8 (JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ)Lio/getstream/chat/android/compose/ui/theme/StreamColors; + public static synthetic fun copy-UxbjAG8$default (Lio/getstream/chat/android/compose/ui/theme/StreamColors;JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJIIIILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/StreamColors; public fun equals (Ljava/lang/Object;)Z public final fun getAccentBlack-0d7_KjU ()J public final fun getAccentError-0d7_KjU ()J @@ -3761,6 +3753,7 @@ public final class io/getstream/chat/android/compose/ui/theme/StreamColors { public final fun getAvatarPaletteText4-0d7_KjU ()J public final fun getAvatarPaletteText5-0d7_KjU ()J public final fun getBackgroundCoreDisabled-0d7_KjU ()J + public final fun getBackgroundCoreInverse-0d7_KjU ()J public final fun getBackgroundCoreSelected-0d7_KjU ()J public final fun getBackgroundCoreSurface-0d7_KjU ()J public final fun getBackgroundCoreSurfaceSubtle-0d7_KjU ()J @@ -4443,6 +4436,13 @@ public final class io/getstream/chat/android/compose/ui/util/ComposableSingleton public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } +public final class io/getstream/chat/android/compose/ui/util/ComposableSingletons$SnackbarPopupKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/util/ComposableSingletons$SnackbarPopupKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; +} + public final class io/getstream/chat/android/compose/ui/util/DefaultPollSwitchItemFactory : io/getstream/chat/android/compose/ui/util/PollSwitchItemFactory { public static final field $stable I public fun (Landroid/content/Context;)V @@ -4887,7 +4887,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC public final fun setMessageInput (Ljava/lang/String;)V public final fun setMessageMode (Lio/getstream/chat/android/ui/common/state/messages/MessageMode;)V public final fun setTypingUpdatesBuffer (Lio/getstream/chat/android/ui/common/utils/typing/TypingUpdatesBuffer;)V - public final fun startRecording (Lkotlin/Pair;)V + public final fun startRecording ()V public final fun stopRecording ()V public final fun toggleCommandsVisibility ()V public final fun toggleRecordingPlayback ()V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt index 8d56d386835..d95850495d0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -50,6 +51,7 @@ import io.getstream.chat.android.previewdata.PreviewMessageData import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.Reply import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState +import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState /** * Input field for the Messages/Conversation screen. Allows label customization, as well as handlers @@ -129,7 +131,12 @@ public fun MessageInput( ) { leadingContent() - centerContent(Modifier.weight(1f)) + val isRecording = messageComposerState.recording !is RecordingState.Idle + if (!isRecording) { + centerContent(Modifier.weight(1f)) + } else { + Spacer(Modifier.weight(1f)) + } trailingContent() } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/suggestions/SuggestionList.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/suggestions/SuggestionList.kt index 8bfc3b24d21..009d54baa25 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/suggestions/SuggestionList.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/suggestions/SuggestionList.kt @@ -49,7 +49,7 @@ public fun SuggestionList( headerContent: @Composable () -> Unit = {}, centerContent: @Composable () -> Unit, ) { - Popup(popupPositionProvider = AboveAnchorPopupPositionProvider()) { + Popup(popupPositionProvider = AboveAnchorPopupPositionProvider) { Card( modifier = modifier.semantics { testTagsAsResourceId = true }, elevation = CardDefaults.cardElevation(defaultElevation = ChatTheme.dimens.suggestionListElevation), diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt index f59c3616032..03f356b4c24 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.runtime.Composable @@ -40,14 +39,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.composer.MessageInput import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.LocalMessageComposerFloatingStyleEnabled import io.getstream.chat.android.compose.ui.theme.StreamTokens -import io.getstream.chat.android.compose.ui.util.AboveAnchorPopupPositionProvider +import io.getstream.chat.android.compose.ui.util.SnackbarPopup import io.getstream.chat.android.compose.util.extensions.toSet import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel import io.getstream.chat.android.models.Attachment @@ -58,7 +56,6 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState -import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState import io.getstream.chat.android.ui.common.state.messages.composer.ValidationError import io.getstream.chat.android.ui.common.utils.MediaStringUtil @@ -88,8 +85,6 @@ import io.getstream.chat.android.ui.common.utils.MediaStringUtil * @param commandPopupContent Customizable composable that represents the instant command suggestions popup. * @param leadingContent The content shown at the start of the message composer. * @param input Customizable composable that represents the input field for the composer, [MessageInput] by default. - * @param audioRecordingContent Customizable composable used for displaying audio recording information - * while audio recording is in progress. * @param trailingContent Customizable composable that represents the trailing content of the composer, send button * by default. */ @@ -108,7 +103,10 @@ public fun MessageComposer( onMentionSelected: (User) -> Unit = { viewModel.selectMention(it) }, onCommandSelected: (Command) -> Unit = { viewModel.selectCommand(it) }, onAlsoSendToChannelSelected: (Boolean) -> Unit = { viewModel.setAlsoSendToChannel(it) }, - recordingActions: AudioRecordingActions = AudioRecordingActions.defaultActions(viewModel), + recordingActions: AudioRecordingActions = AudioRecordingActions.defaultActions( + viewModel = viewModel, + sendOnComplete = ChatTheme.messageComposerTheme.audioRecording.sendOnComplete, + ), headerContent: @Composable ColumnScope.(MessageComposerState) -> Unit = { with(ChatTheme.componentFactory) { MessageComposerHeaderContent( @@ -186,14 +184,6 @@ public fun MessageComposer( ) } }, - audioRecordingContent: @Composable RowScope.(MessageComposerState) -> Unit = { - with(ChatTheme.componentFactory) { - MessageComposerAudioRecordingContent( - state = it, - recordingActions = recordingActions, - ) - } - }, trailingContent: @Composable (MessageComposerState) -> Unit = { ChatTheme.componentFactory.MessageComposerTrailingContent( state = it, @@ -220,7 +210,6 @@ public fun MessageComposer( commandPopupContent = commandPopupContent, leadingContent = leadingContent, input = input, - audioRecordingContent = audioRecordingContent, trailingContent = trailingContent, messageComposerState = messageComposerState, onCancelAction = onCancelAction, @@ -255,8 +244,6 @@ public fun MessageComposer( * @param commandPopupContent Customizable composable that represents the instant command suggestions popup. * @param leadingContent The content shown at the start of the message composer. * @param input Customizable composable that represents the input field for the composer, [MessageInput] by default. - * @param audioRecordingContent Customizable composable used for displaying audio recording information - * while audio recording is in progress. * @param trailingContent Customizable composable that represents the trailing content of the composer, send button * by default. */ @@ -347,14 +334,6 @@ public fun MessageComposer( ) } }, - audioRecordingContent: @Composable RowScope.(MessageComposerState) -> Unit = { - with(ChatTheme.componentFactory) { - MessageComposerAudioRecordingContent( - state = it, - recordingActions = recordingActions, - ) - } - }, trailingContent: @Composable (MessageComposerState) -> Unit = { ChatTheme.componentFactory.MessageComposerTrailingContent( state = it, @@ -367,8 +346,6 @@ public fun MessageComposer( val commandSuggestions = messageComposerState.commandSuggestions val snackbarHostState = remember { SnackbarHostState() } - val isRecording = messageComposerState.recording !is RecordingState.Idle - MessageInputValidationError( validationErrors = validationErrors, snackbarHostState = snackbarHostState, @@ -402,10 +379,6 @@ public fun MessageComposer( input(messageComposerState) - if (isRecording) { - audioRecordingContent(messageComposerState) - } - trailingContent(messageComposerState) } @@ -413,7 +386,7 @@ public fun MessageComposer( } if (snackbarHostState.currentSnackbarData != null) { - SnackbarPopup(snackbarHostState = snackbarHostState) + SnackbarPopup(hostState = snackbarHostState) } if (mentionSuggestions.isNotEmpty()) { @@ -508,20 +481,6 @@ private fun MessageInputValidationError(validationErrors: List, } } -/** - * A snackbar wrapped inside of a popup allowing it be - * displayed above the Composable it's anchored to. - * - * @param snackbarHostState The state of the snackbar host. Contains - * the snackbar data necessary to display the snackbar. - */ -@Composable -private fun SnackbarPopup(snackbarHostState: SnackbarHostState) { - Popup(popupPositionProvider = AboveAnchorPopupPositionProvider()) { - SnackbarHost(hostState = snackbarHostState) - } -} - @Preview @Composable private fun MessageComposerDefaultStylePreview() { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions.kt index 32111278783..4f1594e1f0a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions.kt @@ -19,35 +19,56 @@ package io.getstream.chat.android.compose.ui.messages.composer.actions import androidx.compose.runtime.Immutable import androidx.compose.ui.geometry.Offset import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel +import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState /** - * Represents the actions that can be performed on an audio recording. + * Actions that can be performed during an audio recording session. * - * @property onStartRecording Handler when the user starts recording an audio message. - * @property onHoldRecording Handler when the user holds the recording button. - * @property onLockRecording Handler when the user locks the recording. - * @property onCancelRecording Handler when the user cancels the recording. - * @property onDeleteRecording Handler when the user deletes the recording. - * @property onStopRecording Handler when the user stops the recording. - * @property onCompleteRecording Handler when the user completes the recording. - * @property onToggleRecordingPlayback Handler when the user toggles the recording playback. - * @property onRecordingSliderDragStart Handler when the user starts dragging the recording slider. - * @property onRecordingSliderDragStop Handler when the user stops dragging the recording slider. - * @property onSendRecording Handler when the user sends the recording. + * Each property maps a user gesture or button tap to a handler. + * Override individual actions via [copy] to customise behaviour while keeping the rest at their defaults. + * + * @property onStartRecording Begins a new recording. + * Transitions from [RecordingState.Idle] to [RecordingState.Hold]. + * Ignored when the current state is not [RecordingState.Idle]. + * @property onHoldRecording Updates the drag offset while the user holds the record button. + * The [Offset] represents the constrained drag delta from the initial press position + * (negative x = cancel direction, negative y = lock direction). + * Only meaningful in [RecordingState.Hold]. + * @property onLockRecording Locks the recording so it continues hands-free. + * Transitions from [RecordingState.Hold] to [RecordingState.Locked]. + * @property onCancelRecording Discards the recording via the swipe-to-cancel gesture. + * Invoked when the user drags past the cancel threshold during [RecordingState.Hold]. + * Transitions to [RecordingState.Idle]. + * @property onDeleteRecording Discards the recording via the delete button. + * Invoked when the user taps the trash icon in [RecordingState.Locked] or [RecordingState.Overview]. + * Transitions to [RecordingState.Idle]. + * @property onStopRecording Stops the active microphone recording. + * Transitions from [RecordingState.Locked] to [RecordingState.Overview], + * where the user can review the waveform before confirming. + * @property onConfirmRecording Finalises the recording. + * Depending on configuration, this either sends the recording immediately + * or attaches it to the composer for manual sending. + * Invoked on finger release (when not locked) or when tapping the confirm button in + * [RecordingState.Locked] / [RecordingState.Overview]. + * @property onToggleRecordingPlayback Toggles play / pause of the recorded audio. + * Only meaningful in [RecordingState.Overview]. + * @property onRecordingSliderDragStart Called when the user begins dragging the playback slider. + * Pauses playback. The [Float] is the playback progress at the drag start point (0..1). + * @property onRecordingSliderDragStop Called when the user releases the playback slider. + * Seeks playback to the given progress. The [Float] is the target progress (0..1). */ @Immutable public data class AudioRecordingActions( - val onStartRecording: (Offset) -> Unit, + val onStartRecording: () -> Unit, val onHoldRecording: (Offset) -> Unit, val onLockRecording: () -> Unit, val onCancelRecording: () -> Unit, val onDeleteRecording: () -> Unit, val onStopRecording: () -> Unit, - val onCompleteRecording: (Boolean) -> Unit, + val onConfirmRecording: () -> Unit, val onToggleRecordingPlayback: () -> Unit, val onRecordingSliderDragStart: (Float) -> Unit, val onRecordingSliderDragStop: (Float) -> Unit, - val onSendRecording: () -> Unit, ) { public companion object { @@ -61,32 +82,35 @@ public data class AudioRecordingActions( onCancelRecording = {}, onDeleteRecording = {}, onStopRecording = {}, - onCompleteRecording = {}, + onConfirmRecording = {}, onToggleRecordingPlayback = {}, onRecordingSliderDragStart = {}, onRecordingSliderDragStop = {}, - onSendRecording = {}, ) /** - * Default implementation of [AudioRecordingActions]. + * Default implementation backed by [viewModel]. + * + * @param viewModel The [MessageComposerViewModel] that drives recording state. + * @param sendOnComplete When `true`, [onConfirmRecording] sends the message immediately. + * When `false`, it attaches the recording to the composer for manual sending. */ public fun defaultActions( viewModel: MessageComposerViewModel, + sendOnComplete: Boolean, ): AudioRecordingActions = AudioRecordingActions( - onStartRecording = { viewModel.startRecording(it.toRestrictedCoordinates()) }, + onStartRecording = viewModel::startRecording, onHoldRecording = { viewModel.holdRecording(it.toRestrictedCoordinates()) }, - onLockRecording = { viewModel.lockRecording() }, - onCancelRecording = { viewModel.cancelRecording() }, - onDeleteRecording = { viewModel.cancelRecording() }, - onStopRecording = { viewModel.stopRecording() }, - onCompleteRecording = { sendOnComplete -> + onLockRecording = viewModel::lockRecording, + onCancelRecording = viewModel::cancelRecording, + onDeleteRecording = viewModel::cancelRecording, + onStopRecording = viewModel::stopRecording, + onConfirmRecording = { if (sendOnComplete) viewModel.sendRecording() else viewModel.completeRecording() }, - onToggleRecordingPlayback = { viewModel.toggleRecordingPlayback() }, + onToggleRecordingPlayback = viewModel::toggleRecordingPlayback, onRecordingSliderDragStart = { viewModel.pauseRecording() }, - onRecordingSliderDragStop = { viewModel.seekRecordingTo(it) }, - onSendRecording = { viewModel.sendRecording() }, + onRecordingSliderDragStop = viewModel::seekRecordingTo, ) } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingButton.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingButton.kt new file mode 100644 index 00000000000..05dee8be3a8 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingButton.kt @@ -0,0 +1,681 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("TooManyFunctions") // Composable UI file: main components + private helpers + previews. + +package io.getstream.chat.android.compose.ui.messages.composer.internal + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions +import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.IconContainerStyle +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.theme.messages.composer.AudioRecordingFloatingIconStyle +import io.getstream.chat.android.compose.ui.util.SnackbarPopup +import io.getstream.chat.android.compose.ui.util.mirrorRtl +import io.getstream.chat.android.compose.ui.util.padding +import io.getstream.chat.android.compose.ui.util.size +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * Unified voice recording component that handles both the mic button gesture lifecycle + * and the recording content (waveform, controls, floating icons). + * + * **Layout behavior:** + * - **Idle**: Renders just the mic button (wrap content). + * - **Hold**: Mic button stays visible, recording content (timer, slide-to-cancel) fills the + * available width alongside it, floating mic/lock icons appear as Popups. + * - **Locked / Overview**: Mic button hides, recording content fills the width, + * control buttons (delete, stop, complete) appear below. + * + * The mic button is **always in the composition tree** to preserve gesture continuity. + * Its size toggles between `style.size` (visible) and `0.dp` (hidden) — it never leaves + * the tree during a recording session. + * + * @param recordingState The current recording state from the ViewModel. + * @param recordingActions The actions to control the audio recording. + * @param modifier Modifier applied to the outer container. + */ +@Composable +internal fun AudioRecordingButton( + recordingState: RecordingState, + recordingActions: AudioRecordingActions, + modifier: Modifier = Modifier, +) { + val isRecording = recordingState !is RecordingState.Idle + val showControls = recordingState is RecordingState.Locked || recordingState is RecordingState.Overview + val showFloatingIcons = recordingState is RecordingState.Hold || recordingState is RecordingState.Locked + val floatingMic = rememberFloatingMicState(recordingState) + + Column(modifier = modifier.then(if (isRecording) Modifier.fillMaxWidth() else Modifier)) { + Row(verticalAlignment = Alignment.Bottom) { + if (isRecording) { + AudioRecordingContent( + recordingState = recordingState, + recordingActions = recordingActions, + modifier = Modifier.weight(1f), + ) + } + + MicButton( + isVisible = floatingMic.isMicVisible, + recordingState = recordingState, + recordingActions = recordingActions, + floatingActive = floatingMic.isActive, + floatingOffset = floatingMic.floatingOffset, + ) + } + + if (showControls) { + RecordingControlButtons( + isStopVisible = recordingState is RecordingState.Locked, + recordingActions = recordingActions, + ) + } + + if (showFloatingIcons) { + FloatingLockIcon( + isLocked = recordingState is RecordingState.Locked, + holdControlsOffset = floatingMic.holdOffset, + ) + } + } +} + +/** + * Encapsulates the floating mic button animation state: + * - Tracks hold → idle transitions to trigger spring-back animation. + * - Keeps the mic button sized during spring-back so the Popup anchor stays stable. + */ +private class FloatingMicState( + val isActive: Boolean, + val isMicVisible: Boolean, + val holdOffset: IntOffset, + val floatingOffset: IntOffset, +) + +@Composable +private fun rememberFloatingMicState(recordingState: RecordingState): FloatingMicState { + val isHolding = recordingState is RecordingState.Hold + var isReturning by remember { mutableStateOf(false) } + + // Detect Hold→Idle transition: spring-back only to Idle; when locking, just disappear. + val prevHolding = remember { mutableStateOf(false) } + if (prevHolding.value && !isHolding) { + isReturning = recordingState is RecordingState.Idle + } + prevHolding.value = isHolding + + val holdOffset = if (recordingState is RecordingState.Hold) { + IntOffset( + x = recordingState.offsetX.toInt().coerceAtMost(0), + y = recordingState.offsetY.toInt().coerceAtMost(0), + ) + } else { + IntOffset.Zero + } + + val floatingOffsetX = remember { Animatable(0f) } + val floatingOffsetY = remember { Animatable(0f) } + + if (isHolding) { + LaunchedEffect(holdOffset) { + floatingOffsetX.snapTo(holdOffset.x.toFloat()) + floatingOffsetY.snapTo(holdOffset.y.toFloat()) + } + } + + LaunchedEffect(isReturning) { + if (isReturning) { + try { + coroutineScope { + launch { floatingOffsetX.animateTo(0f, spring()) } + launch { floatingOffsetY.animateTo(0f, spring()) } + } + } finally { + isReturning = false + } + } + } + + return FloatingMicState( + isActive = isHolding || isReturning, + isMicVisible = recordingState is RecordingState.Idle || isHolding || isReturning, + holdOffset = holdOffset, + floatingOffset = IntOffset( + x = floatingOffsetX.value.roundToInt(), + y = floatingOffsetY.value.roundToInt(), + ), + ) +} + +@Composable +private fun MicButton( + isVisible: Boolean, + recordingState: RecordingState, + recordingActions: AudioRecordingActions, + floatingActive: Boolean, + floatingOffset: IntOffset, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val style = ChatTheme.messageComposerTheme.audioRecording.recordButton + + Box(modifier = modifier) { + MicButtonGestureArea( + isVisible = isVisible, + floatingActive = floatingActive, + recordingState = recordingState, + recordingActions = recordingActions, + interactionSource = interactionSource, + ) + + if (floatingActive) { + Popup( + offset = floatingOffset, + alignment = Alignment.TopStart, + properties = PopupProperties(clippingEnabled = false), + ) { + MicButtonVisual( + interactionSource = interactionSource, + isPressed = true, + modifier = Modifier.size(style.size), + ) + } + } + } +} + +/** Gesture target for the mic button: handles touch, permission gating, and recording hints. */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun MicButtonGestureArea( + isVisible: Boolean, + floatingActive: Boolean, + recordingState: RecordingState, + recordingActions: AudioRecordingActions, + interactionSource: MutableInteractionSource, +) { + var isFingerDown by remember { mutableStateOf(false) } + var pressOffset by remember { mutableStateOf(Offset.Zero) } + val permissionState = rememberAudioRecordingPermission() + val hint = rememberRecordingHint() + val currentState by rememberUpdatedState(recordingState) + val hapticFeedback = LocalHapticFeedback.current + + val density = LocalDensity.current + val gestureConfig = RecordingGestureConfig( + cancelThresholdPx = with(density) { + ChatTheme.messageComposerTheme.audioRecording.slideToCancel.threshold.toPx() + }, + lockThresholdPx = with(density) { + ChatTheme.messageComposerTheme.audioRecording.floatingIcons.lockThreshold.toPx() + }, + ) + + PressInteractionEffect(isFingerDown, pressOffset, interactionSource) + + val style = ChatTheme.messageComposerTheme.audioRecording.recordButton + val buttonDescription = stringResource(R.string.stream_compose_cd_record_audio_message) + + Box( + modifier = Modifier + .run { if (isVisible) size(style.size) else size(0.dp) } + .semantics { contentDescription = buttonDescription } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + down.consume() + pressOffset = down.position + isFingerDown = true + hint.dismiss() + + if (permissionState.gateRecording()) { + handleRecordingGesture( + down = down, + config = gestureConfig, + currentState = { currentState }, + recordingActions = recordingActions.withHapticOnStart(hapticFeedback), + onShowHint = { hint.show() }, + ) + } + + isFingerDown = false + } + }, + contentAlignment = Alignment.Center, + ) { + if (isVisible && !floatingActive) { + MicButtonVisual( + interactionSource = interactionSource, + modifier = Modifier.matchParentSize(), + ) + } + } + + SnackbarPopup( + hostState = hint.snackbarHostState, + snackbar = { AudioRecordingSnackbar(it) }, + ) +} + +/** Emits press/release interactions on [interactionSource] while [isFingerDown] is true. */ +@Composable +private fun PressInteractionEffect( + isFingerDown: Boolean, + pressOffset: Offset, + interactionSource: MutableInteractionSource, +) { + LaunchedEffect(isFingerDown) { + if (isFingerDown) { + val press = PressInteraction.Press(pressOffset) + interactionSource.emit(press) + try { + awaitCancellation() + } finally { + interactionSource.tryEmit(PressInteraction.Release(press)) + } + } + } +} + +private const val PressedOverlayAlpha = 0.12f + +@Composable +private fun MicButtonVisual( + interactionSource: MutableInteractionSource, + isPressed: Boolean = false, + modifier: Modifier = Modifier, +) { + val style = ChatTheme.messageComposerTheme.audioRecording.recordButton + val layoutDirection = LocalLayoutDirection.current + Box( + modifier = modifier + .padding(style.padding) + .clip(CircleShape) + .indication( + interactionSource = interactionSource, + indication = ripple(), + ) + .then( + if (isPressed) { + Modifier.background( + ChatTheme.colors.textPrimary.copy(alpha = PressedOverlayAlpha), + ) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .mirrorRtl(layoutDirection = layoutDirection) + .size(style.icon.size) + .testTag("Stream_ComposerRecordAudioButton"), + painter = style.icon.painter, + contentDescription = stringResource(R.string.stream_compose_record_audio_message), + ) + } +} + +private class RecordingHintState( + val snackbarHostState: SnackbarHostState, + private val scope: CoroutineScope, + private val message: String, +) { + fun show() { + scope.launch { + snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short) + } + } + + fun dismiss() { + snackbarHostState.currentSnackbarData?.dismiss() + } +} + +@Composable +private fun rememberRecordingHint(): RecordingHintState { + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val message = stringResource(R.string.stream_compose_message_composer_hold_to_record) + return remember(snackbarHostState, scope, message) { + RecordingHintState(snackbarHostState, scope, message) + } +} + +private val SnackbarShape = RoundedCornerShape(StreamTokens.radius3xl) + +@Composable +private fun AudioRecordingSnackbar(data: SnackbarData) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = StreamTokens.spacingMd), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.shadow(4.dp, shape = SnackbarShape), + shape = SnackbarShape, + color = ChatTheme.colors.backgroundCoreInverse, + contentColor = ChatTheme.colors.textOnAccent, + ) { + Text( + modifier = Modifier.padding( + horizontal = StreamTokens.spacingMd, + vertical = StreamTokens.spacingSm, + ), + text = data.visuals.message, + style = ChatTheme.typography.bodyDefault, + ) + } + } +} + +/** Floating lock icon that follows the drag offset during Hold, or snaps above controls when Locked. */ +@Composable +private fun FloatingLockIcon( + isLocked: Boolean, + holdControlsOffset: IntOffset, +) { + val density = LocalDensity.current + val playbackHeight = ChatTheme.messageComposerTheme.audioRecording.playback.height + val controlsHeight = ChatTheme.messageComposerTheme.audioRecording.controls.height + val totalContentHeight = playbackHeight + controlsHeight + val edgeOffset = ChatTheme.messageComposerTheme.audioRecording.floatingIcons.lockEdgeOffset + val lockOffset = with(density) { + IntOffset( + x = -edgeOffset.x.toPx().toInt(), + y = when (isLocked) { + true -> -totalContentHeight.toPx().toInt() - edgeOffset.y.toPx().toInt() + else -> -playbackHeight.toPx().toInt() - edgeOffset.y.toPx().toInt() + holdControlsOffset.y + }, + ) + } + Popup( + offset = lockOffset, + alignment = Alignment.BottomEnd, + ) { + RecordingLockableIcon(locked = isLocked) + } +} + +@Composable +private fun RecordingLockableIcon(locked: Boolean) { + val style = if (locked) { + ChatTheme.messageComposerTheme.audioRecording.floatingIcons.locked + } else { + ChatTheme.messageComposerTheme.audioRecording.floatingIcons.lock + } + RecordingFloatingIcon(style) +} + +@Composable +private fun RecordingFloatingIcon(style: AudioRecordingFloatingIconStyle) { + Card( + modifier = Modifier + .size(style.size) + .padding(style.padding), + shape = style.backgroundShape, + colors = CardDefaults.cardColors(containerColor = style.backgroundColor), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + painter = style.icon.painter, + contentDescription = null, + modifier = Modifier.size(style.icon.size), + tint = style.icon.tint, + ) + } + } +} + +@Composable +private fun RecordingControlButtons( + isStopVisible: Boolean, + recordingActions: AudioRecordingActions, +) { + val theme = ChatTheme.messageComposerTheme.audioRecording.controls + Row( + modifier = Modifier + .fillMaxWidth() + .height(theme.height), + verticalAlignment = Alignment.CenterVertically, + ) { + ControlIconButton( + style = theme.deleteButton, + tag = "Stream_ComposerDeleteAudioRecordingButton", + onClick = recordingActions.onDeleteRecording, + ) + if (isStopVisible) { + Spacer(modifier = Modifier.weight(1f)) + ControlIconButton( + style = theme.stopButton, + tag = "Stream_ComposerStopAudioRecordingButton", + onClick = recordingActions.onStopRecording, + ) + } + Spacer(modifier = Modifier.weight(1f)) + ControlIconButton( + style = theme.completeButton, + tag = "Stream_ComposerConfirmAudioRecordingButton", + onClick = recordingActions.onConfirmRecording, + ) + } +} + +@Composable +private fun ControlIconButton( + style: IconContainerStyle, + tag: String, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier + .semantics { testTag = tag } + .size(style.size) + .padding(style.padding) + .focusable(true), + ) { + Icon( + painter = style.icon.painter, + contentDescription = null, + modifier = Modifier.size(style.icon.size), + tint = style.icon.tint, + ) + } +} + +/** + * Returns a copy of [AudioRecordingActions] whose [AudioRecordingActions.onStartRecording] also + * triggers [HapticFeedbackType.LongPress] via [hapticFeedback]. + */ +private fun AudioRecordingActions.withHapticOnStart( + hapticFeedback: HapticFeedback, +): AudioRecordingActions = copy( + onStartRecording = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onStartRecording() + }, +) + +private const val PreviewDurationInMs = 120_000 + +@Suppress("MagicNumber") +private val PreviewWaveformData = (0..10).map { + listOf(0.5f, 0.8f, 0.3f, 0.6f, 0.4f, 0.7f, 0.2f, 0.9f, 0.1f) +}.flatten() + +@Preview(showBackground = true) +@Composable +private fun AudioRecordingButtonIdlePreview() { + ChatPreviewTheme { + Box( + modifier = Modifier.size(80.dp), + contentAlignment = Alignment.Center, + ) { + AudioRecordingButton( + recordingState = RecordingState.Idle, + recordingActions = AudioRecordingActions.None, + ) + } + } +} + +@Preview(showBackground = true, heightDp = 200) +@Composable +private fun AudioRecordingButtonHoldPreview() { + ChatPreviewTheme { + AudioRecordingButtonHold() + } +} + +@Composable +internal fun AudioRecordingButtonHold() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + AudioRecordingButton( + recordingState = RecordingState.Hold( + durationInMs = PreviewDurationInMs, + waveform = PreviewWaveformData, + ), + recordingActions = AudioRecordingActions.None, + ) + } +} + +@Preview(showBackground = true, heightDp = 200) +@Composable +private fun AudioRecordingButtonLockedPreview() { + ChatPreviewTheme { + AudioRecordingButtonLocked() + } +} + +@Composable +internal fun AudioRecordingButtonLocked() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + AudioRecordingButton( + recordingState = RecordingState.Locked( + durationInMs = PreviewDurationInMs, + waveform = PreviewWaveformData, + ), + recordingActions = AudioRecordingActions.None, + ) + } +} + +@Preview(showBackground = true, heightDp = 200) +@Composable +private fun AudioRecordingButtonOverviewPreview() { + ChatPreviewTheme { + AudioRecordingButtonOverview() + } +} + +@Composable +internal fun AudioRecordingButtonOverview() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + AudioRecordingButton( + recordingState = RecordingState.Overview( + durationInMs = PreviewDurationInMs, + waveform = PreviewWaveformData, + attachment = Attachment(), + ), + recordingActions = AudioRecordingActions.None, + ) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingContent.kt new file mode 100644 index 00000000000..ec9a709791e --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingContent.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.messages.composer.internal + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.audio.PlaybackTimerText +import io.getstream.chat.android.compose.ui.components.audio.StaticWaveformSlider +import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.util.padding +import io.getstream.chat.android.compose.ui.util.size +import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState +import kotlin.math.abs + +@Composable +internal fun AudioRecordingContent( + recordingState: RecordingState, + recordingActions: AudioRecordingActions, + modifier: Modifier = Modifier, +) { + when (recordingState) { + is RecordingState.Hold -> HoldRecordContent(recordingState, modifier) + is RecordingState.Locked -> LockedRecordContent(recordingState, modifier) + is RecordingState.Overview -> OverviewRecordContent(recordingState, recordingActions, modifier) + is RecordingState.Complete, + is RecordingState.Idle, + -> Unit + } +} + +/** Finger-down state: timer counts up while the slide-to-cancel hint follows the drag. */ +@Composable +private fun HoldRecordContent( + state: RecordingState.Hold, + modifier: Modifier = Modifier, +) { + val playbackTheme = ChatTheme.messageComposerTheme.audioRecording.playback + Row( + modifier = modifier + .fillMaxWidth() + .height(playbackTheme.height), + verticalAlignment = Alignment.CenterVertically, + ) { + MicIndicatorIcon() + + PlaybackTimerText( + progress = 1f, + durationInMs = state.durationInMs, + color = ChatTheme.colors.textPrimary, + countdown = false, + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(playbackTheme.height), + contentAlignment = Alignment.CenterEnd, + ) { + RecordingSlideToCancelIndicator( + holdControlsOffset = IntOffset( + x = state.offsetX.toInt().coerceAtMost(0), + y = state.offsetY.toInt().coerceAtMost(0), + ), + ) + } + } +} + +/** Recording locked (finger released): waveform grows as recording continues. */ +@Composable +private fun LockedRecordContent( + state: RecordingState.Locked, + modifier: Modifier = Modifier, +) { + val playbackTheme = ChatTheme.messageComposerTheme.audioRecording.playback + Row( + modifier = modifier + .fillMaxWidth() + .height(playbackTheme.height), + verticalAlignment = Alignment.CenterVertically, + ) { + MicIndicatorIcon() + + PlaybackTimerText( + progress = 1f, + durationInMs = state.durationInMs, + color = ChatTheme.colors.textPrimary, + countdown = false, + ) + + StaticWaveformSlider( + modifier = Modifier + .fillMaxSize() + .padding(playbackTheme.waveformSliderPadding), + waveformData = state.waveform, + progress = 1f, + isPlaying = false, + visibleBarLimit = 100, + adjustBarWidthToLimit = true, + isThumbVisible = false, + onDragStart = {}, + onDrag = {}, + onDragStop = {}, + ) + } +} + +/** Recording stopped: user can scrub the waveform and play back before sending. */ +@Composable +private fun OverviewRecordContent( + state: RecordingState.Overview, + recordingActions: AudioRecordingActions, + modifier: Modifier = Modifier, +) { + var currentProgress by remember { mutableFloatStateOf(state.playingProgress) } + LaunchedEffect(state.playingProgress, state.durationInMs) { + currentProgress = state.playingProgress + } + + val playbackTheme = ChatTheme.messageComposerTheme.audioRecording.playback + Row( + modifier = modifier + .fillMaxWidth() + .height(playbackTheme.height), + verticalAlignment = Alignment.CenterVertically, + ) { + val btnStyle = if (state.isPlaying) playbackTheme.pauseButton else playbackTheme.playButton + IconButton( + onClick = recordingActions.onToggleRecordingPlayback, + modifier = Modifier + .size(btnStyle.size) + .padding(btnStyle.padding) + .focusable(true), + ) { + Icon( + painter = btnStyle.icon.painter, + contentDescription = null, + modifier = Modifier.size(btnStyle.size), + tint = btnStyle.icon.tint, + ) + } + + PlaybackTimerText( + progress = currentProgress, + durationInMs = state.durationInMs, + color = ChatTheme.colors.textPrimary, + countdown = false, + ) + + StaticWaveformSlider( + modifier = Modifier + .fillMaxSize() + .padding(playbackTheme.waveformSliderPadding), + waveformData = state.waveform, + progress = currentProgress, + isPlaying = state.isPlaying, + visibleBarLimit = 100, + adjustBarWidthToLimit = true, + isThumbVisible = true, + onDragStart = { currentProgress = it.also(recordingActions.onRecordingSliderDragStart) }, + onDrag = { currentProgress = it }, + onDragStop = { currentProgress = it.also(recordingActions.onRecordingSliderDragStop) }, + ) + } +} + +@Composable +private fun MicIndicatorIcon() { + val micStyle = ChatTheme.messageComposerTheme.audioRecording.playback.micIndicator + Box( + modifier = Modifier + .size(micStyle.size) + .padding(micStyle.padding), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = micStyle.icon.painter, + contentDescription = null, + modifier = Modifier.size(micStyle.size), + tint = micStyle.icon.tint, + ) + } +} + +@Composable +private fun RecordingSlideToCancelIndicator( + holdControlsOffset: IntOffset, +) { + val theme = ChatTheme.messageComposerTheme.audioRecording.slideToCancel + val cancelThresholdPx = with(LocalDensity.current) { theme.threshold.toPx() } + val dragX = abs(holdControlsOffset.x.coerceAtMost(0)).toFloat() + val progress = (dragX / cancelThresholdPx).coerceIn(0f, 1f) + + Row( + modifier = Modifier + .alpha(1f - progress) + .offset { IntOffset(holdControlsOffset.x.coerceAtMost(0), 0) }, + ) { + val iconStyle = theme.iconStyle + Icon( + modifier = Modifier.size(iconStyle.size), + painter = iconStyle.painter, + tint = iconStyle.tint, + contentDescription = null, + ) + + Text( + text = stringResource(id = R.string.stream_compose_message_composer_slide_to_cancel), + modifier = Modifier.align(Alignment.CenterVertically), + style = theme.textStyle, + ) + Spacer(modifier = Modifier.width(theme.marginEnd)) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingGesture.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingGesture.kt new file mode 100644 index 00000000000..cdc61c74c90 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingGesture.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("MatchingDeclarationName") // File groups related gesture types, not just the config class. + +package io.getstream.chat.android.compose.ui.messages.composer.internal + +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.gestures.awaitDragOrCancellation +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerInputChange +import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions +import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState +import kotlin.math.abs + +/** + * Thresholds used to interpret drag gestures during audio recording. + * + * @property cancelThresholdPx Horizontal drag distance (px) to cancel recording. + * @property lockThresholdPx Vertical drag distance (px) to lock recording. + */ +internal class RecordingGestureConfig( + val cancelThresholdPx: Float, + val lockThresholdPx: Float, +) + +@VisibleForTesting +internal enum class DragAxis { Horizontal, Vertical } + +/** + * Terminal outcome of the recording drag gesture. + */ +@VisibleForTesting +internal enum class DragResult { + /** Finger lifted — caller should complete the recording if not already locked. */ + Released, + + /** Dragged past the cancel threshold. */ + Cancel, + + /** Dragged past the lock threshold. */ + Lock, + + /** State was already [RecordingState.Locked] (e.g. from a prior frame). */ + AlreadyLocked, +} + +/** + * Handles the full gesture lifecycle: + * 1. Waits for the platform long-press timeout — if the user releases before that, + * it's a tap → [onShowHint]. + * 2. Once the threshold is reached, starts recording and tracks drag for cancel / lock / confirm. + * + * Drag is axis-locked: once the first significant movement picks a direction + * (left → cancel, up → lock), the offset is constrained to that axis. + */ +internal suspend fun AwaitPointerEventScope.handleRecordingGesture( + down: PointerInputChange, + config: RecordingGestureConfig, + currentState: () -> RecordingState, + recordingActions: AudioRecordingActions, + onShowHint: () -> Unit, +) { + if (!awaitHoldThreshold(down)) { + onShowHint() + return + } + + recordingActions.onStartRecording() + + val result = awaitDragResult(down, config, currentState, recordingActions.onHoldRecording) + handleDragResult(result, currentState, recordingActions) +} + +/** + * Waits for the platform long-press timeout. Returns `true` if the user held long enough, + * `false` if they released early (tap). + */ +private suspend fun AwaitPointerEventScope.awaitHoldThreshold( + down: PointerInputChange, +): Boolean { + val releasedEarly = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + while (true) { + val event = awaitDragOrCancellation(down.id) ?: return@withTimeoutOrNull + if (!event.pressed) return@withTimeoutOrNull + event.consume() + } + } != null + return !releasedEarly +} + +/** + * Tracks drag events after recording has started, returning a [DragResult] + * when a terminal condition is reached (release, cancel threshold, lock threshold, + * or externally locked state). + */ +private suspend fun AwaitPointerEventScope.awaitDragResult( + down: PointerInputChange, + config: RecordingGestureConfig, + currentState: () -> RecordingState, + onDragOffset: (Offset) -> Unit, +): DragResult { + val startPosition = down.position + var dragAxis: DragAxis? = null + val touchSlop = viewConfiguration.touchSlop + + while (true) { + if (currentState() is RecordingState.Locked) return DragResult.AlreadyLocked + + val dragEvent = awaitDragOrCancellation(down.id) + if (dragEvent == null || !dragEvent.pressed) return DragResult.Released + + dragEvent.consume() + val rawDiff = dragEvent.position.minus(startPosition) + dragAxis = dragAxis ?: resolveAxis(rawDiff, touchSlop) + + val constrained = constrainToAxis(rawDiff, dragAxis) + onDragOffset(constrained) + + evaluateDragThreshold(constrained, config)?.let { return it } + } +} + +/** + * Determines the drag axis once the first significant movement exceeds [touchSlop]. + * Returns `null` if the movement is still too small. + */ +@VisibleForTesting +internal fun resolveAxis(rawDiff: Offset, touchSlop: Float): DragAxis? { + val absX = abs(rawDiff.x) + val absY = abs(rawDiff.y) + if (absX <= touchSlop && absY <= touchSlop) return null + return if (absX > absY) DragAxis.Horizontal else DragAxis.Vertical +} + +/** + * Constrains a raw offset to the given [axis]. Returns [Offset.Zero] if no axis is locked yet. + */ +@VisibleForTesting +internal fun constrainToAxis(rawDiff: Offset, axis: DragAxis?): Offset = when (axis) { + DragAxis.Horizontal -> Offset(rawDiff.x, 0f) + DragAxis.Vertical -> Offset(0f, rawDiff.y) + null -> Offset.Zero +} + +/** + * Returns a terminal [DragResult] when the constrained offset crosses a gesture threshold, + * or `null` if the drag is still within bounds. + * + * Cancel (horizontal) is evaluated before lock (vertical), so a simultaneous breach favours cancel. + */ +@VisibleForTesting +internal fun evaluateDragThreshold( + constrained: Offset, + config: RecordingGestureConfig, +): DragResult? = when { + constrained.x <= -config.cancelThresholdPx -> DragResult.Cancel + constrained.y <= -config.lockThresholdPx -> DragResult.Lock + else -> null +} + +/** + * Dispatches the appropriate recording action for the given [result]. + * + * On [DragResult.Released] the recording is confirmed only when the current state is **not** + * already [RecordingState.Locked] (the lock UI handles its own send flow). + */ +@VisibleForTesting +internal fun handleDragResult( + result: DragResult, + currentState: () -> RecordingState, + recordingActions: AudioRecordingActions, +) { + when (result) { + DragResult.Released -> { + if (currentState() !is RecordingState.Locked) recordingActions.onConfirmRecording() + } + DragResult.Cancel -> recordingActions.onCancelRecording() + DragResult.Lock -> recordingActions.onLockRecording() + DragResult.AlreadyLocked -> Unit + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingPermission.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingPermission.kt new file mode 100644 index 00000000000..f547f64bbfc --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingPermission.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.messages.composer.internal + +import android.Manifest +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.window.Popup +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.SimpleDialog +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.util.padding +import io.getstream.chat.android.ui.common.utils.openSystemSettings +import kotlinx.coroutines.delay + +/** + * Wrapper around Accompanist's [rememberPermissionState]. + * + * In preview / Paparazzi environments (where there is no Activity context) this returns a + * granted [AudioRecordingPermission]. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +internal fun rememberAudioRecordingPermission(): AudioRecordingPermission { + if (LocalInspectionMode.current) { + return remember { + AudioRecordingPermission( + status = PermissionStatus.Granted, + launchPermissionRequest = {}, + showRationale = {}, + ) + } + } + + var showRationale by remember { mutableStateOf(false) } + if (showRationale) { + AudioRecordingPermissionRationale( + onDismissRequest = { showRationale = false }, + ) + } + + var showDenied by remember { mutableStateOf(false) } + val state = rememberPermissionState(Manifest.permission.RECORD_AUDIO) { granted -> + showDenied = !granted + } + if (showDenied) { + SimpleDialog( + title = stringResource(id = R.string.stream_ui_message_composer_permission_audio_record_title), + message = stringResource(id = R.string.stream_ui_message_composer_permission_audio_record_message), + onDismiss = { showDenied = false }, + onPositiveAction = { showDenied = false }, + showDismissButton = false, + ) + } + + return remember(state) { + AudioRecordingPermission( + status = state.status, + launchPermissionRequest = { state.launchPermissionRequest() }, + showRationale = { showRationale = true }, + ) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +internal class AudioRecordingPermission( + val status: PermissionStatus, + val launchPermissionRequest: () -> Unit, + val showRationale: () -> Unit, +) + +/** + * Returns `true` if the recording can proceed (permission granted). + * Otherwise, triggers the appropriate permission request or rationale dialog and returns `false`. + */ +@OptIn(ExperimentalPermissionsApi::class) +internal fun AudioRecordingPermission.gateRecording(): Boolean = when { + status.shouldShowRationale -> { + showRationale() + false + } + !status.isGranted -> { + launchPermissionRequest() + false + } + else -> true +} + +/** + * A popup anchored at [Alignment.BottomCenter] that auto-dismisses after [dismissTimeoutMs]. + */ +@Composable +private fun TimedPopup( + offsetY: Int, + dismissTimeoutMs: Long = 1000L, + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) { + LaunchedEffect(Unit) { + delay(dismissTimeoutMs) + onDismissRequest() + } + Popup( + onDismissRequest = onDismissRequest, + offset = IntOffset(0, -offsetY), + alignment = Alignment.BottomCenter, + ) { + content() + } +} + +@Composable +private fun AudioRecordingPermissionRationale( + onDismissRequest: () -> Unit, +) { + val theme = ChatTheme.messageComposerTheme.audioRecording.permissionRationale + val offsetY = with(LocalDensity.current) { theme.containerBottomOffset.toPx().toInt() } + TimedPopup(offsetY = offsetY, onDismissRequest = onDismissRequest) { + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(theme.containerPadding), + elevation = CardDefaults.cardElevation(defaultElevation = theme.containerElevation), + shape = theme.containerShape, + colors = CardDefaults.cardColors(containerColor = theme.containerColor), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(theme.contentHeight) + .padding(theme.contentPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + style = theme.textStyle, + text = stringResource(id = R.string.stream_ui_message_composer_permission_audio_record_message), + ) + Spacer(modifier = Modifier.width(theme.contentSpace)) + val context = LocalContext.current + TextButton( + modifier = Modifier, + onClick = { context.openSystemSettings() }, + ) { + Text( + style = theme.buttonTextStyle, + text = stringResource(id = R.string.stream_ui_message_composer_permissions_setting_button) + .uppercase(), + ) + } + } + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContent.kt deleted file mode 100644 index d479c4d5a32..00000000000 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContent.kt +++ /dev/null @@ -1,865 +0,0 @@ -/* - * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.chat.android.compose.ui.messages.composer.internal - -import android.Manifest -import android.os.SystemClock -import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.awaitDragOrCancellation -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.indication -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.PressInteraction -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.BottomCenter -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTag -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.google.accompanist.permissions.shouldShowRationale -import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.ui.components.SimpleDialog -import io.getstream.chat.android.compose.ui.components.audio.PlaybackTimerText -import io.getstream.chat.android.compose.ui.components.audio.StaticWaveformSlider -import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions -import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme -import io.getstream.chat.android.compose.ui.theme.ChatTheme -import io.getstream.chat.android.compose.ui.theme.messages.composer.AudioRecordingFloatingIconStyle -import io.getstream.chat.android.compose.ui.util.mirrorRtl -import io.getstream.chat.android.compose.ui.util.padding -import io.getstream.chat.android.compose.ui.util.size -import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState -import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState -import io.getstream.chat.android.ui.common.utils.openSystemSettings -import kotlinx.coroutines.delay -import kotlin.math.abs - -private const val HOLD_TO_RECORD_THRESHOLD = 1000L -private const val HOLD_TO_RECORD_DISMISS_TIMEOUT = 1000L -private const val PERMISSION_RATIONALE_DISMISS_TIMEOUT = 1000L - -/** - * Default implementation of the audio record button. - * - * @param state The current recording state. - * @param recordingActions The actions to perform on the recording. - * @param holdToRecordThreshold The threshold to hold to record. - * @param holdToRecordDismissTimeout The timeout to dismiss the hold to record popup. - * @param permissionRationaleDismissTimeout The timeout to dismiss the permission rationale popup. - */ -@Composable -@OptIn(ExperimentalPermissionsApi::class) -public fun DefaultAudioRecordButton( - state: RecordingState, - recordingActions: AudioRecordingActions = AudioRecordingActions.None, - holdToRecordThreshold: Long = HOLD_TO_RECORD_THRESHOLD, - holdToRecordDismissTimeout: Long = HOLD_TO_RECORD_DISMISS_TIMEOUT, - permissionRationaleDismissTimeout: Long = PERMISSION_RATIONALE_DISMISS_TIMEOUT, -) { - val layoutDirection = LocalLayoutDirection.current - val recordAudioButtonDescription = stringResource(id = R.string.stream_compose_cd_record_audio_message) - - var micSize by remember { mutableStateOf(IntSize.Zero) } - - var showDurationWarning by remember { mutableStateOf(false) } - if (showDurationWarning) { - DefaultHoldToRecordPopup( - offset = micSize.height, - dismissTimeoutMs = holdToRecordDismissTimeout, - onDismissRequest = { showDurationWarning = false }, - ) - } - - var showPermissionRationale by remember { mutableStateOf(false) } - if (showPermissionRationale) { - DefaultAudioRecordPermissionRationale( - dismissTimeoutMs = permissionRationaleDismissTimeout, - onDismissRequest = { showPermissionRationale = false }, - ) - } - - var showPermissionDenied by remember { mutableStateOf(false) } - val permissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) { permissionGranted -> - showPermissionDenied = !permissionGranted - } - if (showPermissionDenied) { - SimpleDialog( - title = stringResource(id = R.string.stream_ui_message_composer_permission_audio_record_title), - message = stringResource(id = R.string.stream_ui_message_composer_permission_audio_record_message), - onDismiss = { showPermissionDenied = false }, - onPositiveAction = { showPermissionDenied = false }, - showDismissButton = false, - ) - } - - val style = ChatTheme.messageComposerTheme.audioRecording.recordButton - val isRecording = state !is RecordingState.Idle - val interactionSource = remember { MutableInteractionSource() } - val currentState by rememberUpdatedState(state) - - val density = LocalDensity.current - val cancelThresholdX = with(density) { - ChatTheme.messageComposerTheme.audioRecording.slideToCancel.threshold.toPx().toInt() - } - val lockThresholdY = with(density) { - ChatTheme.messageComposerTheme.audioRecording.floatingIcons.lockThreshold.toPx().toInt() - } - Box( - modifier = Modifier - .run { if (isRecording) size(0.dp) else size(style.size) } - .padding(style.padding) - .onSizeChanged { micSize = it } - .indication( - interactionSource, - ripple( - bounded = true, - radius = with(density) { micSize.height.toDp() / 2 }, - ), - ) - .semantics { contentDescription = recordAudioButtonDescription } - .pointerInput(Unit) { - awaitEachGesture { - // Await the first pointer down event - val downEvent = awaitFirstDown() - downEvent.consume() - - // Trigger ripple when the gesture starts - interactionSource.tryEmit(PressInteraction.Press(downEvent.position)) - - if (permissionState.status.shouldShowRationale) { - showPermissionRationale = true - } else if (!permissionState.status.isGranted) { - permissionState.launchPermissionRequest() - } else { - val downOffset = downEvent.position - val holdStartTime = SystemClock.elapsedRealtime() - val startOffset = downOffset.minus(Offset(micSize.width.toFloat(), micSize.height.toFloat())) - recordingActions.onStartRecording(Offset.Zero) - - // Await drag events - while (true) { - // If the recording is already locked, exit the drag loop - if (currentState is RecordingState.Locked) { - break - } - - val dragEvent = awaitDragOrCancellation(downEvent.id) - if (dragEvent == null || !dragEvent.pressed) { - // On release, only perform actions if not already locked. - // The currentState can change during awaitDragOrCancellation, so this check is needed. - @Suppress("KotlinConstantConditions") - if (currentState !is RecordingState.Locked) { - val holdElapsedTime = SystemClock.elapsedRealtime() - holdStartTime - if (holdElapsedTime < holdToRecordThreshold) { - recordingActions.onCancelRecording() - showDurationWarning = true - } else { - recordingActions.onSendRecording() - } - } - break - } - dragEvent.consume() - val diffOffset = dragEvent.position.minus(startOffset) - recordingActions.onHoldRecording(diffOffset) - - if (diffOffset.x <= -cancelThresholdX) { - recordingActions.onCancelRecording() - break - } else if (diffOffset.y <= -lockThresholdY) { - recordingActions.onLockRecording() - break - } - } - } - - // End the ripple when the gesture is complete - interactionSource.tryEmit(PressInteraction.Release(PressInteraction.Press(downEvent.position))) - } - }, - contentAlignment = Alignment.Center, - ) { - Icon( - modifier = Modifier - .mirrorRtl(layoutDirection = layoutDirection) - .size(style.icon.size) - .testTag("Stream_ComposerRecordAudioButton"), - painter = style.icon.painter, - contentDescription = stringResource(id = R.string.stream_compose_record_audio_message), - ) - } -} - -/** - * Default implementation of the hold to record popup. - */ -@Composable -internal fun DefaultHoldToRecordPopup( - offset: Int, - dismissTimeoutMs: Long = 1000L, - onDismissRequest: () -> Unit, -) { - LaunchedEffect(Unit) { - delay(dismissTimeoutMs) - onDismissRequest() - } - Popup( - onDismissRequest = onDismissRequest, - offset = IntOffset(0, -offset), - alignment = BottomCenter, - ) { - val theme = ChatTheme.messageComposerTheme.audioRecording.holdToRecord - Card( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(theme.containerPadding), - elevation = CardDefaults.cardElevation(defaultElevation = theme.containerElevation), - shape = theme.containerShape, - colors = CardDefaults.cardColors(containerColor = theme.containerColor), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(theme.contentHeight) - .padding(theme.contentPadding), - contentAlignment = Alignment.CenterStart, - ) { - Text( - style = theme.textStyle, - text = stringResource(id = R.string.stream_compose_message_composer_hold_to_record), - ) - } - } - } -} - -@Composable -internal fun DefaultAudioRecordPermissionRationale( - dismissTimeoutMs: Long = 1000L, - onDismissRequest: () -> Unit, -) { - LaunchedEffect(Unit) { - delay(dismissTimeoutMs) - onDismissRequest() - } - val theme = ChatTheme.messageComposerTheme.audioRecording.permissionRationale - val offsetY = with(LocalDensity.current) { - theme.containerBottomOffset.toPx() - } - Popup( - onDismissRequest = onDismissRequest, - offset = IntOffset(0, -offsetY.toInt()), - alignment = BottomCenter, - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(theme.containerPadding), - elevation = CardDefaults.cardElevation(defaultElevation = theme.containerElevation), - shape = theme.containerShape, - colors = CardDefaults.cardColors(containerColor = theme.containerColor), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(theme.contentHeight) - .padding(theme.contentPadding), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.weight(1f), - style = theme.textStyle, - text = stringResource(id = R.string.stream_ui_message_composer_permission_audio_record_message), - ) - Spacer(modifier = Modifier.width(theme.contentSpace)) - val context = LocalContext.current - TextButton( - modifier = Modifier, - onClick = { context.openSystemSettings() }, - ) { - Text( - style = theme.buttonTextStyle, - text = stringResource(id = R.string.stream_ui_message_composer_permissions_setting_button) - .uppercase(), - ) - } - } - } - } -} - -/** - * Default implementation of the audio recording content. - */ -@Composable -public fun DefaultMessageComposerRecordingContent( - messageComposerState: MessageComposerState, - recordingActions: AudioRecordingActions = AudioRecordingActions.None, -) { - val recordingState = messageComposerState.recording - - val durationInMs = when (recordingState) { - is RecordingState.Recording -> recordingState.durationInMs - is RecordingState.Overview -> recordingState.durationInMs - else -> 0 - } - - val waveformVisible = when (recordingState) { - is RecordingState.Locked, - is RecordingState.Overview, - -> true - else -> false - } - - val waveformData = when (recordingState) { - is RecordingState.Recording -> recordingState.waveform - is RecordingState.Overview -> recordingState.waveform - else -> emptyList() - } - - val waveformThumbVisible = recordingState is RecordingState.Overview - val waveformPlaying = recordingState is RecordingState.Overview && recordingState.isPlaying - - val waveformProgress = when (recordingState) { - is RecordingState.Overview -> recordingState.playingProgress - is RecordingState.Locked -> 1f // Locked state should color all bars as passed - else -> 0f - } - - val slideToCancelVisible = recordingState is RecordingState.Hold - - val holdControlsVisible = recordingState.let { it is RecordingState.Hold || it is RecordingState.Locked } - val holdControlsLocked = recordingState is RecordingState.Locked - val holdControlsOffset = when (recordingState) { - is RecordingState.Hold -> IntOffset( - x = recordingState.offsetX.toInt().coerceAtMost(maximumValue = 0), - y = recordingState.offsetY.toInt().coerceAtMost(maximumValue = 0), - ) - else -> IntOffset.Zero - } - - val recordingControlsVisible = when (recordingState) { - is RecordingState.Locked, - is RecordingState.Overview, - -> true - else -> false - } - - val recordingStopControlVisible = recordingState is RecordingState.Locked - - DefaultMessageComposerRecordingContent( - durationInMs = durationInMs, - waveformVisible = waveformVisible, - waveformData = waveformData, - waveformPlaying = waveformPlaying, - waveformProgress = waveformProgress, - slideToCancelVisible = slideToCancelVisible, - waveformThumbVisible = waveformThumbVisible, - holdControlsVisible = holdControlsVisible, - holdControlsLocked = holdControlsLocked, - holdControlsOffset = holdControlsOffset, - recordingControlsVisible = recordingControlsVisible, - recordingStopControlVisible = recordingStopControlVisible, - recordingActions = recordingActions, - ) -} - -@Composable -private fun DefaultMessageComposerRecordingContent( - modifier: Modifier = Modifier, - durationInMs: Int = 0, - waveformVisible: Boolean = true, - waveformThumbVisible: Boolean = false, - waveformData: List, - waveformPlaying: Boolean = false, - waveformProgress: Float = 0f, - slideToCancelVisible: Boolean = true, - holdControlsVisible: Boolean = false, - holdControlsLocked: Boolean = false, - holdControlsOffset: IntOffset = IntOffset.Zero, - recordingControlsVisible: Boolean = true, - recordingStopControlVisible: Boolean = true, - recordingActions: AudioRecordingActions = AudioRecordingActions.None, -) { - var contentSize by remember { mutableStateOf(IntSize.Zero) } - val density = LocalDensity.current - val cancelThresholdX = with(density) { - ChatTheme.messageComposerTheme.audioRecording.slideToCancel.threshold.toPx().toInt() - } - - val cancelOffsetX = abs(holdControlsOffset.x.takeIf { it <= 0 } ?: 0).toFloat() - val slideToCancelProgress = (cancelOffsetX / cancelThresholdX).coerceIn(0f, 1f) - - Column( - modifier = modifier - .onSizeChanged { - contentSize = it - }, - ) { - RecordingContent( - durationInMs = durationInMs, - waveformVisible = waveformVisible, - waveformThumbVisible = waveformThumbVisible, - waveformData = waveformData, - waveformPlaying = waveformPlaying, - waveformProgress = waveformProgress, - slideToCancelVisible = slideToCancelVisible, - slideToCancelProgress = slideToCancelProgress, - holdControlsOffset = holdControlsOffset, - onToggleRecordingPlayback = recordingActions.onToggleRecordingPlayback, - onSliderDragStart = recordingActions.onRecordingSliderDragStart, - onSliderDragStop = recordingActions.onRecordingSliderDragStop, - ) - - if (recordingControlsVisible) { - RecordingControlButtons( - isStopControlVisible = recordingStopControlVisible, - onDeleteRecording = recordingActions.onDeleteRecording, - onStopRecording = recordingActions.onStopRecording, - onCompleteRecording = recordingActions.onCompleteRecording, - ) - } - - if (holdControlsVisible) { - if (!holdControlsLocked) { - val micBaseWidth = ChatTheme.messageComposerTheme.audioRecording.recordButton.size.width - val micFloatingWidth = ChatTheme.messageComposerTheme.audioRecording.floatingIcons.mic.size.width - val micBaseOffset = remember { - with(density) { - IntOffset( - x = ((micFloatingWidth - micBaseWidth) / 2).toPx().toInt(), - y = 0, - ) - } - } - val micOffset = micBaseOffset + holdControlsOffset - - Popup( - offset = micOffset, - properties = PopupProperties(clippingEnabled = false), - alignment = Alignment.CenterEnd, - ) { - RecordingMicIcon() - } - } - - val playbackHeight = ChatTheme.messageComposerTheme.audioRecording.playback.height - val controlsHeight = ChatTheme.messageComposerTheme.audioRecording.controls.height - val totalContentHeight = playbackHeight + controlsHeight - val edgeOffset = ChatTheme.messageComposerTheme.audioRecording.floatingIcons.lockEdgeOffset - val lockOffset = with(density) { - IntOffset( - x = -edgeOffset.x.toPx().toInt(), - y = when (holdControlsLocked) { - true -> -totalContentHeight.toPx().toInt() - edgeOffset.y.toPx().toInt() - else -> -playbackHeight.toPx().toInt() - edgeOffset.y.toPx().toInt() + holdControlsOffset.y - }, - ) - } - - Popup( - offset = lockOffset, - alignment = Alignment.BottomEnd, - ) { - RecordingLockableIcon(locked = holdControlsLocked) - } - } - } -} - -@Composable -private fun RecordingContent( - modifier: Modifier = Modifier, - durationInMs: Int = 0, - waveformVisible: Boolean = true, - waveformThumbVisible: Boolean = false, - waveformData: List, - waveformPlaying: Boolean = false, - waveformProgress: Float = 0f, - slideToCancelVisible: Boolean = true, - slideToCancelProgress: Float = 0f, - holdControlsOffset: IntOffset = IntOffset.Zero, - onToggleRecordingPlayback: () -> Unit, - onSliderDragStart: (Float) -> Unit, - onSliderDragStop: (Float) -> Unit, -) { - val playbackTheme = ChatTheme.messageComposerTheme.audioRecording.playback - Row( - modifier = modifier - .fillMaxWidth() - .height(playbackTheme.height), - verticalAlignment = Alignment.CenterVertically, - ) { - if (waveformThumbVisible) { - val btnStyle = when (waveformPlaying) { - true -> playbackTheme.pauseButton - else -> playbackTheme.playButton - } - IconButton( - onClick = onToggleRecordingPlayback, - modifier = Modifier - .size(btnStyle.size) - .padding(btnStyle.padding) - .focusable(true), - ) { - Icon( - painter = btnStyle.icon.painter, - contentDescription = null, - modifier = Modifier - .size(btnStyle.size), - tint = btnStyle.icon.tint, - ) - } - } else { - val micStyle = playbackTheme.micIndicator - Box( - modifier = Modifier - .size(micStyle.size) - .padding(micStyle.padding), - contentAlignment = Alignment.Center, - ) { - Icon( - painter = micStyle.icon.painter, - contentDescription = null, - modifier = Modifier - .size(micStyle.size), - tint = micStyle.icon.tint, - ) - } - } - - var currentProgress by remember { mutableFloatStateOf(waveformProgress) } - LaunchedEffect(waveformProgress, durationInMs) { currentProgress = waveformProgress } - - PlaybackTimerText( - progress = currentProgress, - durationInMs = durationInMs, - color = ChatTheme.colors.textPrimary, - countdown = false, - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(playbackTheme.height), - contentAlignment = Alignment.CenterEnd, - ) { - if (waveformVisible) { - StaticWaveformSlider( - modifier = Modifier - .fillMaxSize() - .align(Alignment.CenterStart) - .padding(playbackTheme.waveformSliderPadding), - waveformData = waveformData, - progress = currentProgress, - isPlaying = waveformPlaying, - visibleBarLimit = 100, - adjustBarWidthToLimit = true, - isThumbVisible = waveformThumbVisible, - onDragStart = { currentProgress = it.also(onSliderDragStart) }, - onDrag = { currentProgress = it }, - onDragStop = { currentProgress = it.also(onSliderDragStop) }, - ) - } - - if (slideToCancelVisible) { - RecordingSlideToCancelIndicator(slideToCancelProgress, holdControlsOffset) - } - } - } -} - -@Composable -private fun RecordingMicIcon() { - RecordingFloatingIcon(ChatTheme.messageComposerTheme.audioRecording.floatingIcons.mic) -} - -@Composable -private fun RecordingLockableIcon( - locked: Boolean, -) { - if (locked) { - RecordingFloatingIcon(ChatTheme.messageComposerTheme.audioRecording.floatingIcons.locked) - } else { - RecordingFloatingIcon(ChatTheme.messageComposerTheme.audioRecording.floatingIcons.lock) - } -} - -@Composable -private fun RecordingFloatingIcon( - style: AudioRecordingFloatingIconStyle, -) { - Card( - modifier = Modifier - .size(style.size) - .padding(style.padding), - shape = style.backgroundShape, - colors = CardDefaults.cardColors(containerColor = style.backgroundColor), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize(), - ) { - Icon( - painter = style.icon.painter, - contentDescription = null, - modifier = Modifier.size(style.icon.size), - tint = style.icon.tint, - ) - } - } -} - -@Composable -private fun RecordingSlideToCancelIndicator( - progress: Float = 0f, - holdControlsOffset: IntOffset, -) { - val theme = ChatTheme.messageComposerTheme.audioRecording.slideToCancel - val offsetX = abs(holdControlsOffset.x.takeIf { it <= 0 } ?: 0) - Row( - modifier = Modifier.alpha(1 - progress), - ) { - val iconStyle = theme.iconStyle - Icon( - modifier = Modifier.size(iconStyle.size), - painter = iconStyle.painter, - tint = iconStyle.tint, - contentDescription = null, - ) - - Text( - text = stringResource(id = R.string.stream_compose_message_composer_slide_to_cancel), - modifier = Modifier - .align(Alignment.CenterVertically), - style = theme.textStyle, - ) - Spacer(modifier = Modifier.width(theme.marginEnd)) - Spacer( - modifier = Modifier.width( - with(LocalDensity.current) { - offsetX.toDp() - }, - ), - ) - } -} - -@Composable -private fun RecordingControlButtons( - isStopControlVisible: Boolean, - onDeleteRecording: () -> Unit, - onStopRecording: () -> Unit, - onCompleteRecording: (Boolean) -> Unit, -) { - val sendOnComplete = ChatTheme.messageComposerTheme.audioRecording.sendOnComplete - val theme = ChatTheme.messageComposerTheme.audioRecording.controls - Row( - modifier = Modifier - .fillMaxWidth() - .height(theme.height), - verticalAlignment = Alignment.CenterVertically, - ) { - val deleteStyle = theme.deleteButton - IconButton( - onClick = onDeleteRecording, - modifier = Modifier - .semantics { - testTag = "Stream_ComposerDeleteAudioRecordingButton" - } - .size(deleteStyle.size) - .padding(deleteStyle.padding) - .focusable(true), - ) { - Icon( - painter = deleteStyle.icon.painter, - contentDescription = null, - modifier = Modifier.size(deleteStyle.icon.size), - tint = deleteStyle.icon.tint, - ) - } - - if (isStopControlVisible) { - Spacer(modifier = Modifier.weight(1f)) - val stopStyle = theme.stopButton - IconButton( - onClick = onStopRecording, - modifier = Modifier - .semantics { - testTag = "Stream_ComposerStopAudioRecordingButton" - } - .size(stopStyle.size) - .padding(stopStyle.padding) - .focusable(true), - ) { - Icon( - painter = stopStyle.icon.painter, - contentDescription = null, - modifier = Modifier.size(stopStyle.icon.size), - tint = stopStyle.icon.tint, - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - val completeStyle = theme.completeButton - IconButton( - onClick = { onCompleteRecording(sendOnComplete) }, - modifier = Modifier - .semantics { - testTag = "Stream_ComposerCompleteAudioRecordingButton" - } - .size(completeStyle.size) - .padding(completeStyle.padding) - .focusable(true), - ) { - Icon( - painter = completeStyle.icon.painter, - contentDescription = null, - modifier = Modifier - .size(completeStyle.icon.size), - tint = completeStyle.icon.tint, - ) - } - } -} - -@Preview(showBackground = true, heightDp = 200) -@Composable -private fun MessageComposerRecordingContentHoldPreview() { - ChatPreviewTheme { - MessageComposerRecordingContentHold() - } -} - -@Composable -internal fun MessageComposerRecordingContentHold() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = BottomCenter, - ) { - DefaultMessageComposerRecordingContent( - durationInMs = DurationInMs, - waveformVisible = false, - waveformData = WaveformData, - holdControlsVisible = true, - recordingControlsVisible = false, - ) - } -} - -@Preview(showBackground = true, heightDp = 200) -@Composable -private fun MessageComposerRecordingContentLockPreview() { - ChatPreviewTheme { - MessageComposerRecordingContentLock() - } -} - -@Composable -internal fun MessageComposerRecordingContentLock() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = BottomCenter, - ) { - DefaultMessageComposerRecordingContent( - durationInMs = DurationInMs, - waveformData = WaveformData, - waveformProgress = 0.3f, - slideToCancelVisible = false, - holdControlsVisible = true, - holdControlsLocked = true, - ) - } -} - -@Preview(showBackground = true, heightDp = 200) -@Composable -private fun MessageComposerRecordingContentOverviewPreview() { - ChatPreviewTheme { - MessageComposerRecordingContentOverview() - } -} - -@Composable -internal fun MessageComposerRecordingContentOverview() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = BottomCenter, - ) { - DefaultMessageComposerRecordingContent( - durationInMs = DurationInMs, - waveformThumbVisible = true, - waveformData = WaveformData, - slideToCancelVisible = false, - recordingStopControlVisible = false, - ) - } -} - -private const val DurationInMs = 120_000 - -@Suppress("MagicNumber") -private val WaveformData = (0..10).map { - listOf(0.5f, 0.8f, 0.3f, 0.6f, 0.4f, 0.7f, 0.2f, 0.9f, 0.1f) -}.flatten() diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/MessageComposerDefaults.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/MessageComposerDefaults.kt index 8ff96028cf6..a9f9412c3ff 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/MessageComposerDefaults.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/MessageComposerDefaults.kt @@ -19,7 +19,6 @@ package io.getstream.chat.android.compose.ui.messages.composer.internal import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.border import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -44,13 +43,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.ui.components.composer.MessageInput -import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.mirrorRtl import io.getstream.chat.android.compose.ui.util.padding import io.getstream.chat.android.compose.ui.util.size -import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.LinkPreview import io.getstream.chat.android.ui.common.feature.messages.composer.capabilities.canSendMessage import io.getstream.chat.android.ui.common.feature.messages.composer.capabilities.canUploadFile @@ -171,56 +167,6 @@ internal fun DefaultMessageComposerLeadingContent( private const val OpenAttachmentPickerButtonRotation = 225f -@Suppress("LongParameterList") -@Composable -internal fun RowScope.DefaultMessageComposerInput( - messageComposerState: MessageComposerState, - onValueChange: (String) -> Unit, - onAttachmentRemoved: (Attachment) -> Unit, - onLinkPreviewClick: ((LinkPreview) -> Unit)?, - onCancelAction: () -> Unit, - onCancelLinkPreviewClick: (() -> Unit)? = null, - onSendClick: (String, List) -> Unit, - recordingActions: AudioRecordingActions, - leadingContent: @Composable RowScope.() -> Unit = { - ChatTheme.componentFactory.MessageComposerInputLeadingContent( - state = messageComposerState, - ) - }, - centerContent: @Composable (modifier: Modifier) -> Unit = { modifier -> - ChatTheme.componentFactory.MessageComposerInputCenterContent( - state = messageComposerState, - onValueChange = onValueChange, - modifier = modifier, - ) - }, - trailingContent: @Composable RowScope.() -> Unit = { - ChatTheme.componentFactory.MessageComposerInputTrailingContent( - state = messageComposerState, - recordingActions = recordingActions, - onSendClick = onSendClick, - ) - }, -) { - val isRecording = messageComposerState.recording !is RecordingState.Idle - if (!isRecording) { - MessageInput( - modifier = Modifier.weight(1f), - messageComposerState = messageComposerState, - onValueChange = onValueChange, - onAttachmentRemoved = onAttachmentRemoved, - onCancelAction = onCancelAction, - onLinkPreviewClick = onLinkPreviewClick, - onCancelLinkPreviewClick = onCancelLinkPreviewClick, - onSendClick = onSendClick, - recordingActions = recordingActions, - leadingContent = leadingContent, - centerContent = centerContent, - trailingContent = trailingContent, - ) - } -} - /** * Default implementation of the "Send" button. */ diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 82cad51371f..3df1ffdb3fe 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -126,6 +126,7 @@ import io.getstream.chat.android.compose.ui.components.channels.MessageReadStatu import io.getstream.chat.android.compose.ui.components.channels.UnreadCountIndicator import io.getstream.chat.android.compose.ui.components.composer.ComposerLinkPreview import io.getstream.chat.android.compose.ui.components.composer.CoolDownIndicator +import io.getstream.chat.android.compose.ui.components.composer.MessageInput import io.getstream.chat.android.compose.ui.components.composer.MessageInputOptions import io.getstream.chat.android.compose.ui.components.messageoptions.MessageOptions import io.getstream.chat.android.compose.ui.components.messages.DefaultMessageContent @@ -161,12 +162,10 @@ import io.getstream.chat.android.compose.ui.components.suggestions.mentions.Ment import io.getstream.chat.android.compose.ui.components.userreactions.UserReactions import io.getstream.chat.android.compose.ui.messages.attachments.factory.AttachmentPickerAction import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions -import io.getstream.chat.android.compose.ui.messages.composer.internal.DefaultAudioRecordButton +import io.getstream.chat.android.compose.ui.messages.composer.internal.AudioRecordingButton import io.getstream.chat.android.compose.ui.messages.composer.internal.DefaultMessageComposerFooterInThreadMode import io.getstream.chat.android.compose.ui.messages.composer.internal.DefaultMessageComposerHeaderContent -import io.getstream.chat.android.compose.ui.messages.composer.internal.DefaultMessageComposerInput import io.getstream.chat.android.compose.ui.messages.composer.internal.DefaultMessageComposerLeadingContent -import io.getstream.chat.android.compose.ui.messages.composer.internal.DefaultMessageComposerRecordingContent import io.getstream.chat.android.compose.ui.messages.composer.internal.SendButton import io.getstream.chat.android.compose.ui.messages.header.DefaultMessageListHeaderCenterContent import io.getstream.chat.android.compose.ui.messages.header.DefaultMessageListHeaderLeadingContent @@ -1425,7 +1424,6 @@ public interface ChatComponentFactory { commandPopupContent: @Composable (List) -> Unit, leadingContent: @Composable RowScope.(MessageComposerState) -> Unit, input: @Composable RowScope.(MessageComposerState) -> Unit, - audioRecordingContent: @Composable RowScope.(MessageComposerState) -> Unit, trailingContent: @Composable (MessageComposerState) -> Unit, ) { io.getstream.chat.android.compose.ui.messages.composer.MessageComposer( @@ -1448,7 +1446,6 @@ public interface ChatComponentFactory { commandPopupContent = commandPopupContent, leadingContent = leadingContent, input = input, - audioRecordingContent = audioRecordingContent, trailingContent = trailingContent, ) } @@ -1723,7 +1720,8 @@ public interface ChatComponentFactory { centerContent: @Composable (Modifier) -> Unit, trailingContent: @Composable RowScope.() -> Unit, ) { - DefaultMessageComposerInput( + MessageInput( + modifier = Modifier.weight(1f), messageComposerState = state, onValueChange = onInputChanged, onAttachmentRemoved = onAttachmentRemoved, @@ -1882,21 +1880,10 @@ public interface ChatComponentFactory { state: RecordingState, recordingActions: AudioRecordingActions, ) { - DefaultAudioRecordButton(state, recordingActions) - } - - /** - * Default composable used for displaying audio recording information while audio recording is in progress. - * - * @param state The current state of the message composer. - * @param recordingActions The actions to control the audio recording. - */ - @Composable - public fun RowScope.MessageComposerAudioRecordingContent( - state: MessageComposerState, - recordingActions: AudioRecordingActions, - ) { - DefaultMessageComposerRecordingContent(state, recordingActions) + AudioRecordingButton( + recordingState = state, + recordingActions = recordingActions, + ) } /** diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamColors.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamColors.kt index 1bca984a034..0b7fe7a53b7 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamColors.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamColors.kt @@ -72,6 +72,8 @@ import io.getstream.chat.android.compose.R * @param backgroundCoreDisabled Used for disabled background in components like buttons. * @param backgroundCoreSurface Used for surface background in components like buttons. * @param backgroundCoreSurfaceSubtle Used for subtle surface backgrounds. + * @param backgroundCoreInverse Used for elevated, transient, or high-attention UI surfaces that + * sit on top of the default app background. * @param backgroundElevationElevation0 Used for base elevation surface backgrounds. * @param borderCoreImage Used for image frame border treatment. * @param borderCoreDefault Used for default border color. @@ -184,6 +186,7 @@ public data class StreamColors( public val backgroundCoreDisabled: Color, public val backgroundCoreSurface: Color, public val backgroundCoreSurfaceSubtle: Color, + public val backgroundCoreInverse: Color, public val backgroundElevationElevation0: Color, public val borderCoreImage: Color, public val borderCoreDefault: Color, @@ -297,6 +300,7 @@ public data class StreamColors( backgroundCoreDisabled = StreamPrimitiveColors.slate200, backgroundCoreSurface = StreamPrimitiveColors.slate100, backgroundCoreSurfaceSubtle = StreamPrimitiveColors.slate200, + backgroundCoreInverse = StreamPrimitiveColors.slate900, backgroundElevationElevation0 = StreamPrimitiveColors.baseWhite, backgroundElevationElevation2 = StreamPrimitiveColors.baseWhite, badgeBgInverse = StreamPrimitiveColors.baseBlack, @@ -385,12 +389,13 @@ public data class StreamColors( accentBlack = StreamPrimitiveColors.baseBlack, accentError = StreamPrimitiveColors.red400, - backgroundCoreSurfaceSubtle = StreamPrimitiveColors.neutral800, accentNeutral = StreamPrimitiveColors.neutral500, accentPrimary = StreamPrimitiveColors.blue400, accentSuccess = StreamPrimitiveColors.green400, backgroundCoreDisabled = StreamPrimitiveColors.slate800, backgroundCoreSurface = StreamPrimitiveColors.neutral700, + backgroundCoreSurfaceSubtle = StreamPrimitiveColors.neutral800, + backgroundCoreInverse = StreamPrimitiveColors.neutral50, backgroundElevationElevation0 = StreamPrimitiveColors.baseBlack, backgroundElevationElevation2 = StreamPrimitiveColors.neutral800, borderCoreDefault = StreamPrimitiveColors.neutral600, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AboveAnchorPopupPositionProvider.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/SnackbarPopup.kt similarity index 52% rename from stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AboveAnchorPopupPositionProvider.kt rename to stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/SnackbarPopup.kt index f684768b7da..47a558f519a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AboveAnchorPopupPositionProvider.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/SnackbarPopup.kt @@ -16,6 +16,11 @@ package io.getstream.chat.android.compose.ui.util +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize @@ -24,18 +29,33 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider /** - * Calculates the position of the suggestion list [Popup] on the screen. + * A snackbar wrapped inside of a popup allowing it be displayed above the Composable it's anchored to. + * + * @param hostState The state of this component to read and show Snackbars accordingly + * @param snackbar The Snackbar to be shown at the appropriate time + * with appearance based on the SnackbarData provided as a param */ -internal class AboveAnchorPopupPositionProvider : PopupPositionProvider { +@Composable +internal fun SnackbarPopup( + hostState: SnackbarHostState, + snackbar: @Composable (SnackbarData) -> Unit = { Snackbar(it) }, +) { + Popup(popupPositionProvider = AboveAnchorPopupPositionProvider) { + SnackbarHost( + hostState = hostState, + snackbar = snackbar, + ) + } +} + +internal object AboveAnchorPopupPositionProvider : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize, - ): IntOffset { - return IntOffset( - x = 0, - y = anchorBounds.top - popupContentSize.height, - ) - } + ) = IntOffset( + x = 0, + y = anchorBounds.top - popupContentSize.height, + ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index a11e4d6ebbf..e9ac02f03d8 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -268,7 +268,7 @@ public class MessageComposerViewModel( */ public fun clearData(): Unit = messageComposerController.clearData() - public fun startRecording(offset: Pair): Unit = messageComposerController.startRecording(offset) + public fun startRecording(): Unit = messageComposerController.startRecording() public fun holdRecording(offset: Pair): Unit = messageComposerController.holdRecording(offset) diff --git a/stream-chat-android-compose/src/main/res/values/strings.xml b/stream-chat-android-compose/src/main/res/values/strings.xml index bceab6c07d2..20e2298d042 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -149,7 +149,7 @@ Slide to cancel - Hold to start, release to send. + Hold to record. Release to send Voice message diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/MessageComposerScreenTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/MessageComposerScreenTest.kt index a3a0f8cae06..a68c5a35a96 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/MessageComposerScreenTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/MessageComposerScreenTest.kt @@ -117,7 +117,7 @@ internal class MessageComposerScreenTest : MockedChatClientTest { } } - composeTestRule.onNodeWithText("00:00").assertExists() + composeTestRule.onNodeWithText("02:00").assertExists() composeTestRule.onNodeWithText("Slide to cancel").assertExists() } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContentTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingButtonTest.kt similarity index 76% rename from stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContentTest.kt rename to stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingButtonTest.kt index 9dbf393e20b..180904729c5 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContentTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingButtonTest.kt @@ -22,29 +22,29 @@ import io.getstream.chat.android.compose.ui.PaparazziComposeTest import org.junit.Rule import org.junit.Test -internal class DefaultMessageComposerRecordingContentTest : PaparazziComposeTest { +internal class AudioRecordingButtonTest : PaparazziComposeTest { @get:Rule override val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_2) @Test - fun `recording content hold`() { + fun `button hold`() { snapshotWithDarkMode { - MessageComposerRecordingContentHold() + AudioRecordingButtonHold() } } @Test - fun `recording content lock`() { + fun `button locked`() { snapshotWithDarkMode { - MessageComposerRecordingContentLock() + AudioRecordingButtonLocked() } } @Test - fun `recording content overview`() { + fun `button overview`() { snapshotWithDarkMode { - MessageComposerRecordingContentOverview() + AudioRecordingButtonOverview() } } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingGestureTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingGestureTest.kt new file mode 100644 index 00000000000..3745de0012d --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingGestureTest.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.messages.composer.internal + +import androidx.compose.ui.geometry.Offset +import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions +import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState +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.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +internal class AudioRecordingGestureTest { + + // -- resolveAxis ------------------------------------------------------- + + @Nested + inner class ResolveAxisTest { + + @Test + fun `returns null when both axes are within touch slop`() { + val result = resolveAxis(Offset(5f, 5f), touchSlop = 10f) + assertNull(result) + } + + @Test + fun `returns null when exactly at touch slop boundary`() { + val result = resolveAxis(Offset(10f, 10f), touchSlop = 10f) + assertNull(result) + } + + @Test + fun `returns Horizontal when horizontal exceeds vertical`() { + val result = resolveAxis(Offset(20f, 5f), touchSlop = 10f) + assertEquals(DragAxis.Horizontal, result) + } + + @Test + fun `returns Vertical when vertical exceeds horizontal`() { + val result = resolveAxis(Offset(5f, 20f), touchSlop = 10f) + assertEquals(DragAxis.Vertical, result) + } + + @Test + fun `returns Vertical when both axes are equal and exceed slop`() { + val result = resolveAxis(Offset(15f, 15f), touchSlop = 10f) + assertEquals(DragAxis.Vertical, result) + } + + @Test + fun `handles negative horizontal offset`() { + val result = resolveAxis(Offset(-20f, -5f), touchSlop = 10f) + assertEquals(DragAxis.Horizontal, result) + } + + @Test + fun `handles negative vertical offset`() { + val result = resolveAxis(Offset(-5f, -20f), touchSlop = 10f) + assertEquals(DragAxis.Vertical, result) + } + + @Test + fun `returns Horizontal when only x exceeds slop`() { + val result = resolveAxis(Offset(15f, 3f), touchSlop = 10f) + assertEquals(DragAxis.Horizontal, result) + } + + @Test + fun `returns Vertical when only y exceeds slop`() { + val result = resolveAxis(Offset(3f, 15f), touchSlop = 10f) + assertEquals(DragAxis.Vertical, result) + } + } + + // -- constrainToAxis --------------------------------------------------- + + @Nested + inner class ConstrainToAxisTest { + + @Test + fun `returns zero offset when axis is null`() { + val result = constrainToAxis(Offset(10f, 20f), axis = null) + assertEquals(Offset.Zero, result) + } + + @Test + fun `constrains to horizontal axis zeroing y`() { + val result = constrainToAxis(Offset(10f, 20f), DragAxis.Horizontal) + assertEquals(Offset(10f, 0f), result) + } + + @Test + fun `constrains to vertical axis zeroing x`() { + val result = constrainToAxis(Offset(10f, 20f), DragAxis.Vertical) + assertEquals(Offset(0f, 20f), result) + } + + @Test + fun `preserves negative horizontal value`() { + val result = constrainToAxis(Offset(-30f, 10f), DragAxis.Horizontal) + assertEquals(Offset(-30f, 0f), result) + } + + @Test + fun `preserves negative vertical value`() { + val result = constrainToAxis(Offset(10f, -30f), DragAxis.Vertical) + assertEquals(Offset(0f, -30f), result) + } + } + + // -- evaluateDragThreshold --------------------------------------------- + + @Nested + inner class EvaluateDragThresholdTest { + + private val config = RecordingGestureConfig( + cancelThresholdPx = 100f, + lockThresholdPx = 80f, + ) + + @Test + fun `returns null when within both thresholds`() { + val result = evaluateDragThreshold(Offset(-50f, -30f), config) + assertNull(result) + } + + @Test + fun `returns Cancel when x exceeds cancel threshold`() { + val result = evaluateDragThreshold(Offset(-150f, 0f), config) + assertEquals(DragResult.Cancel, result) + } + + @Test + fun `returns Cancel at exact cancel boundary`() { + val result = evaluateDragThreshold(Offset(-100f, 0f), config) + assertEquals(DragResult.Cancel, result) + } + + @Test + fun `returns null just before cancel threshold`() { + val result = evaluateDragThreshold(Offset(-99.9f, 0f), config) + assertNull(result) + } + + @Test + fun `returns Lock when y exceeds lock threshold`() { + val result = evaluateDragThreshold(Offset(0f, -100f), config) + assertEquals(DragResult.Lock, result) + } + + @Test + fun `returns Lock at exact lock boundary`() { + val result = evaluateDragThreshold(Offset(0f, -80f), config) + assertEquals(DragResult.Lock, result) + } + + @Test + fun `returns null just before lock threshold`() { + val result = evaluateDragThreshold(Offset(0f, -79.9f), config) + assertNull(result) + } + + @Test + fun `cancel takes priority when both thresholds are exceeded`() { + val result = evaluateDragThreshold(Offset(-150f, -100f), config) + assertEquals(DragResult.Cancel, result) + } + + @Test + fun `positive offsets never trigger thresholds`() { + val result = evaluateDragThreshold(Offset(200f, 200f), config) + assertNull(result) + } + } + + // -- handleDragResult -------------------------------------------------- + + @Nested + inner class HandleDragResultTest { + + @Test + fun `Released confirms recording when state is not Locked`() { + var confirmed = false + val actions = AudioRecordingActions.None.copy( + onConfirmRecording = { confirmed = true }, + ) + + handleDragResult(DragResult.Released, { RecordingState.Hold() }, actions) + + assertTrue(confirmed) + } + + @Test + fun `Released does not confirm when state is Locked`() { + var confirmed = false + val actions = AudioRecordingActions.None.copy( + onConfirmRecording = { confirmed = true }, + ) + + handleDragResult(DragResult.Released, { RecordingState.Locked() }, actions) + + assertFalse(confirmed) + } + + @Test + fun `Cancel invokes onCancelRecording`() { + var cancelled = false + val actions = AudioRecordingActions.None.copy( + onCancelRecording = { cancelled = true }, + ) + + handleDragResult(DragResult.Cancel, { RecordingState.Hold() }, actions) + + assertTrue(cancelled) + } + + @Test + fun `Lock invokes onLockRecording`() { + var locked = false + val actions = AudioRecordingActions.None.copy( + onLockRecording = { locked = true }, + ) + + handleDragResult(DragResult.Lock, { RecordingState.Hold() }, actions) + + assertTrue(locked) + } + + @Test + fun `AlreadyLocked does not invoke any action`() { + var anyActionCalled = false + val actions = AudioRecordingActions.None.copy( + onConfirmRecording = { anyActionCalled = true }, + onCancelRecording = { anyActionCalled = true }, + onLockRecording = { anyActionCalled = true }, + ) + + handleDragResult(DragResult.AlreadyLocked, { RecordingState.Hold() }, actions) + + assertFalse(anyActionCalled) + } + + @Test + fun `Released confirms when state is Hold with offset`() { + var confirmed = false + val actions = AudioRecordingActions.None.copy( + onConfirmRecording = { confirmed = true }, + ) + val holdState = RecordingState.Hold( + durationInMs = 500, + offset = Pair(-20f, -10f), + ) + + handleDragResult(DragResult.Released, { holdState }, actions) + + assertTrue(confirmed) + } + + @Test + fun `Released confirms when state is Idle`() { + var confirmed = false + val actions = AudioRecordingActions.None.copy( + onConfirmRecording = { confirmed = true }, + ) + + handleDragResult(DragResult.Released, { RecordingState.Idle }, actions) + + assertTrue(confirmed) + } + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt index d025852082d..04cbbbd2784 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt @@ -385,6 +385,16 @@ internal class MessageComposerViewModelTest { viewModel.input.value `should be equal to` "@Custom Mention " } + @Test + fun `Given message composer When startRecording is called Then delegates to controller`() { + val controller: MessageComposerController = mock() + val viewModel = MessageComposerViewModel(controller) + + viewModel.startRecording() + + verify(controller).startRecording() + } + private class Fixture( private val chatClient: ChatClient = mock(), private val channelId: String = "messaging:123", diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_AudioRecordingButtonTest_button_hold.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_AudioRecordingButtonTest_button_hold.png new file mode 100644 index 00000000000..9ff5a2741df Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_AudioRecordingButtonTest_button_hold.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_AudioRecordingButtonTest_button_locked.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_AudioRecordingButtonTest_button_locked.png new file mode 100644 index 00000000000..3832f7bbf3a Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_AudioRecordingButtonTest_button_locked.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_DefaultMessageComposerRecordingContentTest_recording_content_overview.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_AudioRecordingButtonTest_button_overview.png similarity index 100% rename from stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_DefaultMessageComposerRecordingContentTest_recording_content_overview.png rename to stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_AudioRecordingButtonTest_button_overview.png diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_DefaultMessageComposerRecordingContentTest_recording_content_hold.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_DefaultMessageComposerRecordingContentTest_recording_content_hold.png deleted file mode 100644 index 9eca614c6a4..00000000000 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_DefaultMessageComposerRecordingContentTest_recording_content_hold.png and /dev/null differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_DefaultMessageComposerRecordingContentTest_recording_content_lock.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_DefaultMessageComposerRecordingContentTest_recording_content_lock.png deleted file mode 100644 index 5dee24885a5..00000000000 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal_DefaultMessageComposerRecordingContentTest_recording_content_lock.png and /dev/null differ diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt index e149235937c..bbd8f5179c6 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt @@ -218,7 +218,10 @@ private object MessageComposerCustomizationSnippet { onAttachmentRemoved = { composerViewModel.removeSelectedAttachment(it) }, onCancelAction = { composerViewModel.dismissMessageActions() }, onSendClick = onSendClick, - recordingActions = AudioRecordingActions.defaultActions(composerViewModel), + recordingActions = AudioRecordingActions.defaultActions( + viewModel = composerViewModel, + sendOnComplete = ChatTheme.messageComposerTheme.audioRecording.sendOnComplete, + ), centerContent = { modifier -> // create a custom text field OutlinedTextField( diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt index 8861ce7b43c..2faf0537609 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt @@ -151,7 +151,10 @@ private fun CustomMessageComposer( onAttachmentRemoved = { composerViewModel.removeSelectedAttachment(it) }, onCancelAction = { composerViewModel.dismissMessageActions() }, onSendClick = onSendClick, - recordingActions = AudioRecordingActions.defaultActions(composerViewModel), + recordingActions = AudioRecordingActions.defaultActions( + viewModel = composerViewModel, + sendOnComplete = ChatTheme.messageComposerTheme.audioRecording.sendOnComplete, + ), modifier = Modifier .padding(horizontal = 10.dp) .align(Alignment.CenterVertically), diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt index d4f4939f0d9..34516aa0abb 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt @@ -173,7 +173,7 @@ internal class AudioRecordingController( } } - suspend fun startRecording(offset: Pair? = null) { + suspend fun startRecording() { val state = this.recordingState.value if (state !is RecordingState.Idle) { logger.w { "[startRecording] rejected (state is not Idle): $state" } @@ -202,7 +202,7 @@ internal class AudioRecordingController( setState(RecordingState.Locked(0, emptyList())) return } - setState(RecordingState.Hold(offset = offset ?: RecordingState.Hold.ZeroOffset)) + setState(RecordingState.Hold()) } fun holdRecording(offset: Pair? = null) { diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 06c8e2a8af1..2204ba6acdc 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -899,9 +899,9 @@ public class MessageComposerController( * Starts audio recording and moves [MessageComposerState.recording] state * from [RecordingState.Idle] to [RecordingState.Hold]. */ - public fun startRecording(offset: Pair? = null) { + public fun startRecording() { scope.launch { - audioRecordingController.startRecording(offset) + audioRecordingController.startRecording() } } diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingControllerTest.kt index f90a8035b63..3d7d9b17700 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingControllerTest.kt @@ -83,21 +83,6 @@ internal class AudioRecordingControllerTest { verify(mockMediaRecorder).startAudioRecording(any(), any(), eq(true)) } - @Test - fun `Given Idle state When startRecording with offset is called Then state transitions to Hold with offset`() = runTest { - // Given - val offset = Pair(10f, 20f) - whenever(mockMediaRecorder.startAudioRecording(any(), any(), any())) doReturn Result.Success(mockFile) - - // When - controller.startRecording(offset) - - // Then - val state = controller.recordingState.value - assertTrue(state is RecordingState.Hold) - assertEquals(offset, (state as RecordingState.Hold).offset) - } - @Test fun `Given non-Idle state When startRecording is called Then state remains unchanged`() = runTest { // Given diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index c2120bac7b8..234aba53188 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -36,17 +36,24 @@ import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType import io.getstream.chat.android.ui.common.state.messages.MessageInput +import io.getstream.result.Result +import io.getstream.sdk.chat.audio.recording.StreamMediaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.io.File import java.util.Date internal class MessageComposerControllerTest { @@ -228,6 +235,32 @@ internal class MessageComposerControllerTest { assertEquals(MessageInput.Source.MentionSelected, controller.messageInput.value.source) } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Given idle state When startRecording is called Then delegates to media recorder`() = runTest { + // Given + val mockFile: File = mock() + val fixture = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .givenMediaRecorderStartSuccess(mockFile) + val controller = fixture.get() + + // When + controller.startRecording() + advanceUntilIdle() + + // Then + verify(fixture.mediaRecorder).startAudioRecording( + any(), + any(), + any(), + ) + } + @Test fun `Given no attachments When updateSelectedAttachments called Then attachments are set`() = runTest { // Given @@ -264,6 +297,7 @@ internal class MessageComposerControllerTest { private val clientState: ClientState = mock() private val channelState: ChannelState = mock() private val globalState: GlobalState = mock() + val mediaRecorder: StreamMediaRecorder = mock() fun givenAppSettings(appSettings: AppSettings = defaultAppSettings()) = apply { whenever(chatClient.getAppSettings()) doReturn appSettings @@ -290,6 +324,12 @@ internal class MessageComposerControllerTest { whenever(chatClient.audioPlayer) doReturn audioPlayer } + fun givenMediaRecorderStartSuccess(file: File) = apply { + whenever( + mediaRecorder.startAudioRecording(any(), any(), any()), + ) doReturn Result.Success(file) + } + fun givenClientState(user: User) = apply { whenever(clientState.user) doReturn MutableStateFlow(user) whenever(chatClient.clientState) doReturn clientState @@ -323,7 +363,7 @@ internal class MessageComposerControllerTest { channelCid = cid, chatClient = chatClient, channelState = MutableStateFlow(channelState), - mediaRecorder = mock(), + mediaRecorder = mediaRecorder, userLookupHandler = mock(), fileToUri = mock(), globalState = MutableStateFlow(globalState), diff --git a/stream-chat-android-ui-components/src/main/res/values/strings.xml b/stream-chat-android-ui-components/src/main/res/values/strings.xml index d2417b1f46b..76f4d7bad1c 100644 --- a/stream-chat-android-ui-components/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-components/src/main/res/values/strings.xml @@ -109,7 +109,7 @@ Slide to cancel - Hold to start, release to send. + Hold to record. Release to send No messages Download started