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 // Jetpack Compose
implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-util" implementation "androidx.compose.ui:ui-util"
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material3:material3'
implementation "androidx.compose.ui:ui-tooling-preview" implementation "androidx.compose.ui:ui-tooling-preview"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'

View File

@ -1,7 +1,9 @@
package chat.revolt.components.generic package chat.revolt.components.generic
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -26,7 +28,17 @@ enum class Presence {
Idle, Idle,
Dnd, Dnd,
Focus, 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 { fun presenceColour(presence: Presence): Color {
@ -50,6 +62,7 @@ fun PresenceBadge(presence: Presence, size: Dp = 16.dp) {
) )
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun UserAvatar( fun UserAvatar(
username: String, username: String,
@ -60,6 +73,8 @@ fun UserAvatar(
rawUrl: String? = null, rawUrl: String? = null,
size: Dp = 40.dp, size: Dp = 40.dp,
presenceSize: Dp = 16.dp, presenceSize: Dp = 16.dp,
onLongClick: (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
) { ) {
Box( Box(
modifier = modifier modifier = modifier
@ -69,19 +84,35 @@ fun UserAvatar(
if (avatar != null) { if (avatar != null) {
RemoteImage( RemoteImage(
url = rawUrl ?: "$REVOLT_FILES/avatars/${avatar.id!!}/user.png", url = rawUrl ?: "$REVOLT_FILES/avatars/${avatar.id!!}/user.png",
description = stringResource(id = R.string.avatar_alt, username),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
description = stringResource(id = R.string.avatar_alt, username),
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.size(size) .size(size)
.then(
if (onLongClick != null || onClick != null) Modifier
.combinedClickable(
onClick = { onClick?.invoke() },
onLongClick = { onLongClick?.invoke() }
)
else Modifier
)
) )
} else { } else {
RemoteImage( RemoteImage(
url = "$REVOLT_BASE/users/${userId}/default_avatar", url = "$REVOLT_BASE/users/${userId}/default_avatar",
description = stringResource(id = R.string.avatar_alt, username),
modifier = Modifier modifier = Modifier
.size(size) .size(size)
.then(
if (onLongClick != null || onClick != null) Modifier
.combinedClickable(
onClick = { onClick?.invoke() },
onLongClick = { onLongClick?.invoke() }
)
else Modifier
)
.clip(CircleShape), .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.background
import androidx.compose.foundation.clickable 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.components.screens.chat.ChannelIcon
@Composable @Composable
fun DrawerChannel( 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.background
import androidx.compose.foundation.clickable 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.background
import androidx.compose.foundation.layout.padding 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box

View File

@ -1,19 +1,24 @@
package chat.revolt.screens.chat package chat.revolt.screens.chat
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background import androidx.compose.foundation.background
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.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.platform.LocalContext
import androidx.compose.ui.res.stringResource 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
@ -31,9 +36,17 @@ import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.components.chat.DisconnectedNotice 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.ChannelInfoSheet
import chat.revolt.screens.chat.sheets.MessageContextSheet 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.ChannelScreen
import chat.revolt.screens.chat.views.HomeScreen import chat.revolt.screens.chat.views.HomeScreen
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
@ -72,11 +85,12 @@ class ChatRouterViewModel : ViewModel() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialNavigationApi::class) @OptIn(ExperimentalMaterialNavigationApi::class)
@Composable @Composable
fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = viewModel()) { fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = viewModel()) {
val channelDrawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDoubleDrawerState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current
val bottomSheetNavigator = rememberBottomSheetNavigator() val bottomSheetNavigator = rememberBottomSheetNavigator()
val navController = rememberNavController(bottomSheetNavigator) val navController = rememberNavController(bottomSheetNavigator)
@ -93,56 +107,80 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
}) })
} }
DismissibleNavigationDrawer( DoubleDrawer(
drawerState = channelDrawerState, state = drawerState,
drawerContent = { startPanel = {
ModalDrawerSheet( Column(Modifier.fillMaxWidth()) {
drawerContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) Row {
) { Column(
Column(Modifier.fillMaxWidth()) { modifier = Modifier
Row { .fillMaxHeight()
Column( .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 modifier = Modifier
.fillMaxHeight() .padding(8.dp)
.verticalScroll(rememberScrollState()) .size(48.dp)
.background( )
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.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() DrawerServerlikeIcon(
onClick = {
RevoltAPI.serverCache.values Toast.makeText(
.sortedBy { it.id } context,
.forEach { server -> context.getString(R.string.comingsoon_toast),
if (server.name == null) return@forEach Toast.LENGTH_SHORT
).show()
DrawerServer( }
iconId = server.icon?.id, ) {
serverName = server.name Icon(
) { Icons.Default.Add,
viewModel.navigateToServer( contentDescription = stringResource(id = R.string.server_plus_alt),
server.id!!, modifier = Modifier.padding(4.dp)
navController )
)
}
}
} }
}
Crossfade(targetState = viewModel.currentServer) { Crossfade(targetState = viewModel.currentServer) {
Surface(
tonalElevation = 1.dp,
modifier = Modifier
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(16.dp))
) {
Column( Column(
Modifier Modifier
.weight(1f) .weight(1f)
@ -165,7 +203,7 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
onClick = { onClick = {
navController.navigate("channel/${channel.id}") navController.navigate("channel/${channel.id}")
scope.launch { scope.launch {
channelDrawerState.close() drawerState.focusCenter()
} }
} }
) )
@ -196,13 +234,14 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
"channelId" "channelId"
) == ch.id, ) == ch.id,
onClick = { onClick = {
scope.launch { channelDrawerState.close() } scope.launch { drawerState.focusCenter() }
navController.navigate("channel/${ch.id}") { navController.navigate("channel/${ch.id}") {
popUpTo("home") { popUpTo("home") {
inclusive = true 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()) { Column(Modifier.fillMaxSize()) {
NavHost(navController = navController, startDestination = "home") { 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="home">Home</string>
<string name="logout">Log out</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="avatar_alt">%1$s\'s avatar</string>
<string name="channel_dm">Direct Message</string> <string name="channel_dm">Direct Message</string>