feat: consolidate the last week of changes

- unfortunately forgot to do atomic commits
 - design improved in many many areas
 - app now crashes less
 - messages are now virtualised
 - remote images are now cached in-memory
 - more strings (most unused)
This commit is contained in:
Infi 2023-01-03 01:58:20 +01:00
parent 2eeb44e800
commit 8f068edeec
13 changed files with 319 additions and 80 deletions

View File

@ -26,7 +26,8 @@ android {
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
} }

View File

@ -31,10 +31,15 @@ suspend fun fetchSelf(): User {
} }
suspend fun fetchUser(id: String): User { suspend fun fetchUser(id: String): User {
val response = RevoltHttp.get("/users/$id") { val res = RevoltHttp.get("/users/$id") {
headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
} }
.bodyAsText()
if (res.status.value == 404) {
return User.getPlaceholder(id)
}
val response = res.bodyAsText()
try { try {
val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) val error = RevoltJson.decodeFromString(RevoltError.serializer(), response)

View File

@ -37,6 +37,22 @@ data class User(
online = partial.online ?: online online = partial.online ?: online
) )
} }
companion object {
fun getPlaceholder(forId: String) = User(
id = forId,
username = "Unknown User",
avatar = null,
badges = 0,
status = null,
profile = null,
flags = 0,
privileged = false,
bot = null,
relationship = null,
online = false
)
}
} }
@Serializable @Serializable

View File

