diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d856fcc5..6a67e057 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index 7d3f5ff4..207e875e 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -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") } diff --git a/app/src/main/java/chat/revolt/api/internals/ResourceLocations.kt b/app/src/main/java/chat/revolt/api/internals/ResourceLocations.kt new file mode 100644 index 00000000..e2ab87b9 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/internals/ResourceLocations.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/internals/UserQR.kt b/app/src/main/java/chat/revolt/api/internals/UserQR.kt new file mode 100644 index 00000000..312dcb00 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/internals/UserQR.kt @@ -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 + ) + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt b/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt index 6347e022..d79716bc 100644 --- a/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt +++ b/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt @@ -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) { diff --git a/app/src/main/java/chat/revolt/components/profile/UserCard.kt b/app/src/main/java/chat/revolt/components/profile/UserCard.kt new file mode 100644 index 00000000..795fd36b --- /dev/null +++ b/app/src/main/java/chat/revolt/components/profile/UserCard.kt @@ -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(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(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) + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/OverviewScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/OverviewScreen.kt index 1defcf48..f2892f58 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/OverviewScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/OverviewScreen.kt @@ -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, diff --git a/app/src/main/java/chat/revolt/sheets/UserCardSheet.kt b/app/src/main/java/chat/revolt/sheets/UserCardSheet.kt new file mode 100644 index 00000000..13f234c8 --- /dev/null +++ b/app/src/main/java/chat/revolt/sheets/UserCardSheet.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/sheets/UserInfoSheet.kt b/app/src/main/java/chat/revolt/sheets/UserInfoSheet.kt index 7badf739..54c22ec8 100644 --- a/app/src/main/java/chat/revolt/sheets/UserInfoSheet.kt +++ b/app/src/main/java/chat/revolt/sheets/UserInfoSheet.kt @@ -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 { diff --git a/app/src/main/res/drawable/ic_badge_account_horizontal_24dp.xml b/app/src/main/res/drawable/ic_badge_account_horizontal_24dp.xml new file mode 100644 index 00000000..9216fce4 --- /dev/null +++ b/app/src/main/res/drawable/ic_badge_account_horizontal_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/usercard_heading.xml b/app/src/main/res/drawable/usercard_heading.xml new file mode 100644 index 00000000..20dd1723 --- /dev/null +++ b/app/src/main/res/drawable/usercard_heading.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/usercard_qr_placeholder.png b/app/src/main/res/drawable/usercard_qr_placeholder.png new file mode 100644 index 00000000..e2a799d4 Binary files /dev/null and b/app/src/main/res/drawable/usercard_qr_placeholder.png differ diff --git a/app/src/main/res/font/revolt_usercard.ttf b/app/src/main/res/font/revolt_usercard.ttf new file mode 100644 index 00000000..981814b8 Binary files /dev/null and b/app/src/main/res/font/revolt_usercard.ttf differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f594f4bb..d506ebc0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -147,7 +147,14 @@ Revolt never sleeps, take a look to see what\'s been in the works Provide Feedback Got something to say? We\'re all ears - + + Tap to copy + An error occurred while sharing your user card. + %1$d.Name + %1$d.Username + %1$d.On Revolt Since + %1$d.QR Code + %1$d.Photo Friends Incoming Requests diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index bae99c16..82db3849 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -2,4 +2,7 @@ + \ No newline at end of file