RoomList: add avatar for rooms and date formatting

This commit is contained in:
ganfra 2022-11-02 19:04:29 +01:00
parent 1801bcfcd3
commit 9037b9b66b
16 changed files with 244 additions and 61 deletions

View file

@ -69,6 +69,7 @@ dependencies {
implementation project(":features:login") implementation project(":features:login")
implementation project(":features:roomlist") implementation project(":features:roomlist")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.2.0"
implementation 'io.github.raamcosta.compose-destinations:core:1.7.23-beta' implementation 'io.github.raamcosta.compose-destinations:core:1.7.23-beta'
ksp 'io.github.raamcosta.compose-destinations:ksp:1.7.23-beta' ksp 'io.github.raamcosta.compose-destinations:ksp:1.7.23-beta'

View file

@ -12,6 +12,7 @@ dependencies {
implementation(project(":libraries:designsystem")) implementation(project(":libraries:designsystem"))
implementation(libs.mavericks.compose) implementation(libs.mavericks.compose)
implementation(libs.timber) implementation(libs.timber)
implementation(libs.datetime)
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")

View file

@ -0,0 +1,82 @@
package io.element.android.x.features.roomlist
import android.text.format.DateFormat
import android.text.format.DateUtils
import kotlinx.datetime.*
import kotlinx.datetime.TimeZone
import java.time.Period
import java.time.format.DateTimeFormatter
import java.util.*
import kotlin.math.absoluteValue
class LastMessageFormatter(
private val clock: Clock = Clock.System,
private val locale: Locale = Locale.getDefault()
) {
private val onlyTimeFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm")
DateTimeFormatter.ofPattern(pattern)
}
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM")
DateTimeFormatter.ofPattern(pattern)
}
private val dateWithYearFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy")
DateTimeFormatter.ofPattern(pattern)
}
fun format(timestamp: Long?): String {
if (timestamp == null) return ""
val now: Instant = clock.now()
val tsInstant = Instant.fromEpochSeconds(timestamp)
val nowDateTime = now.toLocalDateTime(TimeZone.currentSystemDefault())
val tsDateTime = tsInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val isSameDay = nowDateTime.date == tsDateTime.date
return when {
isSameDay -> {
onlyTimeFormatter.format(tsDateTime.toJavaLocalDateTime())
}
else -> {
formatDate(tsDateTime, nowDateTime)
}
}
}
private fun formatDate(
date: LocalDateTime,
currentDate: LocalDateTime,
): String {
val period = Period.between(date.date.toJavaLocalDate(), currentDate.date.toJavaLocalDate())
return if (period.years.absoluteValue >= 1) {
formatDateWithYear(date)
} else if (period.days.absoluteValue < 2 && period.months.absoluteValue < 1) {
getRelativeDay(date.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds())
} else {
formatDateWithMonth(date)
}
}
private fun formatDateWithMonth(localDateTime: LocalDateTime): String {
return dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime())
}
private fun formatDateWithYear(localDateTime: LocalDateTime): String {
return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
private fun getRelativeDay(ts: Long): String {
return DateUtils.getRelativeTimeSpanString(
ts,
clock.now().toEpochMilliseconds(),
DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_SHOW_WEEKDAY
).toString()
}
}

View file

@ -1,6 +1,5 @@
package io.element.android.x.features.roomlist package io.element.android.x.features.roomlist
sealed interface RoomListActions { sealed interface RoomListActions {
object LoadMore : RoomListActions
object Logout : RoomListActions object Logout : RoomListActions
} }

View file