@ -8,7 +8,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -75,7 +75,7 @@ fun MessageField(
modifier = Modifier.height(56.dp) modifier = Modifier.height(56.dp)
) { ) {
Icon( Icon(
Icons.Default.KeyboardArrowLeft, Icons.Default.KeyboardArrowRight,
contentDescription = stringResource(id = R.string.show_more_alt), contentDescription = stringResource(id = R.string.show_more_alt),
modifier = Modifier modifier = Modifier
.size(24.dp + 8.dp) .size(24.dp + 8.dp)

View File

@ -11,6 +11,7 @@ import coil.compose.AsyncImage
import coil.decode.GifDecoder import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import coil.memory.MemoryCache
import coil.request.ImageRequest import coil.request.ImageRequest
@Composable @Composable
@ -20,19 +21,28 @@ fun RemoteImage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop contentScale: ContentScale = ContentScale.Crop
) { ) {
val context = LocalContext.current
AsyncImage( AsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(context)
.data(url) .data(url)
.crossfade(true) .crossfade(true)
.build(), .build(),
imageLoader = ImageLoader.Builder(LocalContext.current).components { imageLoader = ImageLoader.Builder(context)
if (Build.VERSION.SDK_INT >= 28) { .components {
add(ImageDecoderDecoder.Factory()) if (Build.VERSION.SDK_INT >= 28) {
} else { add(ImageDecoderDecoder.Factory())
add(GifDecoder.Factory()) } else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} }
add(SvgDecoder.Factory()) .memoryCache {
}.build(), MemoryCache.Builder(context)
.maxSizePercent(.25)
.build()
}
.build(),
contentDescription = description, contentDescription = description,
contentScale = contentScale, contentScale = contentScale,
modifier = modifier modifier = modifier

View File

@ -0,0 +1,75 @@
package chat.revolt.components.screens.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.revolt.api.schemas.ChannelType
import chat.revolt.R
@Composable
fun DrawerChannel(
channelType: ChannelType,
name: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.padding(vertical = 4.dp, horizontal = 8.dp)
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(if (selected) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 16.dp)
) {
when (channelType) {
ChannelType.TextChannel -> {
Icon(
modifier = Modifier.padding(end = 8.dp),
painter = painterResource(R.drawable.ic_pound_24dp),
contentDescription = stringResource(R.string.channel_text)
)
}
ChannelType.VoiceChannel -> {
Icon(
modifier = Modifier.padding(end = 8.dp),
painter = painterResource(R.drawable.ic_volume_up_24dp),
contentDescription = stringResource(R.string.channel_voice)
)
}
ChannelType.SavedMessages -> {
Icon(
modifier = Modifier.padding(end = 8.dp),
painter = painterResource(R.drawable.ic_note_24dp),
contentDescription = stringResource(R.string.channel_notes)
)
}
else -> {
Icon(
modifier = Modifier.padding(end = 8.dp),
imageVector = Icons.Default.List,
contentDescription = "Channel"
)
}
}
Text(
text = name,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}

View File

@ -1,16 +1,23 @@
package chat.revolt.screens.chat package chat.revolt.screens.chat
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -19,19 +26,20 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import chat.revolt.api.REVOLT_FILES import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.generic.drawableResource
import chat.revolt.screens.chat.views.HomeScreen import chat.revolt.screens.chat.views.HomeScreen
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.schemas.ChannelType
import chat.revolt.components.screens.chat.DrawerChannel
import chat.revolt.screens.chat.views.ChannelScreen import chat.revolt.screens.chat.views.ChannelScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ChatRouterViewModel : ViewModel() { class ChatRouterViewModel : ViewModel() {
private var _currentServer = private var _currentServer = mutableStateOf("home")
mutableStateOf(RevoltAPI.serverCache.values.firstOrNull()?.id ?: "home")
val currentServer: String val currentServer: String
get() = _currentServer.value get() = _currentServer.value
@ -50,71 +58,133 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
val channelDrawerState = rememberDrawerState(DrawerValue.Closed) val channelDrawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navController = rememberNavController() val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
DismissibleNavigationDrawer(drawerState = channelDrawerState, drawerContent = { DismissibleNavigationDrawer(
ModalDrawerSheet { drawerState = channelDrawerState,
Column(Modifier.fillMaxWidth()) { drawerContent = {
Row { ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) {
Column(Modifier.verticalScroll(rememberScrollState())) { Column(Modifier.fillMaxWidth()) {
RemoteImage( Row {
url = drawableResource(R.drawable.ic_launcher_monochrome), Column(
modifier = Modifier modifier = Modifier
.size(48.dp) .verticalScroll(rememberScrollState())
.clip(CircleShape) .background(MaterialTheme.colorScheme.surface)
.clickable { viewModel.goToHome() }, ) {
description = "Home", IconButton(
) onClick = { viewModel.goToHome() },
RevoltAPI.serverCache.values.forEach { server -> modifier = Modifier
server.icon?.let { icon -> .padding(8.dp)
RemoteImage( .size(48.dp)
url = "$REVOLT_FILES/icons/${icon.id!!}/server.png?max_side=256", ) {
modifier = Modifier Icon(
.size(48.dp) Icons.Default.Home,
.clip(CircleShape) contentDescription = stringResource(id = R.string.home),
.clickable { viewModel.setCurrentServer(server.id!!) }, modifier = Modifier.padding(4.dp)
description = "${server.name}"
) )
} }
}
}
Column(
Modifier
.weight(1f)
) {
if (viewModel.currentServer != "home") {
val server = RevoltAPI.serverCache[viewModel.currentServer]
Text( RevoltAPI.serverCache.values.forEach { server ->
text = server?.name ?: "Unknown Server", if (server.name == null) return@forEach
fontWeight = FontWeight.Black,
fontSize = 24.sp
)
Column( if (server.icon != null) {
Modifier RemoteImage(
.weight(1f) url = "$REVOLT_FILES/icons/${server.icon.id!!}/server.png?max_side=256",
.verticalScroll(rememberScrollState()) modifier = Modifier
) { .padding(8.dp)
server?.channels?.forEach { channelId -> .size(48.dp)
RevoltAPI.channelCache[channelId]?.let { .clip(CircleShape)
.clickable { viewModel.setCurrentServer(server.id!!) },
description = "${server.name}"
)
} else {
// return a placeholder icon, currently the first letter of the server name in a circle
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { viewModel.setCurrentServer(server.id!!) }
) {
Text( Text(
text = it.name ?: "Unnamed Channel", text = server.name.first().toString(),
modifier = Modifier.clickable { fontSize = 20.sp,
scope.launch { channelDrawerState.close() } fontWeight = FontWeight.SemiBold,
navController.navigate("channel/${it.id}") color = MaterialTheme.colorScheme.onSurface
}
) )
} }
} }
} }
} else { }
Text(text = "Home not implemented!")
Crossfade(targetState = viewModel.currentServer) {
Column(
Modifier
.weight(1f)
) {
if (it == "home") {
Column(
Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.DirectMessage }
.forEach { channel ->
DrawerChannel(
name = "DM #${channel.id}", // TODO get user or group name
channelType = ChannelType.DirectMessage,
selected = channel.id == (navBackStackEntry?.arguments?.getString(
"channelId"
) ?: false),
onClick = {
navController.navigate("channel/${channel.id}")
scope.launch {
channelDrawerState.close()
}
}
)
}
}
} else {
val server = RevoltAPI.serverCache[it]
Text(
text = server?.name ?: stringResource(R.string.unknown),
fontWeight = FontWeight.Black,
fontSize = 24.sp,
modifier = Modifier.padding(16.dp)
)
Column(
Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
server?.channels?.forEach { channelId ->
RevoltAPI.channelCache[channelId]?.let { ch ->
DrawerChannel(
name = ch.name!!,
channelType = ch.channelType!!,
selected = navBackStackEntry?.arguments?.getString(
"channelId"
) == ch.id,
onClick = {
scope.launch { channelDrawerState.close() }
navController.navigate("channel/${ch.id}")
})
}
}
}
}
}
} }
} }
} }
} }
} }
}) { ) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
NavHost(navController = navController, startDestination = "home") { NavHost(navController = navController, startDestination = "home") {
composable("home") { composable("home") {

View File

@ -1,15 +1,17 @@
package chat.revolt.screens.chat.views package chat.revolt.screens.chat.views
import android.util.Log import android.util.Log
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -27,6 +29,8 @@ import chat.revolt.api.schemas.Message as MessageSchema
import chat.revolt.components.chat.Message import chat.revolt.components.chat.Message
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import chat.revolt.R import chat.revolt.R
import chat.revolt.RevoltTweenFloat
import chat.revolt.RevoltTweenInt
import chat.revolt.api.routes.channel.fetchMessagesFromChannel import chat.revolt.api.routes.channel.fetchMessagesFromChannel
import chat.revolt.components.chat.MessageField import chat.revolt.components.chat.MessageField
@ -181,30 +185,48 @@ fun ChannelScreen(
} }
Column { Column {
Text(text = "#" + channel.name!!) Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
) {
Text(
text = channel.name ?: channel.id!!,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(16.dp)
)
}
Divider() LazyColumn(Modifier.weight(1f)) {
items(viewModel.renderableMessages) { message ->
// Column nesting is needed to make the vertical scroll work properly Message(message)
Column(Modifier.weight(1f)) {
Column(Modifier.verticalScroll(scrollState)) {
viewModel.renderableMessages.forEach {
Message(message = it)
}
} }
} }
AnimatedVisibility(visible = viewModel.typingUsers.isNotEmpty()) { AnimatedVisibility(
visible = viewModel.typingUsers.isNotEmpty(),
enter = slideInVertically(
animationSpec = RevoltTweenInt,
initialOffsetY = { it }
) + fadeIn(animationSpec = RevoltTweenFloat),
exit = slideOutVertically(
animationSpec = RevoltTweenInt,
targetOffsetY = { it }
) + fadeOut(animationSpec = RevoltTweenFloat)
) {
Row( Row(
Modifier Modifier
.padding(all = 4.dp)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.fillMaxWidth()
.padding(all = 4.dp)
) { ) {
Text( Text(
text = stringResource( text = stringResource(
id = viewModel.typingMessageResource(), id = viewModel.typingMessageResource(),
viewModel.getTypingUsernames() viewModel.getTypingUsernames()
) ),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
} }
@ -218,7 +240,7 @@ fun ChannelScreen(
onMessageContentChange = viewModel::setMessageContent, onMessageContentChange = viewModel::setMessageContent,
onSendMessage = viewModel::sendPendingMessage, onSendMessage = viewModel::sendPendingMessage,
channelType = it, channelType = it,
channelName = channel.name channelName = channel.name ?: channel.id!!
) )
} }
} }

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11,9c0,1.66 -1.34,3 -3,3s-3,-1.34 -3,-3s1.34,-3 3,-3s3,1.34 3,3m3,11H2v-2c0,-2.21 2.69,-4 6,-4s6,1.79 6,4m8,-6v2h-9v-2m9,-4v2h-9V8m9,-4v2h-9V4Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,3h-4.18C14.4,1.84 13.3,1 12,1s-2.4,0.84 -2.82,2L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM12,2.75c0.41,0 0.75,0.34 0.75,0.75s-0.34,0.75 -0.75,0.75s-0.75,-0.34 -0.75,-0.75s0.34,-0.75 0.75,-0.75zM9.1,17L7,17v-2.14l5.96,-5.96l2.12,2.12L9.1,17zM16.85,9.27l-1.06,1.06l-2.12,-2.12l1.06,-1.06c0.2,-0.2 0.51,-0.2 0.71,0l1.41,1.41c0.2,0.2 0.2,0.51 0,0.71z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,4 @@
<vector android:height="24dp" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="m5.41,21l0.71,-4h-4l0.35,-2h4l1.06,-6h-4l0.35,-2h4l0.71,-4h2l-0.71,4h6l0.71,-4h2l-0.71,4h4l-0.35,2h-4l-1.06,6h4l-0.35,2h-4l-0.71,4h-2l0.71,-4h-6l-0.71,4h-2M9.53,9l-1.06,6h6l1.06,-6h-6Z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.5,12A4.5,4.5 0,0 0,14 7.97v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -74,8 +74,17 @@
<string name="tutorial">Welcome to Revolt\'s in-progress Android experience!</string> <string name="tutorial">Welcome to Revolt\'s in-progress Android experience!</string>
<string name="select_channel">Select a server and channel by swiping from the left.</string> <string name="select_channel">Select a server and channel by swiping from the left.</string>
<string name="unknown">Unknown</string>
<string name="home">Home</string>
<string name="avatar_alt">%1$s\'s avatar</string> <string name="avatar_alt">%1$s\'s avatar</string>
<string name="channel_dm">Direct Message</string>
<string name="channel_text">Text Channel</string>
<string name="channel_voice">Voice Channel</string>
<string name="channel_group">Group</string>
<string name="channel_notes">Notes</string>
<string name="message_field_placeholder_dm">Message @%1$s</string> <string name="message_field_placeholder_dm">Message @%1$s</string>
<string name="message_field_placeholder_text">Message #%1$s</string> <string name="message_field_placeholder_text">Message #%1$s</string>
<string name="message_field_placeholder_voice">Message #%1$s</string> <string name="message_field_placeholder_voice">Message #%1$s</string>