feat: revamp server name and banner display

also adds a bottom sheet and fixes an NPE

Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
Infi 2023-06-15 23:46:01 +02:00
parent 5db3d6f401
commit 8b7c36337b
5 changed files with 213 additions and 35 deletions

View File

@ -1,7 +1,8 @@
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.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -13,18 +14,23 @@ 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
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun IconPlaceholder( fun IconPlaceholder(
name: String, name: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) { ) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = modifier modifier = modifier
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp))
.then( .then(
if (onClick != {}) Modifier.clickable(onClick = onClick) if (onClick != {} || onLongClick != {}) Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
else Modifier else Modifier
) )
) { ) {

View File

@ -78,7 +78,7 @@ fun UIMarkdown(
memberMap = mapOf(), memberMap = mapOf(),
userMap = RevoltAPI.userCache.toMap(), userMap = RevoltAPI.userCache.toMap(),
channelMap = RevoltAPI.channelCache.mapValues { ch -> channelMap = RevoltAPI.channelCache.mapValues { ch ->
ch.value.name ?: ch.value.id!! ch.value.name ?: ch.value.id ?: "{this does not exist 🤫}"
}, },
emojiMap = RevoltAPI.emojiCache, emojiMap = RevoltAPI.emojiCache,
serverId = null serverId = null

View File

@ -1,33 +1,52 @@
package chat.revolt.components.screens.chat.drawer.channel package chat.revolt.components.screens.chat.drawer.channel
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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 chat.revolt.R import chat.revolt.R
import chat.revolt.activities.RevoltTweenDp
import chat.revolt.activities.RevoltTweenFloat
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.api.internals.ChannelUtils import chat.revolt.api.internals.ChannelUtils
@ -37,8 +56,12 @@ import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.generic.presenceFromStatus import chat.revolt.components.generic.presenceFromStatus
import chat.revolt.components.screens.chat.drawer.server.DrawerChannel import chat.revolt.components.screens.chat.drawer.server.DrawerChannel
import chat.revolt.sheets.ChannelContextSheet import chat.revolt.sheets.ChannelContextSheet
import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class) const val BANNER_HEIGHT_COMPACT = 56
const val BANNER_HEIGHT_EXPANDED = 128
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun RowScope.ChannelList( fun RowScope.ChannelList(
serverId: String, serverId: String,
@ -46,7 +69,27 @@ fun RowScope.ChannelList(
currentChannel: String?, currentChannel: String?,
onChannelClick: (String) -> Unit, onChannelClick: (String) -> Unit,
onSpecialClick: (String) -> Unit, onSpecialClick: (String) -> Unit,
onServerSheetOpenFor: (String) -> Unit,
) { ) {
val lazyListState = rememberLazyListState()
val enableSmallBanner by remember {
derivedStateOf {
lazyListState.firstVisibleItemScrollOffset > 40 ||
lazyListState.firstVisibleItemIndex > 0
}
}
val bannerHeight by animateDpAsState(
targetValue = if (enableSmallBanner) BANNER_HEIGHT_COMPACT.dp else BANNER_HEIGHT_EXPANDED.dp,
animationSpec = RevoltTweenDp,
label = "Banner Height"
)
val bannerImageOpacity by animateFloatAsState(
targetValue = if (enableSmallBanner) 0f else 1f,
animationSpec = RevoltTweenFloat,
label = "Banner Image Opacity"
)
var channelContextSheetShown by remember { mutableStateOf(false) } var channelContextSheetShown by remember { mutableStateOf(false) }
var channelContextSheetTarget by remember { mutableStateOf("") } var channelContextSheetTarget by remember { mutableStateOf("") }
@ -87,17 +130,30 @@ fun RowScope.ChannelList(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxSize(), .fillMaxSize(),
state = lazyListState,
) { ) {
if (serverId == "home") { if (serverId == "home") {
item( stickyHeader(
key = "header" key = "header"
) { ) {
Text( Box(
text = stringResource(R.string.direct_messages), modifier = Modifier
style = MaterialTheme.typography.labelLarge, .padding(start = 8.dp, end = 8.dp, top = 0.dp, bottom = 8.dp)
fontSize = 24.sp, .alpha(0.9f)
modifier = Modifier.padding(16.dp) .height(BANNER_HEIGHT_COMPACT.dp + 8.dp) // due to padding in Text
) .fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
.weight(1f)
) {
Text(
text = stringResource(R.string.direct_messages),
style = MaterialTheme.typography.labelLarge,
fontSize = 16.sp,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 16.dp)
)
}
} }
item( item(
@ -189,28 +245,110 @@ fun RowScope.ChannelList(
} else { } else {
val server = RevoltAPI.serverCache[serverId] val server = RevoltAPI.serverCache[serverId]
item { stickyHeader {
Text( Box(
text = server?.name contentAlignment = Alignment.BottomStart,
?: stringResource(R.string.unknown), modifier = Modifier
style = MaterialTheme.typography.labelLarge, .then(
fontSize = 24.sp, // if there is no banner, we change the design slightly.
modifier = Modifier.padding(16.dp) // instead of there being a banner card we make a "classic"
) // sticky header á la Google Messages
} if (server?.banner != null) {
Modifier.padding(vertical = 8.dp, horizontal = 8.dp)
} else {
Modifier.padding(
start = 0.dp,
end = 8.dp,
top = 0.dp,
bottom = 0.dp
)
}
)
.fillMaxWidth()
) {
if (server?.banner != null) {
Box(modifier = Modifier.height(bannerHeight)) {
Box(
modifier = Modifier
.alpha(max(0.95f, bannerImageOpacity))
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surface)
)
server?.banner?.let { RemoteImage(
item { url = "$REVOLT_FILES/banners/${server.banner.id}",
RemoteImage( description = null,
url = "$REVOLT_FILES/banners/${it.id}", modifier = Modifier
description = null, .alpha(bannerImageOpacity)
modifier = Modifier .fillMaxSize()
.fillMaxWidth() .clip(RoundedCornerShape(16.dp))
.height(200.dp) )
.padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 16.dp)
.clip(RoundedCornerShape(16.dp)) Box(
) modifier = Modifier
.alpha(bannerImageOpacity)
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(
Brush.verticalGradient(
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.3f)
)
)
)
)
}
} else {
Box(
modifier = Modifier
.alpha(0.9f)
.height(BANNER_HEIGHT_COMPACT.dp + 8.dp) // due to padding in Text
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = (server?.name
?: stringResource(R.string.unknown)),
style = MaterialTheme.typography.labelLarge,
fontSize = 16.sp,
modifier = Modifier
.then(
if (server?.banner != null) {
Modifier.padding(16.dp)
} else {
Modifier.padding(
start = 24.dp,
end = 24.dp,
top = 16.dp,
bottom = 16.dp
)
}
)
.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
IconButton(onClick = {
onServerSheetOpenFor(serverId)
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(
id = R.string.settings
)
)
}
}
} }
} }
if (server?.channels?.isEmpty() == true) { if (server?.channels?.isEmpty() == true) {
@ -242,8 +380,8 @@ fun RowScope.ChannelList(
server?.channels?.get(it)?.let { channelId -> server?.channels?.get(it)?.let { channelId ->
RevoltAPI.channelCache[channelId]?.let { ch -> RevoltAPI.channelCache[channelId]?.let { ch ->
DrawerChannel( DrawerChannel(
name = ch.name!!, name = ch.name ?: stringResource(R.string.unknown),
channelType = ch.channelType!!, channelType = ch.channelType ?: ChannelType.TextChannel,
selected = currentDestination == "channel/{channelId}" && currentChannel == ch.id, selected = currentDestination == "channel/{channelId}" && currentChannel == ch.id,
hasUnread = ch.lastMessageID?.let { lastMessageID -> hasUnread = ch.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread( RevoltAPI.unreads.hasUnread(

View File

@ -2,8 +2,9 @@ package chat.revolt.components.screens.chat.drawer.server
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -20,11 +21,13 @@ import chat.revolt.api.REVOLT_FILES
import chat.revolt.components.generic.IconPlaceholder import chat.revolt.components.generic.IconPlaceholder
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun DrawerServer( fun DrawerServer(
iconId: String?, iconId: String?,
serverName: String, serverName: String,
hasUnreads: Boolean, hasUnreads: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val unreadIndicatorAlpha = animateFloatAsState( val unreadIndicatorAlpha = animateFloatAsState(
@ -43,13 +46,17 @@ fun DrawerServer(
.padding(8.dp) .padding(8.dp)
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onClick), .combinedClickable(
onClick = onClick,
onLongClick = onLongClick
),
description = serverName description = serverName
) )
} else { } else {
IconPlaceholder( IconPlaceholder(
name = serverName, name = serverName,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier modifier = Modifier
.padding(8.dp) .padding(8.dp)
.size(48.dp) .size(48.dp)

View File

@ -187,6 +187,9 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
var showStatusSheet by remember { mutableStateOf(false) } var showStatusSheet by remember { mutableStateOf(false) }
var showAddServerSheet by remember { mutableStateOf(false) } var showAddServerSheet by remember { mutableStateOf(false) }
var showServerContextSheet by remember { mutableStateOf(false) }
var serverContextSheetTarget by remember { mutableStateOf("") }
BackHandler(enabled = drawerState.isClosed) { BackHandler(enabled = drawerState.isClosed) {
scope.launch { scope.launch {
drawerState.open() drawerState.open()
@ -304,6 +307,22 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
} }
} }
if (showServerContextSheet) {
val serverContextSheetState = rememberModalBottomSheetState()
ModalBottomSheet(
sheetState = serverContextSheetState,
onDismissRequest = {
showServerContextSheet = false
},
) {
Column {
Text(text = "this is server context sheet for $serverContextSheetTarget")
}
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -385,6 +404,10 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
hasUnreads = RevoltAPI.unreads.serverHasUnread( hasUnreads = RevoltAPI.unreads.serverHasUnread(
server.id server.id
), ),
onLongClick = {
serverContextSheetTarget = server.id
showServerContextSheet = true
},
) { ) {
viewModel.navigateToServer( viewModel.navigateToServer(
server.id, server.id,
@ -422,6 +445,10 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
viewModel.navigateToSpecial(destination, navController) viewModel.navigateToSpecial(destination, navController)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
onServerSheetOpenFor = { target ->
serverContextSheetTarget = target
showServerContextSheet = true
},
) )
} }
} }