feat: prototype for user cards

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-12-01 04:16:08 +01:00
parent d573988cce
commit 2d76949644
15 changed files with 758 additions and 13 deletions

View File

@ -251,9 +251,10 @@ dependencies {
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.webkit:webkit:1.12.1")
implementation("androidx.core:core-splashscreen:1.2.0-alpha02")
implementation("androidx.palette:palette-ktx:1.0.0")
// Libraries used for legacy View-based UI
implementation("androidx.constraintlayout:constraintlayout:2.2.0-rc01")
implementation("androidx.constraintlayout:constraintlayout:2.2.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
@ -272,6 +273,10 @@ dependencies {
// Compose libraries
implementation("me.saket.telephoto:zoomable-image:1.0.0-alpha02")
implementation("me.saket.telephoto:zoomable-image-glide:1.0.0-alpha02")
implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0")
// ZXing - QR Code generation
implementation("com.google.zxing:core:3.5.3")
// Persistence
implementation("app.cash.sqldelight:android-driver:2.0.1")

View File

@ -202,8 +202,6 @@ class MainActivityViewModel @Inject constructor(
return@launch startWithoutDestination()
} catch (e: Exception) {
Log.e("MainActivity", "Failed to check onboarding state, clearing session", e)
kvStorage.remove("sessionToken")
kvStorage.remove("sessionId")
startWithDestination("login/greeting")
}

View File

@ -0,0 +1,14 @@
package chat.revolt.api.internals
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.api
import chat.revolt.api.schemas.User
object ResourceLocations {
fun userAvatarUrl(user: User?): String {
if (user?.avatar != null) {
return "$REVOLT_FILES/avatars/${user.avatar.id}/user.png?max_side=256"
}
return "/users/${(user?.id ?: "").ifBlank { "0".repeat(26) }}/default_avatar".api()
}
}

View File

@ -0,0 +1,39 @@
package chat.revolt.api.internals
import chat.revolt.api.RevoltCbor
import chat.revolt.api.schemas.User
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@Serializable
data class UserQRContents(
val format: String = "rqr\$user\$0",
val avatar: String,
val displayName: String,
val username: String,
val discriminator: String,
val id: String,
)
object UserQR {
@OptIn(ExperimentalSerializationApi::class, ExperimentalEncodingApi::class)
fun contents(user: User): String {
return "https://revolt.chat/qr?" + Base64.encode(
RevoltCbor.encodeToByteArray(
UserQRContents.serializer(),
UserQRContents(
avatar = user.avatar?.id
?: "01JDZRBY95P8AY4CFVX16FFVWS", // Sentinel value for missing avatar
displayName = user.displayName
?: "01JDZRDD1YZ84HA8EST2E5GVXT", // Sentinel value for missing display name
username = user.username
?: "01JDZRBAG7AN9PGKKC5GSR7Z72", // Sentinel value for missing username
discriminator = user.discriminator ?: "0000",
id = user.id ?: "01JDZRDSJXH77K36XAFG1GX9JY" // Sentinel value for missing id
)
)
)
}
}

View File

@ -16,6 +16,7 @@ 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.graphics.Shape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@ -89,6 +90,7 @@ fun UserAvatar(
rawUrl: String? = null,
size: Dp = 40.dp,
presenceSize: Dp = 16.dp,
shape: Shape = RoundedCornerShape(LoadedSettings.avatarRadius),
onLongClick: (() -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
@ -103,7 +105,7 @@ fun UserAvatar(
contentScale = ContentScale.Crop,
description = stringResource(id = R.string.avatar_alt, username),
modifier = Modifier
.clip(RoundedCornerShape(LoadedSettings.avatarRadius))
.clip(shape)
.size(size)
.then(
if (onLongClick != null || onClick != null) {
@ -122,7 +124,7 @@ fun UserAvatar(
url = "$REVOLT_BASE/users/${userId.ifBlank { "0".repeat(26) }}/default_avatar",
description = stringResource(id = R.string.avatar_alt, username),
modifier = Modifier
.clip(RoundedCornerShape(LoadedSettings.avatarRadius))
.clip(shape)
.size(size)
.then(
if (onLongClick != null || onClick != null) {

View File

@ -0,0 +1,360 @@
package chat.revolt.components.profile
import android.graphics.Bitmap
import android.icu.text.DateFormat
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.core.graphics.drawable.toBitmap
import androidx.palette.graphics.Palette
import chat.revolt.R
import chat.revolt.api.internals.ResourceLocations
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.UserQR
import chat.revolt.api.schemas.User
import chat.revolt.components.generic.UserAvatar
import chat.revolt.ui.theme.FragmentMono
import com.bumptech.glide.Glide
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Instant
import java.util.Date
@OptIn(ExperimentalTextApi::class)
@Composable
fun UserCard(
user: User? = null,
graphicsLayer: GraphicsLayer = rememberGraphicsLayer(),
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val textMeasurer = rememberTextMeasurer()
var palette by remember { mutableStateOf<Palette?>(null) }
LaunchedEffect(user) {
val avatarUrl = ResourceLocations.userAvatarUrl(user)
val bitmap = withContext(Dispatchers.IO) {
Glide.with(context).load(avatarUrl).submit().get().toBitmap()
}
palette = Palette.from(bitmap).generate()
}
var qrCode by remember(user) { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(user) {
withContext(Dispatchers.IO) {
try {
if (user == null) throw NullPointerException("User is null")
val matrix = QRCodeWriter().encode(
UserQR.contents(user),
BarcodeFormat.QR_CODE,
512,
512,
mapOf(EncodeHintType.MARGIN to "1")
)
val bitmap =
Bitmap.createBitmap(matrix.width, matrix.height, Bitmap.Config.ARGB_8888)
for (x in 0 until matrix.width) {
for (y in 0 until matrix.height) {
bitmap.setPixel(
x,
y,
if (matrix.get(
x,
y
)
) Color.White.toArgb() else Color.Transparent.toArgb()
)
}
}
Log.d("UserCard", "Generated QR code for user ${user.id}")
qrCode = bitmap
} catch (e: Exception) {
e.printStackTrace()
}
}
}
Box(
Modifier
.clip(MaterialTheme.shapes.medium)
.aspectRatio(9 / 12f)
.drawWithContent {
graphicsLayer.record {
this@drawWithContent.drawContent()
}
drawLayer(graphicsLayer)
}
.then(modifier)
) {
CompositionLocalProvider(
LocalContentColor provides Color.White,
LocalTextStyle provides LocalTextStyle.current.copy(
fontFamily = FontFamily(
Font(
R.font.revolt_usercard,
variationSettings = FontVariation.Settings(FontVariation.weight(200)),
weight = FontWeight.ExtraLight
),
Font(
R.font.revolt_usercard,
variationSettings = FontVariation.Settings(FontVariation.weight(300)),
weight = FontWeight.Light
),
Font(
R.font.revolt_usercard,
variationSettings = FontVariation.Settings(FontVariation.weight(400)),
weight = FontWeight.Normal
),
Font(
R.font.revolt_usercard,
variationSettings = FontVariation.Settings(FontVariation.weight(500)),
weight = FontWeight.Medium
),
Font(
R.font.revolt_usercard,
variationSettings = FontVariation.Settings(FontVariation.weight(700)),
weight = FontWeight.Bold
),
Font(
R.font.revolt_usercard,
variationSettings = FontVariation.Settings(FontVariation.weight(900)),
weight = FontWeight.Black
)
)
)
) {
ConstraintLayout(
Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.medium)
.background(
Brush.linearGradient(
listOf(
Color(0xFF150E2A),
Color(0xFF332354)
),
start = Offset.Zero,
end = Offset.Infinite
)
)
.padding(9.dp)
.border(3.dp, palette?.mutedSwatch?.rgb?.let { Color(it) }
?: Color(0xFFFF005C), shape = MaterialTheme.shapes.medium)
.padding(16.dp)
) {
val (heading, nameLabel, name, usernameLabel, username, tagLabel, tag, joinDateLabel, joinDate, qrLabel, qr, photoLabel, photo, url) = createRefs()
Image(
painter = painterResource(R.drawable.usercard_heading),
contentDescription = null,
modifier = Modifier
.fillMaxWidth(0.5f)
.constrainAs(heading) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}
)
Text(
stringResource(R.string.user_card_data_point_display_name, 1).uppercase(),
fontFamily = FragmentMono,
fontSize = 12.sp,
letterSpacing = 1.sp,
modifier = Modifier
.constrainAs(nameLabel) {
bottom.linkTo(name.top)
start.linkTo(name.start)
}
)
Text(
user?.displayName ?: user?.username ?: stringResource(R.string.unknown),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth(0.66f)
.constrainAs(name) {
bottom.linkTo(usernameLabel.top, margin = 8.dp)
start.linkTo(usernameLabel.start)
}
)
Text(
stringResource(R.string.user_card_data_point_username, 2).uppercase(),
fontFamily = FragmentMono,
fontSize = 12.sp,
letterSpacing = 1.sp,
modifier = Modifier
.constrainAs(usernameLabel) {
bottom.linkTo(username.top)
start.linkTo(username.start)
}
)
Text(
text = buildAnnotatedString {
pushStyle(SpanStyle(fontWeight = FontWeight.Medium))
append(user?.username ?: stringResource(R.string.unknown))
pop()
pushStyle(SpanStyle(fontWeight = FontWeight.ExtraLight))
append("#${user?.discriminator ?: "0000"}")
pop()
},
fontSize = 24.sp,
modifier = Modifier
.fillMaxWidth(0.66f)
.constrainAs(username) {
bottom.linkTo(joinDateLabel.top, margin = 8.dp)
start.linkTo(joinDateLabel.start)
}
)
Text(
stringResource(R.string.user_card_data_point_join_date, 3).uppercase(),
fontFamily = FragmentMono,
fontSize = 12.sp,
letterSpacing = 1.sp,
modifier = Modifier
.constrainAs(joinDateLabel) {
bottom.linkTo(joinDate.top)
start.linkTo(joinDate.start)
}
)
Text(
DateFormat.getDateInstance().format(
Date.from(
Instant.ofEpochMilli(
ULID.asTimestamp(
user?.id ?: "00000000000000000000000000"
)
)
)
),
fontSize = 24.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.constrainAs(joinDate) {
bottom.linkTo(qrLabel.top, margin = 8.dp)
start.linkTo(qrLabel.start)
}
)
Text(
stringResource(R.string.user_card_data_point_qrcode, 4).uppercase(),
fontFamily = FragmentMono,
fontSize = 12.sp,
letterSpacing = 1.sp,
modifier = Modifier
.constrainAs(qrLabel) {
bottom.linkTo(qr.top)
start.linkTo(qr.start)
}
)
Image(
painter = qrCode?.let { BitmapPainter(it.asImageBitmap()) }
?: painterResource(R.drawable.usercard_qr_placeholder),
contentDescription = null,
modifier = Modifier
.fillMaxWidth(0.5f)
.aspectRatio(1f)
.constrainAs(qr) {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
}
)
Text(
stringResource(R.string.user_card_data_point_photo, 5).uppercase(),
fontFamily = FragmentMono,
fontSize = 12.sp,
letterSpacing = 1.sp,
modifier = Modifier
.constrainAs(photoLabel) {
top.linkTo(nameLabel.top)
start.linkTo(photo.start)
}
)
BoxWithConstraints(Modifier.constrainAs(photo) {
top.linkTo(photoLabel.bottom, margin = 8.dp)
end.linkTo(parent.end)
}) {
UserAvatar(
username = user?.username ?: stringResource(R.string.unknown),
userId = user?.id ?: "00000000000000000000000000",
avatar = user?.avatar,
shape = CircleShape,
size = maxWidth / 3
)
}
Text(
"revolt.chat",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.constrainAs(url) {
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
}
)
}
}
}
}

View File

@ -30,9 +30,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@ -57,6 +59,7 @@ import chat.revolt.components.generic.NonIdealState
import chat.revolt.components.screens.settings.UserOverview
import chat.revolt.components.skeletons.UserOverviewSkeleton
import chat.revolt.internals.extensions.zero
import chat.revolt.sheets.UserCardSheet
import io.sentry.Sentry
@OptIn(ExperimentalMaterial3Api::class)
@ -85,6 +88,18 @@ fun OverviewScreen(navController: NavController, useDrawer: Boolean, onDrawerCli
}
}
var showUserCardSheet by rememberSaveable { mutableStateOf(false) }
if (showUserCardSheet) {
val state = rememberModalBottomSheetState()
ModalBottomSheet(
sheetState = state,
onDismissRequest = { showUserCardSheet = false },
) {
UserCardSheet(user = user)
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
@ -189,12 +204,7 @@ fun OverviewScreen(navController: NavController, useDrawer: Boolean, onDrawerCli
item(key = "shareProfile") {
OverviewScreenLink(
onClick = {
Toast.makeText(
context,
context.getString(R.string.comingsoon_toast),
Toast.LENGTH_SHORT
).show()
// navController.navigate("userCard")
showUserCardSheet = true
},
backgroundColour = MaterialTheme.colorScheme.tertiaryContainer,
foregroundColour = MaterialTheme.colorScheme.onTertiaryContainer,

View File

@ -0,0 +1,238 @@
package chat.revolt.sheets
import android.content.ClipData
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import chat.revolt.BuildConfig
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.schemas.User
import chat.revolt.components.profile.UserCard
import chat.revolt.internals.Platform
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
@Composable
fun UserCardSheet(user: User?) {
val scope = rememberCoroutineScope()
val cardGraphics = rememberGraphicsLayer()
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var hasError by remember { mutableStateOf(false) }
suspend fun shareCard() {
val folder = File(
context.cacheDir,
"usercards"
).let { File(it, RevoltAPI.selfId.toString()) }
try {
folder.mkdirs()
val bitmap = cardGraphics.toImageBitmap()
val file = File(folder, "usercard.png")
val outputStream = withContext(Dispatchers.IO) {
FileOutputStream(file)
}
bitmap
.asAndroidBitmap()
.compress(
Bitmap.CompressFormat.PNG,
90,
outputStream
)
withContext(Dispatchers.IO) {
outputStream.flush()
outputStream.close()
}
val uri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND)
intent.type = "image/png"
intent.putExtra(
Intent.EXTRA_TITLE,
"User Card"
)
intent.putExtra(
Intent.EXTRA_SUBJECT,
"User Card"
)
intent.putExtra(Intent.EXTRA_STREAM, uri)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(Intent.createChooser(intent, null))
} catch (e: Exception) {
hasError = true
e.printStackTrace()
}
}
suspend fun copyCard() {
val folder = File(
context.cacheDir,
"usercards"
).let { File(it, RevoltAPI.selfId.toString()) }
folder.mkdirs()
val bitmap = cardGraphics.toImageBitmap()
val file = File(folder, "usercard.png")
val outputStream = withContext(Dispatchers.IO) {
FileOutputStream(file)
}
bitmap
.asAndroidBitmap()
.compress(
Bitmap.CompressFormat.PNG,
90,
outputStream
)
withContext(Dispatchers.IO) {
outputStream.flush()
outputStream.close()
}
val uri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
clipboardManager.nativeClipboard.setPrimaryClip(
ClipData.newUri(
context.contentResolver,
"User Card",
uri
)
)
}
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.user_card_tap_to_copy),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
)
UserCard(
user = user,
graphicsLayer = cardGraphics,
modifier = Modifier
.clickable {
scope.launch {
copyCard()
}
}
)
AnimatedVisibility(visible = hasError) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.user_card_error_sharing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
if (Platform.needsShowClipboardNotification()) {
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = {
scope.launch {
shareCard()
}
},
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(R.drawable.ic_share_24dp),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(R.string.share),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TextButton(
onClick = {
scope.launch {
copyCard()
}
},
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(R.drawable.ic_content_copy_24dp),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(R.string.copy),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}

View File

@ -2,6 +2,7 @@ package chat.revolt.sheets
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -15,10 +16,14 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -51,6 +56,7 @@ import chat.revolt.components.screens.settings.UserButtons
import chat.revolt.components.sheets.SheetTile
import kotlinx.datetime.Instant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserInfoSheet(
userId: String,
@ -102,6 +108,17 @@ fun UserInfoSheet(
return
}
var showUserCard by remember { mutableStateOf(false) }
if (showUserCard) {
val sheetState = rememberModalBottomSheetState(true)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = { showUserCard = false }
) {
UserCardSheet(user)
}
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -109,7 +126,20 @@ fun UserInfoSheet(
modifier = Modifier.padding(16.dp)
) {
item(key = "overview", span = StaggeredGridItemSpan.FullLine) {
RawUserOverview(user, profile, internalPadding = false)
Box {
RawUserOverview(user, profile, internalPadding = false)
SmallFloatingActionButton(
onClick = { showUserCard = true },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 8.dp, end = 8.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_badge_account_horizontal_24dp),
contentDescription = null
)
}
}
}
member?.roles?.let {

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M22,4H14V7H10V4H2A2,2 0 0,0 0,6V20A2,2 0 0,0 2,22H22A2,2 0 0,0 24,20V6A2,2 0 0,0 22,4M8,9A2,2 0 0,1 10,11A2,2 0 0,1 8,13A2,2 0 0,1 6,11A2,2 0 0,1 8,9M12,17H4V16C4,14.67 6.67,14 8,14C9.33,14 12,14.67 12,16V17M20,18H14V16H20V18M20,14H14V12H20V14M20,10H14V8H20V10M13,6H11V2H13V6Z" />
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="200dp"
android:height="25dp"
android:viewportWidth="200"
android:viewportHeight="25">
<path
android:pathData="M3.35,19.77C3.35,15.27 3.35,12.31 3.35,8.48L0.49,5.06H9.76C10.86,5.06 11.82,5.25 12.65,5.65C13.48,6.05 14.12,6.62 14.58,7.37C15.04,8.12 15.27,9.01 15.27,10.06C15.27,11.11 15.03,12 14.56,12.72C14.09,13.45 13.43,13.99 12.58,14.36C11.73,14.73 10.75,14.91 9.61,14.91H5.79V11.81H8.8C9.28,11.81 9.68,11.75 10.01,11.64C10.35,11.52 10.61,11.33 10.79,11.07C10.97,10.81 11.06,10.47 11.06,10.06C11.06,9.64 10.97,9.29 10.79,9.03C10.61,8.76 10.35,8.56 10.01,8.44C9.68,8.31 9.28,8.25 8.8,8.25H7.38V19.77H3.35ZM12.05,13.02L15.76,19.77H11.38L7.76,13.02H12.05Z"
android:fillColor="#FF005C" />
<path
android:pathData="M17.36,19.77V5.04H21.29V19.77H17.36ZM20.12,19.77L20.12,16.61H26.17L26.17,19.77H20.12ZM20.12,14.14L20.12,11.07H26.17L26.17,14.14H20.12ZM20.12,8.44L20.12,5.04H26.17V8.44H20.12Z"
android:fillColor="#FF005C" />
<path
android:pathData="M34.04,19.77L39.26,5.04H43.5L37.84,19.77H34.04ZM33.6,19.77L27.89,5.04H32.13L37.39,19.77H33.6Z"
android:fillColor="#FF005C" />
<path
android:pathData="M51.73,20.02C50.17,20.02 48.79,19.7 47.59,19.06C46.4,18.4 45.47,17.5 44.79,16.36C44.11,15.21 43.77,13.9 43.77,12.43C43.77,10.94 44.11,9.63 44.79,8.49C45.47,7.34 46.4,6.44 47.59,5.8C48.78,5.14 50.14,4.81 51.68,4.81C53.24,4.81 54.61,5.14 55.8,5.8C56.99,6.44 57.92,7.34 58.6,8.49C59.28,9.63 59.62,10.94 59.62,12.43C59.62,13.9 59.28,15.21 58.6,16.36C57.94,17.5 57.01,18.4 55.82,19.06C54.63,19.7 53.27,20.02 51.73,20.02ZM51.73,16.45C52.49,16.45 53.16,16.28 53.74,15.94C54.32,15.59 54.77,15.11 55.1,14.51C55.42,13.91 55.59,13.21 55.59,12.43C55.59,11.64 55.42,10.95 55.08,10.34C54.75,9.72 54.29,9.25 53.7,8.91C53.12,8.56 52.45,8.38 51.68,8.38C50.93,8.38 50.26,8.56 49.67,8.91C49.09,9.25 48.63,9.72 48.29,10.34C47.96,10.95 47.8,11.64 47.8,12.43C47.8,13.21 47.97,13.91 48.31,14.51C48.65,15.11 49.11,15.59 49.69,15.94C50.28,16.28 50.96,16.45 51.73,16.45Z"
android:fillColor="#FF005C"
tools:ignore="VectorPath" />
<path
android:pathData="M61.42,19.77V5.04H65.34V19.77H61.42ZM63.63,19.77L63.63,16.45L70.12,16.45L70.12,19.77L63.63,19.77Z"
android:fillColor="#FF005C" />
<path
android:pathData="M73.16,19.77V6.26H77.08V19.77H73.16ZM68.96,8.38L68.96,5.04H81.26L81.26,8.38H68.96Z"
android:fillColor="#FF005C" />
<path
android:pathData="M93.67,20.24C92.44,20.24 91.37,20.01 90.45,19.56C89.54,19.11 88.83,18.48 88.33,17.67C87.83,16.85 87.59,15.92 87.59,14.85V5.22H90.57V14.6C90.57,15.47 90.85,16.19 91.42,16.76C91.98,17.32 92.73,17.6 93.67,17.6C94.61,17.6 95.37,17.32 95.93,16.76C96.49,16.19 96.77,15.48 96.77,14.6V5.22H99.76V14.85C99.76,15.92 99.51,16.85 99.01,17.67C98.5,18.48 97.8,19.11 96.88,19.56C95.97,20.01 94.9,20.24 93.67,20.24ZM107.52,20.24C105.63,20.24 104.15,19.79 103.09,18.9C102.04,18.01 101.52,16.76 101.52,15.15H104.46C104.48,15.96 104.76,16.6 105.31,17.05C105.85,17.5 106.59,17.73 107.54,17.73C108.39,17.73 109.07,17.56 109.58,17.21C110.1,16.87 110.36,16.41 110.36,15.83C110.36,15.36 110.15,14.97 109.73,14.67C109.32,14.37 108.65,14.12 107.71,13.92L106.15,13.58C103.25,12.95 101.8,11.58 101.8,9.47C101.8,8.57 102.04,7.79 102.51,7.12C102.98,6.45 103.63,5.92 104.47,5.54C105.31,5.17 106.3,4.98 107.42,4.98C109.12,4.98 110.47,5.4 111.46,6.25C112.46,7.09 112.98,8.26 113.03,9.75H110.17C110.12,9.05 109.85,8.5 109.36,8.09C108.87,7.69 108.22,7.49 107.43,7.49C106.68,7.49 106.06,7.66 105.57,8C105.1,8.35 104.86,8.79 104.86,9.32C104.86,9.78 105.06,10.15 105.46,10.42C105.85,10.7 106.5,10.94 107.41,11.14L108.83,11.45C110.4,11.78 111.56,12.29 112.28,12.97C113.02,13.63 113.38,14.52 113.38,15.63C113.38,17.06 112.86,18.18 111.82,19.01C110.77,19.83 109.34,20.24 107.52,20.24ZM115.18,20V5.22H125.79V7.84H118.24V11.26H125.22V13.81H118.24V17.38H125.79V20H115.18ZM127.54,20V5.22H133.46C134.57,5.22 135.53,5.41 136.33,5.81C137.15,6.2 137.77,6.76 138.21,7.49C138.65,8.22 138.88,9.08 138.88,10.07C138.88,11.05 138.64,11.89 138.17,12.61C137.71,13.32 137.06,13.87 136.24,14.25L139.2,20H135.93L133.31,14.83C133.3,14.83 133.29,14.83 133.28,14.83H130.6V20H127.54ZM130.6,12.38H133.28C134.09,12.38 134.72,12.17 135.18,11.75C135.65,11.33 135.89,10.77 135.89,10.07C135.89,9.36 135.65,8.8 135.18,8.38C134.71,7.96 134.07,7.76 133.27,7.76H130.6V12.38ZM151.68,20.24C150.32,20.24 149.11,19.93 148.05,19.32C146.99,18.69 146.16,17.81 145.55,16.68C144.95,15.53 144.65,14.18 144.65,12.62C144.65,11.04 144.95,9.69 145.55,8.55C146.16,7.41 146.99,6.53 148.05,5.91C149.11,5.29 150.32,4.98 151.68,4.98C152.83,4.98 153.87,5.2 154.79,5.65C155.72,6.1 156.47,6.74 157.06,7.56C157.65,8.37 158.02,9.33 158.18,10.42H155.1C154.92,9.55 154.53,8.88 153.93,8.4C153.33,7.92 152.59,7.68 151.71,7.68C150.46,7.68 149.48,8.12 148.78,9.02C148.08,9.9 147.73,11.1 147.73,12.62C147.73,14.12 148.08,15.32 148.78,16.21C149.48,17.1 150.46,17.54 151.71,17.54C152.59,17.54 153.32,17.3 153.92,16.82C154.52,16.35 154.92,15.68 155.11,14.81H158.18C158.02,15.91 157.65,16.86 157.06,17.68C156.47,18.49 155.72,19.12 154.79,19.57C153.87,20.02 152.83,20.24 151.68,20.24ZM158.36,20L163.55,5.22H167.48L172.67,20H169.29L168.26,16.78H162.73L161.66,20H158.36ZM163.55,14.33H167.46L166.96,12.81C166.73,12.05 166.5,11.28 166.27,10.5C166.04,9.72 165.79,8.87 165.53,7.95C165.26,8.87 165.01,9.72 164.77,10.5C164.53,11.28 164.3,12.05 164.06,12.81L163.55,14.33ZM173.88,20V5.22H179.79C180.91,5.22 181.87,5.41 182.67,5.81C183.49,6.2 184.11,6.76 184.55,7.49C184.99,8.22 185.21,9.08 185.21,10.07C185.21,11.05 184.98,11.89 184.51,12.61C184.04,13.32 183.4,13.87 182.57,14.25L185.54,20H182.27L179.65,14.83C179.64,14.83 179.63,14.83 179.62,14.83H176.94V20H173.88ZM176.94,12.38H179.62C180.42,12.38 181.06,12.17 181.52,11.75C181.99,11.33 182.23,10.77 182.23,10.07C182.23,9.36 181.99,8.8 181.52,8.38C181.05,7.96 180.41,7.76 179.61,7.76H176.94V12.38ZM192.32,20H187.1V5.22H192.38C193.86,5.22 195.14,5.52 196.23,6.12C197.31,6.72 198.15,7.57 198.74,8.68C199.33,9.78 199.62,11.09 199.62,12.6C199.62,14.11 199.33,15.43 198.74,16.54C198.15,17.64 197.31,18.5 196.22,19.1C195.13,19.7 193.83,20 192.32,20ZM190.15,17.43H192.16C193.65,17.43 194.76,17.01 195.47,16.18C196.19,15.35 196.54,14.15 196.54,12.6C196.54,11.04 196.18,9.85 195.46,9.03C194.75,8.2 193.66,7.79 192.19,7.79H190.15V17.43Z"
android:fillColor="#FFFFFF"
tools:ignore="VectorPath" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

View File

@ -147,7 +147,14 @@
<string name="overview_screen_changelog_description">Revolt never sleeps, take a look to see what\'s been in the works</string>
<string name="overview_screen_feedback">Provide Feedback</string>
<string name="overview_screen_feedback_description">Got something to say? We\'re all ears</string>
<!--Help shape the future of Revolt for Android by providing feedback-->
<string name="user_card_tap_to_copy">Tap to copy</string>
<string name="user_card_error_sharing">An error occurred while sharing your user card.</string>
<string name="user_card_data_point_display_name">%1$d.Name</string>
<string name="user_card_data_point_username">%1$d.Username</string>
<string name="user_card_data_point_join_date">%1$d.On Revolt Since</string>
<string name="user_card_data_point_qrcode">%1$d.QR Code</string>
<string name="user_card_data_point_photo">%1$d.Photo</string>
<string name="friends">Friends</string>
<string name="friends_incoming_requests">Incoming Requests</string>

View File

@ -2,4 +2,7 @@
<cache-path
path="attachments/"
name="attachments" />
<cache-path
path="usercards/"
name="usercards" />
</paths>