7

Building a Jetpack Compose Chat App

 11 months ago
source link: https://dzone.com/articles/building-a-jetpack-compose-chat-app
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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:

The Latest DZone Refcard

Mobile Database Essentials

  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!!!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK