DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Please enter at least three characters to search
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

The software you build is only as secure as the code that powers it. Learn how malicious code creeps into your software supply chain.

Apache Cassandra combines the benefits of major NoSQL databases to support data management needs not covered by traditional RDBMS vendors.

Generative AI has transformed nearly every industry. How can you leverage GenAI to improve your productivity and efficiency?

Modernize your data layer. Learn how to design cloud-native database architectures to meet the evolving demands of AI and GenAI workloads.

Related

  • Allow Users to Track Fitness Status in Your App
  • Building a Kotlin Mobile App With the Salesforce SDK: Editing and Creating Data
  • Building a Kotlin Mobile App With the Salesforce SDK
  • Simplifying Multi-Cloud Observability With Open Source

Trending

  • GitHub Copilot's New AI Coding Agent Saves Developers Time – And Requires Their Oversight
  • MCP Servers: The Technical Debt That Is Coming
  • Tired of Spring Overhead? Try Dropwizard for Your Next Java Microservice
  • Monolith: The Good, The Bad and The Ugly
  1. DZone
  2. Coding
  3. Languages
  4. Building a Jetpack Compose Chat App

Building a Jetpack Compose Chat App

In this tutorial, you will learn how to build an Android chat app (or a chat feature) using Jetpack Compose and Amity’s chat SDK!

By 
Eliza Camber user avatar
Eliza Camber
·
Oct. 05, 23 · Tutorial
Likes (1)
Comment
Save
Tweet
Share
3.4K Views

Join the DZone community and get the full member experience.

Join For Free

Let’s build a chat app (or a chat feature) using Jetpack Compose and Amity’s chat SDK!

First, we’ll need the login functionality. In our previous tutorial, you can find how to do that using Firebase’s OneTap auth.

Keep reading for our full tutorial, or skip to the part you’re looking for:

  1. Initialization & dependencies
  2. Repository
  3. ViewModels
  4. Navigation
  5. Screens

Initialization & Dependencies

Besides all of the Jetpack Compose dependencies, we’ll also need Hilt for dependency injection, Coil for image loading, and Amity for the chat functionality.

Kotlin
 
implementation 'com.github.AmityCo.Amity-Social-Cloud-SDK-Android:amity-sdk:x.y.z'
implementation 'androidx.hilt:hilt-navigation-compose:x.y.z'
implementation 'com.google.dagger:hilt-android:x.y.z'
implementation 'com.google.dagger:hilt-compiler:x.y.z'
implementation 'com.google.dagger:hilt-android-compiler:x.y.z'
implementation 'com.google.dagger:hilt-android-testing:x.y.z'
implementation 'com.google.dagger:hilt-android-gradle-plugin:x.y.z'
implementation 'io.coil-kt:coil-compose:x.y.z'


After our login/ sign-up functionality is in place, we will initialize Amity’s SDK in our main activity.

Kotlin
 
@HiltAndroidApp
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        AmityCoreClient.setup(
            apiKey = "YOUR_API_KEY",
            endpoint = AmityEndpoint.EU
        )
    }
}


Repository

Then, we’ll create our ChatsRepository and its implementation, where we’ll add our core functionality: fetching all the channels (aka chats), creating a new one, fetching a channel by id, fetching all the messages of a channel, and of course, posting a new message.

Kotlin
 
interface ChatRepository {
    val chats: Flow<PagingData<AmityChannel>>
    fun createChannel(user: User, onError: (Throwable) -> Unit): Flow<AmityChannel>
    fun getChannel(id: String, onError: (Throwable) -> Unit): Flow<AmityChannel>
    fun getHistory(id: String, onError: (Throwable) -> Unit): Flow<PagingData<AmityMessage>>
    suspend fun postMessage(
        channelId: String,
        msg: String,
        onError: (Throwable) -> Unit
    )
}
Kotlin
 
class RemoteChatsRepository @Inject constructor() : ChatRepository {
    // initialize Amity
    val amityChannelRepo = AmityChatClient.newChannelRepository()
    val amityMessageRepo = AmityChatClient.newMessageRepository()

    init {
        AmityCoreClient.registerPushNotification()
    }

    override val chats: Flow<PagingData<AmityChannel>> =
        amityChannelRepo.getChannels().all().build().query().asFlow()

    override fun createChannel(user: User, onError: (Throwable) -> Unit) =
        amityChannelRepo.createChannel()
            .conversation(userId = user.uid)
            .build()
            .create()
            .toFlowable().asFlow()
            .catch {
                Log.e(
                    "ChatRepository",
                    "createChannel exception: ${it.localizedMessage}",
                    it
                )
                onError(it)
            }

    override fun getChannel(id: String, onError: (Throwable) -> Unit) = amityChannelRepo.getChannel(id).asFlow()
        .catch {
            Log.e(
                "ChatRepository",
                "getChannel exception: ${it.localizedMessage}",
                it
            )
            onError(it)
        }

    override fun getHistory(id: String, onError: (Throwable) -> Unit) =
        amityMessageRepo.getMessages(subChannelId = id).build().query()
            .asFlow()
            .catch {
                Log.e(
                    "ChatRepository",
                    "getHistory exception: ${it.localizedMessage}",
                    it
                )
                onError(it)
            }

    override suspend fun postMessage(
        channelId: String,
        msg: String,
        onError: (Throwable) -> Unit
    ) {
        try {
            amityMessageRepo.createMessage(subChannelId = channelId).with().text(text = msg).build().send().subscribe()
        } catch (e: Exception) {
            Log.e("ChatRepository", "postMessage exception: ${e.localizedMessage}", e)
            onError(e)
        }
    }
}


ViewModels

In this tutorial, we’re going to use the MVVM architecture, so let’s build our ViewModels next! We’ll need two ViewModels; one for the screen that will show a list with all of our chats and one for the messaging screen.

Kotlin
 
@HiltViewModel
class ChatsViewModel @Inject constructor(
    chatRepository: ChatRepository
) : ViewModel() {

    val uiState: StateFlow<ChatsUiState> = chatRepository.chats
        .cachedIn(viewModelScope)
        .map { Success(data = flowOf(it)) }
        .catch { Error(it) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)
}

sealed interface ChatsUiState {
    object Loading : ChatsUiState
    data class Error(val throwable: Throwable) : ChatsUiState
    data class Success(val data: Flow<PagingData<AmityChannel>>) : ChatsUiState
}
Kotlin
 
@HiltViewModel
class ConversationViewModel @Inject constructor(
    val chatRepository: ChatRepository,
    val authRepository: AuthRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<ConversationUiState>(ConversationUiState.Loading)
    val uiState: StateFlow<ConversationUiState> = _uiState

    val currentUserId = authRepository.currentUserId

    suspend fun getConversation(id: String) = chatRepository.getChannel(id, onError = {
        _uiState.value = ConversationUiState.Error(it)
    })
        .collect {
            _uiState.value = ConversationUiState.Success(it)
        }

    fun getHistory(id: String) = chatRepository.getHistory(id).cachedIn(viewModelScope)

    fun sendMessage(channelId: String, msg: String, onError: (Throwable) -> Unit) =
        viewModelScope.launch {
            chatRepository.postMessage(
                channelId = channelId,
                msg = msg,
                onError = onError
            )
        }
}

sealed interface ConversationUiState {
    object Loading : ConversationUiState
    data class Error(val throwable: Throwable) : ConversationUiState
    data class Success(val data: AmityChannel) : ConversationUiState
}

Here, the ConversationUiState is independent of the chat history, as we decided to show the chat even if we can’t retrieve the previous messages. We could easily combine those two though if we wouldn’t like to show the chat at all in case an error occurs, as shown below.

Kotlin
 
suspend fun getConversation(id: String) {
        val conversation = chatRepository.getChannel(id)
        val history = chatRepository.getHistory(id)

        return conversation.zip(history) { _conversation, _history ->
            Conversation(_conversation, _history)
        }.catch { _uiState.value = ConversationUiState.Error(it) }.collect{
            _uiState.value = ConversationUiState.Success()
        }
    }


Navigation

We’re now ready to start on our UI level!

First, we’ll start with our navigation, which is going to be our entry point Composable in our Application.

Kotlin
 