@ -1,11 +1,13 @@
package io.element.android.x.features.roomlist package io.element.android.x.features.roomlist
import android.widget.Space import Avatar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExitToApp import androidx.compose.material.icons.filled.ExitToApp
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
@ -15,9 +17,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
@ -25,10 +29,10 @@ import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.data.LogCompositions import io.element.android.x.core.data.LogCompositions
import io.element.android.x.designsystem.LightGrey import io.element.android.x.designsystem.LightGrey
import io.element.android.x.designsystem.components.Avatar
import io.element.android.x.features.roomlist.model.MatrixUser import io.element.android.x.features.roomlist.model.MatrixUser
import io.element.android.x.features.roomlist.model.RoomListRoomSummary
import io.element.android.x.features.roomlist.model.RoomListViewState
import io.element.android.x.matrix.core.RoomId import io.element.android.x.matrix.core.RoomId
import io.element.android.x.matrix.room.RoomSummary
@Composable @Composable
fun RoomListScreen( fun RoomListScreen(
@ -54,7 +58,7 @@ fun RoomListScreen(
@Composable @Composable
fun RoomListContent( fun RoomListContent(
roomSummaries: List<RoomSummary>, roomSummaries: List<RoomListRoomSummary>,
matrixUser: MatrixUser, matrixUser: MatrixUser,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onLogoutClicked: () -> Unit, onLogoutClicked: () -> Unit,
@ -69,12 +73,11 @@ fun RoomListContent(
onLogoutClicked = onLogoutClicked onLogoutClicked = onLogoutClicked
) )
LazyColumn { LazyColumn {
items(roomSummaries, key = { it.identifier() }) { room -> items(roomSummaries, key = { it.id }) { room ->
RoomItem(room = room) { RoomItem(room = room) {
onRoomClicked(it) onRoomClicked(it)
} }
} }
} }
} }
} }
@ -90,7 +93,7 @@ fun RoomListTopBar(matrixUser: MatrixUser, onLogoutClicked: () -> Unit) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Avatar(data = matrixUser.avatarData) Avatar(data = matrixUser.avatarData, size = 32.dp)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text("${matrixUser.username}") Text("${matrixUser.username}")
} }
@ -108,18 +111,17 @@ fun RoomListTopBar(matrixUser: MatrixUser, onLogoutClicked: () -> Unit) {
@Composable @Composable
private fun RoomItem( private fun RoomItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
room: RoomSummary, room: RoomListRoomSummary,
onClick: (RoomId) -> Unit onClick: (RoomId) -> Unit
) { ) {
if (room !is RoomSummary.Filled) { if (room.isPlaceholder) {
return return
} }
val details = room.details
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable( .clickable(
onClick = { onClick(room.details.roomId) }, onClick = { onClick(room.roomId) },
indication = rememberRipple(), indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
), ),
@ -128,12 +130,9 @@ private fun RoomItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(modifier = Modifier Avatar(data = room.avatarData)
.align(Alignment.CenterVertically)
) {
Avatar(data = null)
}
Column( Column(
modifier = Modifier modifier = Modifier
.padding(12.dp) .padding(12.dp)
@ -143,31 +142,56 @@ private fun RoomItem(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Black, color = Color.Black,
text = details.name, text = room.name,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = details.lastMessage?.toString().orEmpty(), text = room.lastMessage?.toString().orEmpty(),
color = LightGrey, color = LightGrey,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
Column( Column(
Modifier Modifier.padding(horizontal = 8.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
) { ) {
Text( Text(
fontSize = 12.sp, fontSize = 12.sp,
text = "14:18", text = room.timestamp ?: "",
color = LightGrey color = LightGrey,
)
Spacer(modifier.size(4.dp))
val unreadIndicatorColor = if(room.hasUnread) Color.Black else Color.Transparent
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(unreadIndicatorColor)
.align(Alignment.End),
) )
Spacer(Modifier.size(20.dp))
} }
} }
} }
}
@Preview
@Composable
private fun PreviewableRoomListContent() {
val roomSummaries = listOf(
RoomListRoomSummary(
name = "Room",
hasUnread = true,
timestamp = "14:18",
lastMessage = "A message",
avatarData = null,
id = "roomId"
)
)
RoomListContent(
roomSummaries = roomSummaries,
matrixUser = MatrixUser("User#1"),
onRoomClicked = {},
onLogoutClicked = {}
)
} }

View file

