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:
- Initialization & dependencies
- Repository
- ViewModels
- Navigation
- 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.
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.
@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.
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
)
}
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.
@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
}
@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.
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.
@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.
@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))
}
}
}
}
}
For the conversation screen, we’ll also use a LazyColumn
for showing the previous messages.
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!!!