@Composable
fun MainNavigation(
    modifier: Modifier,
    snackbarHostState: SnackbarHostState,
    viewModel: MainViewModel = hiltViewModel()
) {
    val navController = rememberNavController()
    val lifecycleOwner = LocalLifecycleOwner.current
    val scope = rememberCoroutineScope()

    LaunchedEffect(lifecycleOwner) {
        // Connectivity & login status monitoring code
        // ...
    }

    NavHost(navController = navController, startDestination = Route.Loading.route) {
        composable(Route.Loading.route) { LoadingScreen(...) }
        composable(Route.UsersList.route) { UsersScreen(..) }
        composable(Route.Login.route) { LoginScreen(...) }

        composable(Route.ChatsList.route) {
            ChatsScreen(
                modifier = modifier,
                navigateToUsers = { navController.navigate(Route.UsersList.route) },
                onError = { showSnackbar(scope, snackbarHostState, it) },
                navigateToConversation = { conversationId ->navController.navigate(Route.Conversation.createRoute(conversationId)) })
        }
        composable(Route.Conversation.route) { backStackEntry ->
            ConversationScreen(
                modifier = modifier,
                onError = { showSnackbar(scope, snackbarHostState, it) },
                navigateBack = { navController.navigate(Route.ChatsList.route) { popUpTo(0) } },
                backStackEntry.arguments?.getString(Route.Conversation.ARG_CHANNEL_ID)
            )
        }
    }
}


Screens

And we can finally build our two screens. The list of channels in our ChatsUiState comes in as a PagingData object; thus we will use the LazyColumn layout.

Kotlin
 