@ -4,9 +4,16 @@ import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import io.element.android.x.core.data.parallelMap
import io.element.android.x.features.roomlist.model.MatrixUser import io.element.android.x.features.roomlist.model.MatrixUser
import io.element.android.x.features.roomlist.model.RoomListRoomSummary
import io.element.android.x.features.roomlist.model.RoomListViewState
import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.MatrixInstance import io.element.android.x.matrix.MatrixInstance
import io.element.android.x.matrix.room.RoomSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
@ -14,6 +21,7 @@ class RoomListViewModel(initialState: RoomListViewState) :
MavericksViewModel<RoomListViewState>(initialState) { MavericksViewModel<RoomListViewState>(initialState) {
private val matrix = MatrixInstance.getInstance() private val matrix = MatrixInstance.getInstance()
private val lastMessageFormatter = LastMessageFormatter()
init { init {
handleInit() handleInit()
@ -21,12 +29,11 @@ class RoomListViewModel(initialState: RoomListViewState) :
fun handle(action: RoomListActions) { fun handle(action: RoomListActions) {
when (action) { when (action) {
RoomListActions.LoadMore -> TODO()
RoomListActions.Logout -> handleLogout() RoomListActions.Logout -> handleLogout()
} }
} }
fun logout(){ fun logout() {
handleLogout() handleLogout()
} }
@ -36,26 +43,63 @@ class RoomListViewModel(initialState: RoomListViewState) :
client.startSync() client.startSync()
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull() val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
val userDisplayName = client.loadUserDisplayName().getOrNull() val userDisplayName = client.loadUserDisplayName().getOrNull()
val avatarData = userAvatarUrl?.let { val avatarData = loadAvatarData(client, userAvatarUrl)
mediaSourceFromUrl(it)
}?.let {
client.loadMediaContentForSource(it)
}
setState { setState {
copy( copy(
user = MatrixUser( user = MatrixUser(
username = userDisplayName, username = userDisplayName,
avatarUrl = userAvatarUrl, avatarUrl = userAvatarUrl,
avatarData = avatarData?.getOrNull() avatarData = avatarData,
) )
) )
} }
client.roomSummaryDataSource().roomSummaries().execute { client.roomSummaryDataSource().roomSummaries()
copy(rooms = it) .map { roomSummaries ->
mapRoomSummaries(client, roomSummaries)
}
.flowOn(Dispatchers.Default)
.execute {
copy(rooms = it)
}
}
}
private suspend fun mapRoomSummaries(
client: MatrixClient,
roomSummaries: List<RoomSummary>
): List<RoomListRoomSummary> {
return roomSummaries.parallelMap { roomSummary ->
when (roomSummary) {
is RoomSummary.Empty -> RoomListRoomSummary(
id = roomSummary.identifier,
isPlaceholder = true
)
is RoomSummary.Filled -> {
val avatarData = loadAvatarData(client, roomSummary.details.avatarURLString)
RoomListRoomSummary(
id = roomSummary.identifier(),
name = roomSummary.details.name,
hasUnread = roomSummary.details.unreadNotificationCount > 0,
timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp),
lastMessage = roomSummary.details.lastMessage,
avatarData = avatarData,
)
}
} }
} }
} }
private suspend fun loadAvatarData(client: MatrixClient, url: String?, size: Long = 48): ByteArray? {
val mediaContent = url?.let {
val mediaSource = mediaSourceFromUrl(it)
client.loadMediaThumbnailForSource(mediaSource, size, size)
}
return mediaContent?.fold(
{ it },
{ null }
)
}
private fun handleLogout() { private fun handleLogout() {
viewModelScope.launch { viewModelScope.launch {
setState { copy(logoutAction = Loading()) } setState { copy(logoutAction = Loading()) }

View file

@ -6,5 +6,5 @@ import androidx.compose.runtime.Stable
data class MatrixUser( data class MatrixUser(
val username: String? = null, val username: String? = null,
val avatarUrl: String? = null, val avatarUrl: String? = null,
val avatarData: List<UByte>? = null, val avatarData: ByteArray? = null,
) )

View file

@ -0,0 +1,14 @@
package io.element.android.x.features.roomlist.model
import io.element.android.x.matrix.core.RoomId
data class RoomListRoomSummary(
val id: String,
val roomId: RoomId = RoomId(id),
val name: String = "",
val hasUnread: Boolean = false,
val timestamp: String? = null,
val lastMessage: CharSequence? = null,
val avatarData: ByteArray? = null,
val isPlaceholder: Boolean = false,
)

View file

@ -1,4 +1,4 @@
package io.element.android.x.features.roomlist package io.element.android.x.features.roomlist.model
import com.airbnb.mvrx.Async import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
@ -8,7 +8,7 @@ import io.element.android.x.matrix.room.RoomSummary
data class RoomListViewState( data class RoomListViewState(
val user: MatrixUser = MatrixUser(), val user: MatrixUser = MatrixUser(),
val rooms: Async<List<RoomSummary>> = Uninitialized, val rooms: Async<List<RoomListRoomSummary>> = Uninitialized,
val canLoadMore: Boolean = false, val canLoadMore: Boolean = false,
val logoutAction: Async<Unit> = Uninitialized, val logoutAction: Async<Unit> = Uninitialized,
) : MavericksState ) : MavericksState

View file

@ -33,6 +33,7 @@ test_orchestrator = "1.4.1"
mavericks = "3.0.1" mavericks = "3.0.1"
timber = "5.0.1" timber = "5.0.1"
coil = "2.2.1" coil = "2.2.1"
datetime = "0.4.0"
[libraries] [libraries]
# Project # Project
@ -70,6 +71,8 @@ test_orchestrator = { module = "androidx.test:orchestrator", version.ref = "test
mavericks_compose = { module = "com.airbnb.android:mavericks-compose", version.ref = "mavericks" } mavericks_compose = { module = "com.airbnb.android:mavericks-compose", version.ref = "mavericks" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
[bundles] [bundles]

View file

@ -0,0 +1,9 @@
package io.element.android.x.core.data
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
suspend fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> = coroutineScope {
map { async { f(it) } }.awaitAll()
}

View file

@ -1,37 +1,31 @@
package io.element.android.x.designsystem.components
import android.util.Log import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter import coil.compose.AsyncImage
/** /**
* TODO fallback Avatar * TODO fallback Avatar
*/ */
@Composable @Composable
fun Avatar( fun Avatar(
data: List<UByte>?, data: ByteArray?,
size: Dp = 48.dp, size: Dp = 48.dp,
) { ) {
Image( AsyncImage(
painter = rememberAsyncImagePainter( model = data,
model = data?.toUByteArray()?.toByteArray(), onError = {
onError = { Log.e("TAG", "Error $it\n${it.result}", it.result.throwable)
Log.e("TAG", "Error $it\n${it.result}", it.result.throwable) },
}),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(size) .size(size)
.clip(CircleShape) .clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary, CircleShape)
) )
} }

View file

@ -96,10 +96,21 @@ class MatrixClient internal constructor(
} }
} }
suspend fun loadMediaContentForSource(source: MediaSource): Result<List<UByte>> = suspend fun loadMediaContentForSource(source: MediaSource): Result<ByteArray> =
withContext(dispatchers.io) { withContext(dispatchers.io) {
runCatching { runCatching {
client.getMediaContent(source) client.getMediaContent(source).toUByteArray().toByteArray()
}
}
suspend fun loadMediaThumbnailForSource(
source: MediaSource,
width: Long,
height: Long
): Result<ByteArray> =
withContext(dispatchers.io) {
runCatching {
client.getMediaThumbnail(source, width.toULong(), height.toULong()).toUByteArray().toByteArray()
} }
} }

View file

@ -23,5 +23,5 @@ data class RoomSummaryDetails(
val avatarURLString: String?, val avatarURLString: String?,
val lastMessage: CharSequence?, val lastMessage: CharSequence?,
val lastMessageTimestamp: Long?, val lastMessageTimestamp: Long?,
val unreadNotificationCount: UInt, val unreadNotificationCount: Int,
) )

View file

@ -126,7 +126,7 @@ internal class RustRoomSummaryDataSource(
name = room.name() ?: identifier, name = room.name() ?: identifier,
isDirect = room.isDm() ?: false, isDirect = room.isDm() ?: false,
avatarURLString = room.fullRoom()?.avatarUrl(), avatarURLString = room.fullRoom()?.avatarUrl(),
unreadNotificationCount = room.unreadNotifications().notificationCount(), unreadNotificationCount = room.unreadNotifications().notificationCount().toInt(),
lastMessage = latestRoomMessage?.body, lastMessage = latestRoomMessage?.body,
lastMessageTimestamp = latestRoomMessage?.originServerTs lastMessageTimestamp = latestRoomMessage?.originServerTs
) )

View file

@ -7,15 +7,16 @@ import composeVersion
import org.gradle.api.artifacts.VersionCatalog import org.gradle.api.artifacts.VersionCatalog
fun CommonExtension<*, *, *, *>.androidConfig() { fun CommonExtension<*, *, *, *>.androidConfig() {
defaultConfig { defaultConfig {
compileSdk = Versions.compileSdk compileSdk = Versions.compileSdk
minSdk = Versions.minSdk minSdk = Versions.minSdk
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
} }