Create a Simple Android App

Apr 10 2024 · Kotlin 1.9.21, Android 14, Android Studio Hedgehog | 2023.1.1

Lesson 03: Expand the App

Demo

Episode complete

Play next episode

Next
Transcript

Building the Message UI

First, let’s define what a Message actually is. Create a new package inside com.kodeco.chat, the data.model. Then, copy DateExtensions.kt, MessageUiModel.kt, and User.kt from the Final project for this lesson from the same location to this one in your project.

Add the following dependency to your module-level Gradle file, and then sync the project with Gradle:

// Date Time Library - the latest way to handle dates in Kotlin
  implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")

Create another package named utilities, and copy over Extensions.kt from the Final project.

Copy over MessageFormatter.kt from the conversation package in the Final project over to the same location in your project. This is a utility class to handle the formatting of the text within the messages.

You won’t enable adding new messages to the message list until the next lesson, when you learn about ViewModel. So, in the meantime, you’ll need some placeholder data to see how your message UI will look. From the data package in the Final project, copy over FakeData.kt to the same location in your project.

Finally, you’ll need some graphic assets. From res ▸ drawable in the Final project for this lesson, copy over profile_photo_android_developer.png and someone_else.png to the same location in your project.

In the conversation package, create a new Kotlin class, and name it ConversationUiState.kt. Replace the contents with the following:

class ConversationUiState(
  val channelName: String,
  initialMessages: List<MessageUiModel>,
) {
  private val _messages: MutableList<MessageUiModel> = initialMessages.toMutableStateList()

  val messages: List<MessageUiModel> = _messages

  fun addMessage(msg: String, photoUri: Uri?) {
    // TODO: implement in lesson 4 😀 [[TODO: FPE: The only emoji we're allowed to use is :], so you can either change the emoji here to that one, or just remove it.]
  }
}

@Immutable
data class Message(
  val _id: String = UUID.randomUUID().toString(),
  val createdOn: Instant? = Clock.System.now(),
  val roomId: String = "public", // "public" is the roomID for the default public chat room
  val text: String = "test",
  val userId: String = UUID.randomUUID().toString(),
  val photoUri: Uri? = null,
  val authorImage: Int = if (userId == "me") R.drawable.profile_photo_android_developer else R.drawable.someone_else
)

You’ll learn more about state in Compose in the next lesson. For now, focus on the second half of the code in this class, which defines a Kotlin data class, Message(). It has all the properties a chat message might have, along with default values. Some properties, such as photoUri, are optional and, therefore, defined as nullable; a chat message might not always contain an image attachment.

Change the signature of ConversationContent to accept a parameter: fun ConversationContent(uiState: ConversationUiState) {....

Then, still in ConversationContent, update the Messages block to pass in the dummy messages:

Messages(
  messages = uiState.messages,
  modifier = Modifier.weight(1f)
)

Next, update the definition of Messages() to accept a list of MessageUiModel instead of a list of String:

@Composable
fun Messages(
  messages: List<MessageUiModel>,
  modifier: Modifier = Modifier
) {
  Box(modifier = modifier) {
    LazyColumn(
      // Add content padding so that the content can be scrolled (y-axis)
      // below the status bar + app bar
      contentPadding =
      WindowInsets.statusBars.add(WindowInsets(top = 90.dp)).asPaddingValues(),
      modifier = Modifier
        .fillMaxSize()
    ) {
      itemsIndexed(
        items = messages,
        key= { _, message -> message.id }
      ) { index, content ->
        val prevAuthor = messages.getOrNull(index - 1)?.message?.userId
        val nextAuthor = messages.getOrNull(index + 1)?.message?.userId
        val userId = messages.getOrNull(index)?.message?.userId
        val isFirstMessageByAuthor = prevAuthor != content.message.userId
        val isLastMessageByAuthor = nextAuthor != content.message.userId
        MessageUi(
          onAuthorClick = {  },
          msg = content,
          authorId = "me",
          userId = userId ?: "",
          isFirstMessageByAuthor = isFirstMessageByAuthor,
          isLastMessageByAuthor = isLastMessageByAuthor,
        )
      }
    }
  }
}

Instead of a hard-coded list, this composable now renders messages based on MessageUiModel, which is a complex object comprised of a Message, a User, and a unique id for each message.

There’s also some logic so that the profile image, date, and user’s name are only rendered once if there are multiple text messages from the same user in a row. Lastly, add the following composables:

@Composable
fun MessageUi(
  onAuthorClick: (String) -> Unit,
  msg: MessageUiModel,
  authorId: String,
  userId: String,
  isFirstMessageByAuthor: Boolean,
  isLastMessageByAuthor: Boolean,
) {
  val isUserMe = userId == "me" // hard coded for now
  val borderColor = if (isUserMe) {
    MaterialTheme.colorScheme.primary
  } else {
    MaterialTheme.colorScheme.tertiary
  }

  val authorImageId: Int = if (isUserMe) R.drawable.profile_photo_android_developer else R.drawable.someone_else
  val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier
  Row(modifier = spaceBetweenAuthors) {
    if (isLastMessageByAuthor) {
      // Avatar
      Image(
        modifier = Modifier
          .clickable(onClick = { onAuthorClick(msg.message.userId) })
          .padding(horizontal = 16.dp)
          .size(42.dp)
          .border(1.5.dp, borderColor, CircleShape)
          .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape)
          .clip(CircleShape)
          .align(Alignment.Top),
        painter = painterResource(id = authorImageId),
        contentScale = ContentScale.Crop,
        contentDescription = null
      )
    } else {
      // Space under avatar
      Spacer(modifier = Modifier.width(74.dp))
    }
    AuthorAndTextMessage(
      msg = msg,
      isUserMe = isUserMe,
      isFirstMessageByAuthor = isFirstMessageByAuthor,
      isLastMessageByAuthor = isLastMessageByAuthor,
      authorClicked = onAuthorClick,
      modifier = Modifier
        .padding(end = 16.dp)
        .weight(1f)
    )
  }
}

