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
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

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
  • Securing Verifiable Credentials With DPoP: A Spring Boot Implementation

Trending

  • You Don't Get to Retrofit Trust: Why API Security Must Be Designed In, Not Bolted On
  • The Death of "Text-Only" ChatOps: Why Google's A2UI Matters for DevOps and SRE
  • What Is Plagiarism? How to Avoid It and Cite Sources
  • Your API Authentication Isn’t Broken; It’s Quietly Failing in These 6 Ways
  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.7K 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
  • Securing Verifiable Credentials With DPoP: A Spring Boot Implementation

Partner Resources

×

Comments

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

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

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 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook