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:roomlist")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.2.0"
implementation 'io.github.raamcosta.compose-destinations:core: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(libs.mavericks.compose)
implementation(libs.timber)
implementation(libs.datetime)
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3")
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
sealed interface RoomListActions {
object LoadMore : RoomListActions
object Logout : RoomListActions
}

View File

@@ -1,11 +1,13 @@
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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExitToApp
import androidx.compose.material.ripple.rememberRipple
@@ -15,9 +17,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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.sp
import com.airbnb.mvrx.Success
@@ -25,10 +29,10 @@ import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.data.LogCompositions
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.RoomListRoomSummary
import io.element.android.x.features.roomlist.model.RoomListViewState
import io.element.android.x.matrix.core.RoomId
import io.element.android.x.matrix.room.RoomSummary
@Composable
fun RoomListScreen(
@@ -54,7 +58,7 @@ fun RoomListScreen(
@Composable
fun RoomListContent(
roomSummaries: List<RoomSummary>,
roomSummaries: List<RoomListRoomSummary>,
matrixUser: MatrixUser,
onRoomClicked: (RoomId) -> Unit,
onLogoutClicked: () -> Unit,
@@ -69,12 +73,11 @@ fun RoomListContent(
onLogoutClicked = onLogoutClicked
)
LazyColumn {
items(roomSummaries, key = { it.identifier() }) { room ->
items(roomSummaries, key = { it.id }) { room ->
RoomItem(room = room) {
onRoomClicked(it)
}
}
}
}
}
@@ -90,7 +93,7 @@ fun RoomListTopBar(matrixUser: MatrixUser, onLogoutClicked: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(data = matrixUser.avatarData)
Avatar(data = matrixUser.avatarData, size = 32.dp)
Spacer(modifier = Modifier.width(8.dp))
Text("${matrixUser.username}")
}
@@ -108,18 +111,17 @@ fun RoomListTopBar(matrixUser: MatrixUser, onLogoutClicked: () -> Unit) {
@Composable
private fun RoomItem(
modifier: Modifier = Modifier,
room: RoomSummary,
room: RoomListRoomSummary,
onClick: (RoomId) -> Unit
) {
if (room !is RoomSummary.Filled) {
if (room.isPlaceholder) {
return
}
val details = room.details
Column(
modifier = modifier
.fillMaxWidth()
.clickable(
onClick = { onClick(room.details.roomId) },
onClick = { onClick(room.roomId) },
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() }
),
@@ -128,12 +130,9 @@ private fun RoomItem(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier
.align(Alignment.CenterVertically)
) {
Avatar(data = null)
}
Avatar(data = room.avatarData)
Column(
modifier = Modifier
.padding(12.dp)
@@ -143,31 +142,56 @@ private fun RoomItem(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
text = details.name,
text = room.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = details.lastMessage?.toString().orEmpty(),
text = room.lastMessage?.toString().orEmpty(),
color = LightGrey,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(
Modifier
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
Modifier.padding(horizontal = 8.dp)
) {
Text(
fontSize = 12.sp,
text = "14:18",
color = LightGrey
text = room.timestamp ?: "",
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.MavericksViewModel
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.RoomListRoomSummary
import io.element.android.x.features.roomlist.model.RoomListViewState
import io.element.android.x.matrix.MatrixClient
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 org.matrix.rustcomponents.sdk.mediaSourceFromUrl
@@ -14,6 +21,7 @@ class RoomListViewModel(initialState: RoomListViewState) :
MavericksViewModel<RoomListViewState>(initialState) {
private val matrix = MatrixInstance.getInstance()
private val lastMessageFormatter = LastMessageFormatter()
init {
handleInit()
@@ -21,12 +29,11 @@ class RoomListViewModel(initialState: RoomListViewState) :
fun handle(action: RoomListActions) {
when (action) {
RoomListActions.LoadMore -> TODO()
RoomListActions.Logout -> handleLogout()
}
}
fun logout(){
fun logout() {
handleLogout()
}
@@ -36,26 +43,63 @@ class RoomListViewModel(initialState: RoomListViewState) :
client.startSync()
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
val userDisplayName = client.loadUserDisplayName().getOrNull()
val avatarData = userAvatarUrl?.let {
mediaSourceFromUrl(it)
}?.let {
client.loadMediaContentForSource(it)
}
val avatarData = loadAvatarData(client, userAvatarUrl)
setState {
copy(
user = MatrixUser(
username = userDisplayName,
avatarUrl = userAvatarUrl,
avatarData = avatarData?.getOrNull()
avatarData = avatarData,
)
)
}
client.roomSummaryDataSource().roomSummaries().execute {
copy(rooms = it)
client.roomSummaryDataSource().roomSummaries()
.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() {
viewModelScope.launch {
setState { copy(logoutAction = Loading()) }

View File

@@ -6,5 +6,5 @@ import androidx.compose.runtime.Stable
data class MatrixUser(
val username: 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.MavericksState
@@ -8,7 +8,7 @@ import io.element.android.x.matrix.room.RoomSummary
data class RoomListViewState(
val user: MatrixUser = MatrixUser(),
val rooms: Async<List<RoomSummary>> = Uninitialized,
val rooms: Async<List<RoomListRoomSummary>> = Uninitialized,
val canLoadMore: Boolean = false,
val logoutAction: Async<Unit> = Uninitialized,
) : MavericksState

View File

@@ -33,6 +33,7 @@ test_orchestrator = "1.4.1"
mavericks = "3.0.1"
timber = "5.0.1"
coil = "2.2.1"
datetime = "0.4.0"
[libraries]
# 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" }
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]

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 androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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 coil.compose.rememberAsyncImagePainter
import coil.compose.AsyncImage
/**
* TODO fallback Avatar
*/
@Composable
fun Avatar(
data: List<UByte>?,
data: ByteArray?,
size: Dp = 48.dp,
) {
Image(
painter = rememberAsyncImagePainter(
model = data?.toUByteArray()?.toByteArray(),
onError = {
Log.e("TAG", "Error $it\n${it.result}", it.result.throwable)
}),
AsyncImage(
model = data,
onError = {
Log.e("TAG", "Error $it\n${it.result}", it.result.throwable)
},
contentDescription = null,
modifier = Modifier
.size(size)
.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) {
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 lastMessage: CharSequence?,
val lastMessageTimestamp: Long?,
val unreadNotificationCount: UInt,
val unreadNotificationCount: Int,
)

View File

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

View File

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