@Composable
fun AuthorAndTextMessage(
  msg: MessageUiModel,
  isUserMe: Boolean,
  isFirstMessageByAuthor: Boolean,
  isLastMessageByAuthor: Boolean,
  authorClicked: (String) -> Unit,
  modifier: Modifier = Modifier
) {
  Column(modifier = modifier) {
    if (isLastMessageByAuthor) {
      AuthorNameTimestamp(msg, isUserMe)
    }
    ChatItemBubble(
      msg.message,
      isUserMe,
      authorClicked = authorClicked)
    if (isFirstMessageByAuthor) {
      // Last bubble before next author
      Spacer(modifier = Modifier.height(8.dp))
    } else {
      // Between bubbles
      Spacer(modifier = Modifier.height(4.dp))
    }
  }
}

@Composable
private fun AuthorNameTimestamp(msg: MessageUiModel, isUserMe: Boolean = false) {
  var userFullName: String = msg.user.fullName
  if (isUserMe) {
    userFullName = "me"
  }

  // Combine author and timestamp for author.
  Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
    Text(
      text = userFullName,
      style = MaterialTheme.typography.titleMedium,
      modifier = Modifier
        .alignBy(LastBaseline)
        .paddingFrom(LastBaseline, after = 8.dp) // Space to 1st bubble
    )
    Spacer(modifier = Modifier.width(8.dp))
    Text(
      text = msg.message.createdOn.toString().isoToTimeAgo(),
      style = MaterialTheme.typography.bodySmall,
      modifier = Modifier.alignBy(LastBaseline),
      color = MaterialTheme.colorScheme.onSurfaceVariant
    )
  }
}

@Composable
fun ChatItemBubble(
  message: Message,
  isUserMe: Boolean,
  authorClicked: (String) -> Unit
) {
  val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
  val pressedState = remember { mutableStateOf(false) }
  val backgroundBubbleColor = if (isUserMe) {
    MaterialTheme.colorScheme.primary
  } else {
    MaterialTheme.colorScheme.surfaceVariant
  }
  Column {
    Surface(
      color = backgroundBubbleColor,
      shape = ChatBubbleShape
    ) {
      if (message.text.isNotEmpty()) {
        ClickableMessage(
          message = message,
          isUserMe = isUserMe,
          authorClicked = authorClicked
        )
      }
    }
  }
}

@Composable
fun ClickableMessage(
  message: Message,
  isUserMe: Boolean,
  authorClicked: (String) -> Unit
) {
  val uriHandler = LocalUriHandler.current
  val styledMessage = messageFormatter(
    text = message.text,
    primary = isUserMe
  )

  ClickableText(
    text = styledMessage,
    style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
    modifier = Modifier.padding(16.dp),
    onClick = {
      styledMessage
        .getStringAnnotations(start = it, end = it)
        .firstOrNull()
        ?.let { annotation ->
          when (annotation.tag) {
            SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item)
            SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item)
            else -> Unit
          }
        }
    }
  )
}

It might seem like a lot of code, but really, it’s just composables that define each part of the message UI and handling interactions with the messages. By breaking it down into many small composables, you’re able to address minute details of the UI individually.

Last thing: In MainActivity.kt, update the call to ConversationContent to include the new parameter you added, and supply the dummy chat data:

setContent {
  ConversationContent(
    uiState = exampleUiState
  )
}

Build and run the app.

Wow, this is starting to look like a real chat app now! Tap the hyperlink in the first chat message. The composable code correctly handles it by opening the link in a browser window. Cool beans!

See forum comments
Cinema mode Download course materials from Github
Previous: Expanding the App Next: Expanding the App Quiz