Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@ jobs:

- uses: GetStream/android-ci-actions/actions/gradle-cache@main

- name: Run unit tests
run: ./gradlew testDebugUnitTest --stacktrace
- name: Run unit tests and verify snapshots
run: ./gradlew testCoverage --stacktrace

- name: Unit tests results
uses: actions/upload-artifact@v7
if: failure()
with:
name: unit-tests-results
path: ./**/build/reports/tests/**
path: |
./**/build/reports/tests/**
./**/build/paparazzi/**
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ plugins {

streamProject {
repositoryName.set("stream-chat-android-ai")
coverage {
includedModules.set(setOf("stream-chat-android-ai-compose"))
}
publishing {
description.set("Official AI components for Stream Android Chat SDK")
}
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ streamChatAndroid = "7.0.0"
retrofit = "2.11.0"
okhttp = "4.12.0"
moshi = "1.15.1"
paparazzi = "2.0.0-alpha02"

[libraries]
detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" }
Expand Down Expand Up @@ -60,6 +61,7 @@ arturbosch-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"
stream-project = { id = "io.getstream.project", version.ref = "streamConventions" }
stream-android-library = { id = "io.getstream.android.library", version.ref = "streamConventions" }
stream-android-application = { id = "io.getstream.android.application", version.ref = "streamConventions" }
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }

[bundles]
stream-chat = [
Expand Down
1 change: 1 addition & 0 deletions stream-chat-android-ai-compose/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
alias(libs.plugins.paparazzi)
alias(libs.plugins.stream.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,13 @@ private const val ANIMATION_DOUBLE = 2f
private const val SMOOTHSTEP_FACTOR_1 = 3f
private const val SMOOTHSTEP_FACTOR_2 = 2f

@Composable
internal fun AITypingIndicatorWithLabel() {
AITypingIndicator(label = { Text(text = "Thinking") })
}

@Preview(showBackground = true)
@Composable
private fun AITypingIndicatorPreview() {
AITypingIndicator(label = { Text("Thinking") })
private fun AITypingIndicatorWithLabelPreview() {
AITypingIndicatorWithLabel()
}
Original file line number Diff line number Diff line change
Expand Up @@ -441,68 +441,93 @@ private fun Context.openSettings() {
startActivity(intent)
}

@Composable
internal fun ChatComposerEmpty() {
ChatComposer(
onSendClick = {},
onStopClick = {},
isGenerating = false,
)
}

@Composable
internal fun ChatComposerFilled() {
ChatComposer(
messageData = MessageData(text = "What is Stream Chat?"),
onSendClick = {},
onStopClick = {},
isGenerating = false,
)
}

@Composable
internal fun ChatComposerLongFilled() {
ChatComposer(
messageData = MessageData(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
onSendClick = {},
onStopClick = {},
isGenerating = false,
)
}

@Composable
internal fun ChatComposerWithAttachments() {
ChatComposer(
messageData = MessageData(
text = "What is Stream Chat?",
attachments = setOf("1".toUri(), "2".toUri(), "3".toUri()),
),
onSendClick = {},
onStopClick = {},
isGenerating = false,
)
}

@Composable
internal fun ChatComposerGenerating() {
ChatComposer(
onSendClick = {},
onStopClick = {},
isGenerating = true,
)
}

@Preview(showBackground = true)
@Composable
private fun ChatComposerEmptyPreview() {
MaterialTheme {
ChatComposer(
onSendClick = {},
onStopClick = {},
isGenerating = false,
)
ChatComposerEmpty()
}
}

@Preview(showBackground = true)
@Composable
private fun ChatComposerFilledPreview() {
MaterialTheme {
ChatComposer(
messageData = MessageData(text = "What is Stream Chat?"),
onSendClick = {},
onStopClick = {},
isGenerating = false,
)
ChatComposerFilled()
}
}

@Preview(showBackground = true)
@Composable
private fun ChatComposerLongFilledPreview() {
MaterialTheme {
ChatComposer(
messageData = MessageData(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
onSendClick = {},
onStopClick = {},
isGenerating = false,
)
ChatComposerLongFilled()
}
}

@Preview(showBackground = true)
@Composable
private fun ChatComposerWithAttachmentsPreview() {
MaterialTheme {
ChatComposer(
messageData = MessageData(
text = "What is Stream Chat?",
attachments = setOf("1".toUri(), "2".toUri(), "3".toUri()),
),
onSendClick = {},
onStopClick = {},
isGenerating = false,
)
ChatComposerWithAttachments()
}
}

@Preview(showBackground = true)
@Composable
private fun ChatComposerGeneratingPreview() {
MaterialTheme {
ChatComposer(
onSendClick = {},
onStopClick = {},
isGenerating = true,
)
ChatComposerGenerating()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -280,16 +280,14 @@ private fun VoiceRecordingBars(
}
}

@Preview(showBackground = true)
@Composable
private fun SpeechToTextButtonIdlePreview() {
internal fun SpeechToTextButtonIdle() {
val state = rememberSpeechToTextButtonState { }
SpeechToTextButton(state = state)
}

@Preview(showBackground = true)
@Composable
private fun SpeechToTextButtonRecordingPreview() {
internal fun SpeechToTextButtonRecording() {
val state = remember {
SpeechToTextButtonState(
helper = object : SpeechRecognizerHelper {
Expand All @@ -300,3 +298,15 @@ private fun SpeechToTextButtonRecordingPreview() {
}
SpeechToTextButton(state = state)
}

@Preview(showBackground = true)
@Composable
private fun SpeechToTextButtonIdlePreview() {
SpeechToTextButtonIdle()
}

@Preview(showBackground = true)
@Composable
private fun SpeechToTextButtonRecordingPreview() {
SpeechToTextButtonRecording()
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview
import io.getstream.chat.android.ai.compose.ui.component.internal.RichText
import kotlinx.coroutines.delay

Expand Down Expand Up @@ -52,8 +53,9 @@ public fun StreamingText(
RichText(text = displayedText)
},
) {
// Track the displayed text for animation
var displayedText by remember { mutableStateOf("") }
// Track the displayed text for animation. When not animating, start with the full text so
// the first frame shows it immediately instead of flashing empty before the effect runs.
var displayedText by remember { mutableStateOf(if (animate) "" else text) }
// Track the previous full text to detect new text vs continuation
var previousText by remember { mutableStateOf("") }
// Track previous animate value to detect transition from true to false
Expand Down Expand Up @@ -181,3 +183,17 @@ private fun splitIntoWords(text: String): List<String> {

// Regex to match whitespace or non-whitespace sequences for chunking.
private val WordSplitRegex = Regex("""(\s+|\S+)""")

@Composable
internal fun StreamingTextRendered() {
StreamingText(
text = "Hello, I am the Stream AI assistant. How can I help you today?",
animate = false,
)
}

@Preview(showBackground = true)
@Composable
private fun StreamingTextPreview() {
StreamingTextRendered()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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-ai/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.ai.compose.ui

import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.app.ActivityOptionsCompat
import app.cash.paparazzi.Paparazzi

/**
* Base for Paparazzi snapshot tests of the AI compose components.
*
* Implementers expose a [Paparazzi] rule and call [snapshot] to render a component inside the
* shared test environment: [MaterialTheme] (the module has no `ChatTheme`), with
* [LocalInspectionMode] enabled and a no-op [ActivityResultRegistryOwner] so components that use
* `rememberLauncherForActivityResult` (such as the composer) render without a host activity.
*/
internal interface PaparazziTest {

val paparazzi: Paparazzi

fun snapshot(name: String? = null, composable: @Composable () -> Unit) {
paparazzi.snapshot(name) {
CompositionLocalProvider(
LocalInspectionMode provides true,
LocalActivityResultRegistryOwner provides NoOpResultRegistryOwner,
) {
MaterialTheme {
Surface {
composable()
}
}
}
}
}
}

private val NoOpResultRegistryOwner = object : ActivityResultRegistryOwner {
override val activityResultRegistry = object : ActivityResultRegistry() {
override fun <I, O> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
input: I,
options: ActivityOptionsCompat?,
) {
// No-op: snapshots never launch activities.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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-ai/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.ai.compose.ui.component

import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import com.android.ide.common.rendering.api.SessionParams.RenderingMode
import io.getstream.chat.android.ai.compose.ui.PaparazziTest
import org.junit.Rule
import org.junit.Test

internal class AITypingIndicatorTest : PaparazziTest {

@get:Rule
override val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
renderingMode = RenderingMode.SHRINK,
)

@Test
fun default() {
snapshot {
AITypingIndicator()
}
}

@Test
fun `with label`() {
snapshot {
AITypingIndicatorWithLabel()
}
}
}
Loading
Loading