feat: revamp chat UI to be closer to web, add double drawer

This commit is contained in:
Infi 2023-02-18 03:34:08 +01:00
parent 738a2a832f
commit e5ab9b3228
11 changed files with 393 additions and 134 deletions

View File

@ -70,6 +70,7 @@ dependencies {
// Jetpack Compose
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-util"
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material3:material3'
implementation "androidx.compose.ui:ui-tooling-preview"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'

View File

@ -1,7 +1,9 @@
package chat.revolt.components.generic
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -26,7 +28,17 @@ enum class Presence {
Idle,
Dnd,
Focus,
Offline,
Offline
}
fun presenceFromStatus(status: String): Presence {
return when (status) {
"online" -> Presence.Online
"idle" -> Presence.Idle
"dnd" -> Presence.Dnd
"focus" -> Presence.Focus
else -> Presence.Offline
}
}
fun presenceColour(presence: Presence): Color {
@ -50,6 +62,7 @@ fun PresenceBadge(presence: Presence, size: Dp = 16.dp) {
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserAvatar(
username: String,
@ -60,6 +73,8 @@ fun UserAvatar(
rawUrl: String? = null,
size: Dp = 40.dp,
presenceSize: Dp = 16.dp,
onLongClick: (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
) {
Box(
modifier = modifier
@ -69,19 +84,35 @@ fun UserAvatar(
if (avatar != null) {
RemoteImage(
url = rawUrl ?: "$REVOLT_FILES/avatars/${avatar.id!!}/user.png",
description = stringResource(id = R.string.avatar_alt, username),
contentScale = ContentScale.Crop,
description = stringResource(id = R.string.avatar_alt, username),
modifier = Modifier
.clip(CircleShape)
.size(size)
.then(
if (onLongClick != null || onClick != null) Modifier
.combinedClickable(
onClick = { onClick?.invoke() },
onLongClick = { onLongClick?.invoke() }
)
else Modifier
)
)
} else {
RemoteImage(
url = "$REVOLT_BASE/users/${userId}/default_avatar",
description = stringResource(id = R.string.avatar_alt, username),
modifier = Modifier
.size(size)
.then(
if (onLongClick != null || onClick != null) Modifier
.combinedClickable(
onClick = { onClick?.invoke() },
onLongClick = { onLongClick?.invoke() }
)
else Modifier
)
.clip(CircleShape),
description = stringResource(id = R.string.avatar_alt, username),
)
}

View File

@ -1,71 +0,0 @@
package chat.revolt.components.screens.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.RevoltTweenIntSize
import kotlinx.coroutines.launch
@Composable
fun BottomNavigation(
navController: NavController,
show: Boolean,
) {
val scope = rememberCoroutineScope()
AnimatedVisibility(
visible = show,
enter = expandVertically(
animationSpec = RevoltTweenIntSize
),
exit = shrinkVertically(
animationSpec = RevoltTweenIntSize
),
) {
BottomAppBar(
containerColor = MaterialTheme.colorScheme.background,
) {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
scope.launch {
if (navController.currentDestination?.route != "chat") {
navController.navigate("chat")
}
}
}) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = stringResource(id = R.string.home),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
scope.launch {
if (navController.currentDestination?.route != "settings") {
navController.navigate("settings")
}
}
}) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(id = R.string.settings),
)
}
}
}
}

View File

@ -0,0 +1,185 @@
package chat.revolt.components.screens.chat
import android.content.res.Configuration
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.roundToInt
enum class DoubleDrawerOpenState {
Start,
Center,
End
}
@OptIn(ExperimentalMaterialApi::class)
class DoubleDrawerState(
var initialValue: DoubleDrawerOpenState = DoubleDrawerOpenState.Center,
val confirmStateChange: (DoubleDrawerOpenState) -> Boolean = { true }
) {
val swipeableState = SwipeableState<DoubleDrawerOpenState>(
initialValue = initialValue,
animationSpec = spring(),
confirmStateChange = confirmStateChange
)
suspend fun focusStart() {
swipeableState.animateTo(DoubleDrawerOpenState.Start)
}
suspend fun focusCenter() {
swipeableState.animateTo(DoubleDrawerOpenState.Center)
}
suspend fun focusEnd() {
swipeableState.animateTo(DoubleDrawerOpenState.End)
}
val isStart: Boolean
get() = swipeableState.currentValue == DoubleDrawerOpenState.Start
val isCenter: Boolean
get() = swipeableState.currentValue == DoubleDrawerOpenState.Center
val isEnd: Boolean
get() = swipeableState.currentValue == DoubleDrawerOpenState.End
val currentValue: DoubleDrawerOpenState
get() = swipeableState.currentValue
companion object {
fun Saver(
confirmStateChange: (DoubleDrawerOpenState) -> Boolean
): Saver<DoubleDrawerState, DoubleDrawerOpenState> = Saver(
save = { it.currentValue },
restore = { DoubleDrawerState(it, confirmStateChange) }
)
}
}
@Composable
fun rememberDoubleDrawerState(
initialValue: DoubleDrawerOpenState = DoubleDrawerOpenState.Center,
confirmStateChange: (DoubleDrawerOpenState) -> Boolean = { true }
): DoubleDrawerState = rememberSaveable(
saver = DoubleDrawerState.Saver(confirmStateChange)
) {
DoubleDrawerState(initialValue, confirmStateChange)
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun DoubleDrawer(
state: DoubleDrawerState = rememberDoubleDrawerState(),
startPanel: @Composable () -> Unit,
endPanel: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
val layoutDirection = LocalLayoutDirection.current
BoxWithConstraints(Modifier.fillMaxSize()) {
val isPortrait =
LocalContext.current.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
val drawerWeight =
if (isPortrait) 0.9f else 0.8f
val offsetValue =
(constraints.maxWidth * drawerWeight) + (LocalDensity.current.run { 16.dp.toPx() })
val isAtOffset = abs(state.swipeableState.offset.value) == abs(offsetValue)
val contentCornerRadius by animateDpAsState(
targetValue = if (isAtOffset) 16.dp else 0.dp,
)
Box(
modifier = Modifier
.fillMaxSize()
.swipeable(
state = state.swipeableState,
orientation = Orientation.Horizontal,
velocityThreshold = 500.dp,
anchors = mapOf(
offsetValue to DoubleDrawerOpenState.Start,
0f to DoubleDrawerOpenState.Center,
-offsetValue to DoubleDrawerOpenState.End
),
reverseDirection = layoutDirection == LayoutDirection.Rtl,
resistance = ResistanceConfig(0.5f, 0.5f)
)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(drawerWeight)
.clip(
RoundedCornerShape(
topEnd = 16.dp,
bottomEnd = 16.dp
)
)
.align(Alignment.CenterStart)
.offset {
IntOffset(
x = state.swipeableState.offset.value.roundToInt() - offsetValue.roundToInt(),
y = 0
)
},
) {
startPanel()
}
Box(
modifier = Modifier
.fillMaxSize()
.clip(
RoundedCornerShape(contentCornerRadius)
)
.align(Alignment.Center)
.offset {
IntOffset(
x = state.swipeableState.offset.value.roundToInt(),
y = 0
)
},
) {
content()
}
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(drawerWeight)
.clip(
RoundedCornerShape(
topStart = 16.dp,
bottomStart = 16.dp
)
)
.align(Alignment.CenterEnd)
.offset {
IntOffset(
x = state.swipeableState.offset.value.roundToInt() + offsetValue.roundToInt(),
y = 0
)
},
) {
endPanel()
}
}
}
}

View File

@ -1,4 +1,4 @@
package chat.revolt.components.screens.chat
package chat.revolt.components.screens.chat.drawer.server
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -14,6 +14,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.revolt.api.schemas.ChannelType
import chat.revolt.components.screens.chat.ChannelIcon
@Composable
fun DrawerChannel(

View File

@ -1,4 +1,4 @@
package chat.revolt.components.screens.chat
package chat.revolt.components.screens.chat.drawer.server
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable

View File

@ -1,4 +1,4 @@
package chat.revolt.components.screens.chat
package chat.revolt.components.screens.chat.drawer.server
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding

View File

@ -1,4 +1,4 @@
package chat.revolt.components.screens.chat
package chat.revolt.components.screens.chat.drawer.server
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box

View File

@ -1,19 +1,24 @@
package chat.revolt.screens.chat
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -31,9 +36,17 @@ import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.schemas.ChannelType
import chat.revolt.components.chat.DisconnectedNotice
import chat.revolt.components.screens.chat.*
import chat.revolt.components.generic.UserAvatar
import chat.revolt.components.generic.presenceFromStatus
import chat.revolt.components.screens.chat.DoubleDrawer
import chat.revolt.components.screens.chat.drawer.server.DrawerChannel
import chat.revolt.components.screens.chat.drawer.server.DrawerServer
import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon
import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator
import chat.revolt.components.screens.chat.rememberDoubleDrawerState
import chat.revolt.screens.chat.sheets.ChannelInfoSheet
import chat.revolt.screens.chat.sheets.MessageContextSheet
import chat.revolt.screens.chat.sheets.StatusSheet
import chat.revolt.screens.chat.views.ChannelScreen
import chat.revolt.screens.chat.views.HomeScreen
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
@ -72,11 +85,12 @@ class ChatRouterViewModel : ViewModel() {
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialNavigationApi::class)
@OptIn(ExperimentalMaterialNavigationApi::class)
@Composable
fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = viewModel()) {
val channelDrawerState = rememberDrawerState(DrawerValue.Closed)
val drawerState = rememberDoubleDrawerState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val bottomSheetNavigator = rememberBottomSheetNavigator()
val navController = rememberNavController(bottomSheetNavigator)
@ -93,56 +107,80 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
})
}
DismissibleNavigationDrawer(
drawerState = channelDrawerState,
drawerContent = {
ModalDrawerSheet(
drawerContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
) {
Column(Modifier.fillMaxWidth()) {
Row {
Column(
DoubleDrawer(
state = drawerState,
startPanel = {
Column(Modifier.fillMaxWidth()) {
Row {
Column(
modifier = Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState())
) {
UserAvatar(
username = RevoltAPI.userCache[RevoltAPI.selfId]?.username
?: "",
presence = presenceFromStatus(
RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence
?: ""
),
userId = RevoltAPI.selfId ?: "",
avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar,
size = 48.dp,
presenceSize = 16.dp,
onClick = {
viewModel.navigateToServer("home", navController)
},
onLongClick = {
navController.navigate("status")
},
modifier = Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp
.padding(8.dp)
.size(48.dp)
)
ServerDrawerSeparator()
RevoltAPI.serverCache.values
.sortedBy { it.id }
.forEach { server ->
if (server.name == null) return@forEach
DrawerServer(
iconId = server.icon?.id,
serverName = server.name
) {
viewModel.navigateToServer(
server.id!!,
navController
)
)
) {
DrawerServerlikeIcon(
onClick = {
viewModel.navigateToServer("home", navController)
}
) {
Icon(
Icons.Default.Home,
contentDescription = stringResource(id = R.string.home),
modifier = Modifier.padding(4.dp)
)
}
ServerDrawerSeparator()
RevoltAPI.serverCache.values
.sortedBy { it.id }
.forEach { server ->
if (server.name == null) return@forEach
DrawerServer(
iconId = server.icon?.id,
serverName = server.name
) {
viewModel.navigateToServer(
server.id!!,
navController
)
}
}
DrawerServerlikeIcon(
onClick = {
Toast.makeText(
context,
context.getString(R.string.comingsoon_toast),
Toast.LENGTH_SHORT
).show()
}
) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(id = R.string.server_plus_alt),
modifier = Modifier.padding(4.dp)
)
}
}
Crossfade(targetState = viewModel.currentServer) {
Crossfade(targetState = viewModel.currentServer) {
Surface(
tonalElevation = 1.dp,
modifier = Modifier
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(16.dp))
) {
Column(
Modifier
.weight(1f)
@ -165,7 +203,7 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
onClick = {
navController.navigate("channel/${channel.id}")
scope.launch {
channelDrawerState.close()
drawerState.focusCenter()
}
}
)
@ -196,13 +234,14 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
"channelId"
) == ch.id,
onClick = {
scope.launch { channelDrawerState.close() }
scope.launch { drawerState.focusCenter() }
navController.navigate("channel/${ch.id}") {
popUpTo("home") {
inclusive = true
}
}
})
}
)
}
}
}
@ -213,7 +252,16 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
}
}
},
modifier = Modifier.weight(1f),
endPanel = {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = "👋", fontSize = 64.sp)
}
},
) {
Column(Modifier.fillMaxSize()) {
NavHost(navController = navController, startDestination = "home") {
@ -248,14 +296,12 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
)
}
}
bottomSheet("status") {
StatusSheet(navController = navController, topNav = topNav)
}
}
}
}
BottomNavigation(
navController = topNav,
show = channelDrawerState.currentValue == DrawerValue.Open,
)
}
}
}

View File

@ -0,0 +1,64 @@
package chat.revolt.screens.chat.sheets
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.components.generic.SheetClickable
@Composable
fun StatusSheet(
navController: NavController,
topNav: NavController,
) {
if (RevoltAPI.selfId == null || RevoltAPI.userCache[RevoltAPI.selfId] == null) {
navController.popBackStack()
return
}
val selfUser = RevoltAPI.userCache[RevoltAPI.selfId]!!
Surface {
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState())
) {
Text(text = "Logged in as @${selfUser.username} (${selfUser.id})")
Spacer(modifier = Modifier.height(8.dp))
SheetClickable(
icon = { modifier ->
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.settings),
style = style
)
}
) {
topNav.navigate("settings")
}
}
}
}

View File

@ -86,6 +86,8 @@
<string name="home">Home</string>
<string name="logout">Log out</string>
<string name="server_plus_alt">Add server</string>
<string name="avatar_alt">%1$s\'s avatar</string>
<string name="channel_dm">Direct Message</string>