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

Heads up... You’re accessing parts of this content for free, with some sections shown as obfuscated text.

Heads up... You’re accessing parts of this content for free, with some sections shown as obfuscated text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

// Date Time Library - the latest way to handle dates in Kotlin
  implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
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
)
Messages(
  messages = uiState.messages,
  modifier = Modifier.weight(1f)
)
@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,
        )
      }
    }
  }
}
@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
          }
        }
    }
  )
}
setContent {
  ConversationContent(
    uiState = exampleUiState
  )
}
See forum comments
Cinema mode Download course materials from Github
Previous: Expanding the App Next: Expanding the App Quiz