@Composable
fun ChatsScreen(
    modifier: Modifier,
    navigateToUsers: () -> Unit,
    onError: (String) -> Unit,
    navigateToConversation: (String) -> Unit,
    viewModel: ChatsViewModel = hiltViewModel()
) {
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val uiState by produceState<ChatsUiState>(
        initialValue = ChatsUiState.Loading,
        key1 = lifecycle,
        key2 = viewModel
    ) {
        lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) {
            viewModel.uiState.collect { value = it }
        }
    }

    if (uiState is ChatsUiState.Success) {
        val chats: LazyPagingItems<AmityChannel> =
            (uiState as ChatsUiState.Success).data.collectAsLazyPagingItems()
        ChatsScreen(
            chats = chats,
            navigateToUsers = navigateToUsers,
            navigateToConversation = navigateToConversation,
            modifier = modifier.padding(8.dp)
        )
    } else if(uiState is ChatsUiState.Error){
        (uiState as ChatsUiState.Error).throwable.localizedMessage?.let {
            onError(it)
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ChatsScreen(
    chats: LazyPagingItems<AmityChannel>,
    modifier: Modifier = Modifier,
    navigateToUsers: () -> Unit,
    navigateToConversation: (String) -> Unit,
    state: LazyListState = rememberLazyListState(),
) {
    if (chats.itemCount == 0 && chats.loadState.refresh is LoadState.NotLoading && chats.loadState.refresh.endOfPaginationReached) {
        EmptyChannelList(modifier = modifier, navigateToUsers = navigateToUsers)
    }

    chats.apply {
        when {
            loadState.refresh is LoadState.Loading
                    || loadState.append is LoadState.Loading
                    || loadState.prepend is LoadState.Loading -> {
                LoadingChannels()
            }
        }
    }

    Column {
        TopAppBar(...)
        LazyColumn(modifier = modifier, state = state) {
            items(
                count = chats.itemCount,
                key = chats.itemKey { it.getChannelId() },
                contentType = chats.itemContentType { it.getChannelType() }
            ) { index ->
                chats[index]?.let {
                    ChatsRow(chat = it, navigateToConversation = navigateToConversation)
                    Spacer(modifier = Modifier.height(16.dp))
                }
            }
        }
    }
}

screen to showing previous message


For the conversation screen, we’ll also use a LazyColumn for showing the previous messages.


Kotlin
 
@Composable
fun ConversationScreen(
    modifier: Modifier,
    onError: (String) -> Unit,
    navigateBack: () -> Unit,
    channelId: String?,
    viewModel: ConversationViewModel = hiltViewModel()
) {
    val lifecycle = LocalLifecycleOwner.current.lifecycle

    if (channelId == null) {
        Log.e("ConversationScreen", "ConversationScreen: channel id was null")
        navigateBack.invoke()
    }
    requireNotNull(channelId)

    LaunchedEffect(key1 = lifecycle, key2 = viewModel.uiState) {
        viewModel.getConversation(channelId)
    }

    val uiState by produceState<ConversationUiState>(
        initialValue = ConversationUiState.Loading,
        key1 = lifecycle,
        key2 = viewModel
    ) {
        lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) {
            viewModel.uiState.collect { value = it }
        }
    }

    when (uiState) {
        is ConversationUiState.Error -> {
            (uiState as ConversationUiState.Error).throwable.localizedMessage?.let(onError)
            navigateBack.invoke()
        }

        ConversationUiState.Loading -> { LoadingScreen() } 
        is ConversationUiState.Success -> {
            ConversationScreen(
                channel = (uiState as ConversationUiState.Success).data,
                modifier = modifier,
                navigateBack = navigateBack,
                onError = onError
            )
        }
    }
}

@Composable
internal fun ConversationScreen(
    channel: AmityChannel,
    modifier: Modifier = Modifier,
    navigateBack: () -> Unit,
    onError: (String) -> Unit,
    viewModel: ConversationViewModel = hiltViewModel()
) {

    Box(modifier = modifier.fillMaxSize()) {
        TopAppBar(...)
        Scaffold(modifier = modifier.fillMaxSize(), bottomBar = {
            ComposeMessageBox(channelId = channel.getChannelId(), onError = onError)
        }) { paddingValues ->
            MessageHistory(
                modifier = modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                currentUserId = viewModel.currentUserId,
                channelId = channel.getChannelId()
            )
        }
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun ComposeMessageBox(
    channelId: String,
    onError: (String) -> Unit,
    viewModel: ConversationViewModel = hiltViewModel()
) {
    var msg by rememberSaveable { mutableStateOf("") }
    val keyboardController = LocalSoftwareKeyboardController.current

    Row(
        modifier = Modifier
            .padding(8.dp)
            .fillMaxWidth()
            .wrapContentHeight(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center
    ) {
        BasicTextField(
            value = msg,
            onValueChange = { msg = it },
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Text,
                imeAction = ImeAction.Default
            ),
            modifier = Modifier.weight(1f),
            textStyle = TextStyle(color = MaterialTheme.colorScheme.primary),
            cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
            decorationBox = { innerTextField ->
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .border(
                            width = 2.dp,
                            color = MaterialTheme.colorScheme.secondary,
                            shape = RoundedCornerShape(size = 16.dp)
                        )
                        .padding(8.dp)
                ) {
                    innerTextField()
                }
            }
        )
        IconButton(
            onClick = {
                viewModel.sendMessage(
                    channelId = channelId,
                    msg = msg,
                    onError = {
                        onError(stringResource(id = R.string.chat_message_error))
                    })
                msg = ""
            },
            enabled = msg.isNotBlank()
        ) {
            Icon(
                imageVector = Icons.Default.Send,
                contentDescription = stringResource(id = R.string.chat_send_message)
            )
        }
    }
}

@Composable
internal fun MessageHistory(
    modifier: Modifier,
    currentUserId: String,
    channelId: String,
    state: LazyListState = rememberLazyListState(),
    viewModel: ConversationViewModel = hiltViewModel()
) {
    val scope = rememberCoroutineScope()
    val messages = viewModel.getHistory(channelId).collectAsLazyPagingItems()

    LazyColumn(modifier = modifier, state = state, horizontalAlignment = Alignment.Start, reverseLayout = true) {
        // always scroll to show the latest message
        scope.launch {
            state.scrollToItem(0)
        }

        items(
            count = messages.itemCount,
            key = messages.itemKey { it.getMessageId() },
            contentType = messages.itemContentType { it.getDataType() }
        ) { index ->
            messages[index]?.let {
                MessageRow(it, it.getCreatorId() == currentUserId)
                Spacer(modifier = Modifier.height(16.dp))
            }
        }
    }
}


Some basic chat feature suggestion


And there you have it! It’s worth noticing that this is only the backbone of the chat feature, but we hope it’s enough to get you started fast :) You can also find the code on our GitHub. Do you have any suggestions or questions, or are you missing some features you’d like us to include? Leave a comment below.

Now, go chat!!!

Implementation Software development kit app Kotlin (programming language) Data Types Android OS mobile app

Published at DZone with permission of Eliza Camber. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • Allow Users to Track Fitness Status in Your App
  • Building a Kotlin Mobile App With the Salesforce SDK: Editing and Creating Data
  • Building a Kotlin Mobile App With the Salesforce SDK
  • Simplifying Multi-Cloud Observability With Open Source

Partner Resources

×

Comments
Oops! Something Went Wrong

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • support@dzone.com

Let's be friends:

Likes
There are no likes...yet! 👀
Be the first to like this post!
It looks like you're not logged in.
Sign in to see who liked this post!