feat(PR): spotlight creator info by RSR
Spotlight Creator Info
This commit is contained in:
@@ -957,6 +957,10 @@
|
||||
"name": "أيقونة اسم مستخدم تعليقات منصة الأضواء",
|
||||
"description": "اختر الأيقونة التي يتم عرضها بجانب أسماء المستخدمين في تعليقات منصة الأضواء"
|
||||
},
|
||||
"spotlight_creator_info": {
|
||||
"name": "معلومات منشئ منصة الأضواء",
|
||||
"description": "عرض زر معلومات على سنابات منصة الأضواء/اكتشف لرؤية اسم العرض واسم المستخدم ومعرف المستخدم للمنشئ"
|
||||
},
|
||||
"bypass_video_length_restriction": {
|
||||
"name": "تجاوز قيود طول الفيديو",
|
||||
"description": "مفرد: يرسل فيديو واحد\nمقسم: يقسم الفيديو بعد التحرير"
|
||||
@@ -3128,6 +3132,25 @@
|
||||
"select_date_first": "يرجى تحديد تاريخ أولاً",
|
||||
"invalid_time": "يرجى تحديد وقت مستقبلي"
|
||||
},
|
||||
"spotlight_creator_info": {
|
||||
"title": "معلومات المنشئ",
|
||||
"close": "إغلاق",
|
||||
"creator_info": "معلومات المنشئ",
|
||||
"display_name": "اسم العرض",
|
||||
"username": "اسم المستخدم",
|
||||
"user_id": "معرف المستخدم",
|
||||
"posted_on": "نُشر في",
|
||||
"loading_username": "جاري التحميل…",
|
||||
"username_copied": "تم نسخ اسم المستخدم",
|
||||
"user_id_copied": "تم نسخ معرف المستخدم",
|
||||
"friend_status": "حالة الصداقة",
|
||||
"mutual_friend": "صديق مشترك",
|
||||
"following": "متابَع",
|
||||
"friend_request_sent": "تم إرسال الطلب",
|
||||
"friend_request_received": "تم استلام الطلب",
|
||||
"blocked": "محظور",
|
||||
"friend_removed": "تمت إزالته"
|
||||
},
|
||||
"auto_reply_messages": {
|
||||
"dialog": {
|
||||
"add_message": "إضافة رسالة",
|
||||
|
||||
@@ -1796,6 +1796,10 @@
|
||||
"name": "Spotlight Comments Username Icon",
|
||||
"description": "Choose which icon is displayed next to usernames in Spotlight comments"
|
||||
},
|
||||
"spotlight_creator_info": {
|
||||
"name": "Spotlight Creator Info",
|
||||
"description": "Show an info button on Spotlight/Discover snaps to view creator display name, username, and user ID"
|
||||
},
|
||||
"bypass_video_length_restriction": {
|
||||
"name": "Bypass Video Length Restrictions",
|
||||
"description": "Single: sends a single video\nSplit: split videos after editing"
|
||||
@@ -3223,6 +3227,25 @@
|
||||
"select_date_first": "Please select a date",
|
||||
"invalid_time": "Please select a future time"
|
||||
},
|
||||
"spotlight_creator_info": {
|
||||
"title": "Creator Info",
|
||||
"close": "Close",
|
||||
"creator_info": "Creator Info",
|
||||
"display_name": "Display name",
|
||||
"username": "Username",
|
||||
"user_id": "User ID",
|
||||
"posted_on": "Posted",
|
||||
"loading_username": "Loading…",
|
||||
"username_copied": "Username copied",
|
||||
"user_id_copied": "User ID copied",
|
||||
"friend_status": "Friend status",
|
||||
"mutual_friend": "Mutual friend",
|
||||
"following": "Following",
|
||||
"friend_request_sent": "Request sent",
|
||||
"friend_request_received": "Request received",
|
||||
"blocked": "Blocked",
|
||||
"friend_removed": "Removed"
|
||||
},
|
||||
"auto_reply_messages": {
|
||||
"dialog": {
|
||||
"add_message": "Add Message",
|
||||
|
||||
@@ -50,6 +50,7 @@ class Global : ConfigContainer() {
|
||||
val disableMemoriesSnapFeed = boolean("disable_memories_snap_feed")
|
||||
val spotlightCommentsUsername = boolean("spotlight_comments_username") { requireRestart() }
|
||||
val spotlightCommentsUsernameIcon = string("spotlight_comments_username_icon", "[👤]") { requireRestart() }
|
||||
val spotlightCreatorInfo = boolean("spotlight_creator_info") { requireRestart() }
|
||||
val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices(
|
||||
FeatureNotice.BAN_RISK); requireRestart() }
|
||||
val defaultVideoPlaybackRate = float("default_video_playback_rate", 1.0F) { requireRestart(); inputCheck = { (it.toFloatOrNull() ?: 1.0F) in 0.1F..4.0F} }
|
||||
|
||||
@@ -123,6 +123,7 @@ class FeatureManager(
|
||||
PreventForcedLogout(),
|
||||
ConversationToolbox(),
|
||||
SpotlightCommentsUsername(),
|
||||
SpotlightCreatorInfo(),
|
||||
OperaViewerParamsOverride(),
|
||||
StealthModeIndicator(),
|
||||
DisablePermissionRequests(),
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.data.FriendLinkType
|
||||
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
|
||||
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.Snapchatter
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap
|
||||
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class SpotlightCreatorInfo : Feature("SpotlightCreatorInfo") {
|
||||
private data class CreatorInfo(
|
||||
val snapId: String,
|
||||
val creatorDisplayName: String,
|
||||
val creatorUserId: String?,
|
||||
val timestamp: Long?,
|
||||
val friendLinkType: FriendLinkType? = null,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var currentCreatorInfo: CreatorInfo? = null
|
||||
|
||||
@Volatile
|
||||
private var isInSpotlight: Boolean = false
|
||||
|
||||
@Volatile
|
||||
private var currentSnapId: String? = null
|
||||
|
||||
@Volatile
|
||||
private var lastSpotlightUpdateTime: Long = 0
|
||||
|
||||
private val userIdCache = mutableMapOf<String, String>()
|
||||
|
||||
override fun init() {
|
||||
if (!context.config.global.spotlightCreatorInfo.get()) return
|
||||
|
||||
onNextActivityCreate {
|
||||
context.event.subscribe(BindViewEvent::class) { event ->
|
||||
val modelStr = event.prevModel.toString()
|
||||
if (!modelStr.contains("Spotlight") && !modelStr.contains("SINGLE_SNAP_STORY")) return@subscribe
|
||||
|
||||
val userId = modelStr.takeIf { it.contains("userId=") }
|
||||
?.substringAfter("userId=")
|
||||
?.substringBefore(",")
|
||||
?.substringBefore(")")
|
||||
?.takeIf { it.isNotBlank() && it != "null" }
|
||||
|
||||
val snapId = modelStr.takeIf { it.contains("snapId=") || modelStr.contains("SNAP_ID") }
|
||||
?.substringAfter("snapId=")
|
||||
?.substringBefore(",")
|
||||
?.substringBefore(")")
|
||||
?: modelStr.takeIf { it.contains("SNAP_ID") }
|
||||
?.substringAfter("SNAP_ID=")
|
||||
?.substringBefore(",")
|
||||
?.substringBefore(")")
|
||||
|
||||
if (userId != null && snapId != null) {
|
||||
userIdCache[snapId] = userId
|
||||
}
|
||||
}
|
||||
|
||||
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
|
||||
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
|
||||
classReference.get()?.hook(
|
||||
methodName.get() ?: return@forEach,
|
||||
HookStage.AFTER
|
||||
) onCreatorInfoHook@{ param ->
|
||||
val viewState = (param.thisObject<Any>()).getObjectField(viewStateField.get()!!).toString()
|
||||
val operaLayerList = (param.thisObject<Any>()).getObjectField(layerListField.get()!!) as ArrayList<*>
|
||||
|
||||
if (operaLayerList.isEmpty()) {
|
||||
if (isInSpotlight) {
|
||||
isInSpotlight = false
|
||||
currentCreatorInfo = null
|
||||
currentSnapId = null
|
||||
lastSpotlightUpdateTime = 0
|
||||
}
|
||||
return@onCreatorInfoHook
|
||||
}
|
||||
|
||||
val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap
|
||||
val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString()
|
||||
|
||||
if (snapSource != "SINGLE_SNAP_STORY") {
|
||||
if (isInSpotlight) {
|
||||
isInSpotlight = false
|
||||
currentCreatorInfo = null
|
||||
currentSnapId = null
|
||||
lastSpotlightUpdateTime = 0
|
||||
}
|
||||
return@onCreatorInfoHook
|
||||
}
|
||||
|
||||
if (viewState != "FULLY_DISPLAYED") return@onCreatorInfoHook
|
||||
|
||||
val snapId = mediaParamMap["SNAP_ID"]?.toString()
|
||||
if (snapId == null || snapId == currentSnapId) return@onCreatorInfoHook
|
||||
|
||||
val creatorDisplayName = mediaParamMap["CREATOR_DISPLAY_NAME"]?.toString()
|
||||
var creatorUserId = mediaParamMap["USER_ID"]?.toString()
|
||||
?: mediaParamMap["CREATOR_USER_ID"]?.toString()
|
||||
?: mediaParamMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString()
|
||||
|
||||
val playableStorySnapRecord = mediaParamMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()
|
||||
if (creatorUserId == null && playableStorySnapRecord != null) {
|
||||
val recordStr = playableStorySnapRecord.toString()
|
||||
creatorUserId = recordStr.substringAfter("userId=", "")
|
||||
.substringBefore(",")
|
||||
.substringBefore(")")
|
||||
.takeIf { it.isNotBlank() && it != "null" }
|
||||
}
|
||||
if (creatorUserId == null) creatorUserId = userIdCache[snapId]
|
||||
|
||||
val creationTimestamp = playableStorySnapRecord?.substringAfter("creationTimestamp=")
|
||||
?.substringBefore(",")?.toLongOrNull()
|
||||
val timestamp = creationTimestamp ?: mediaParamMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull()
|
||||
|
||||
if (creatorDisplayName != null || creatorUserId != null) {
|
||||
val friendInfo: FriendInfo? = creatorUserId?.let { context.database.getFriendInfo(it) }
|
||||
val linkType = friendInfo?.let { FriendLinkType.fromValue(it.friendLinkType) }
|
||||
|
||||
currentSnapId = snapId
|
||||
isInSpotlight = true
|
||||
lastSpotlightUpdateTime = System.currentTimeMillis()
|
||||
currentCreatorInfo = CreatorInfo(
|
||||
snapId = snapId,
|
||||
creatorDisplayName = creatorDisplayName ?: context.translation.getOrNull("common.unknown") ?: "Unknown",
|
||||
creatorUserId = creatorUserId,
|
||||
timestamp = timestamp,
|
||||
friendLinkType = linkType
|
||||
)
|
||||
} else {
|
||||
if (isInSpotlight) {
|
||||
isInSpotlight = false
|
||||
currentCreatorInfo = null
|
||||
currentSnapId = null
|
||||
lastSpotlightUpdateTime = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.inAppOverlay.addCustomComposable {
|
||||
var info by remember { mutableStateOf<CreatorInfo?>(null) }
|
||||
var inSpotlight by remember { mutableStateOf(false) }
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
val wasInSpotlight = inSpotlight
|
||||
info = currentCreatorInfo
|
||||
inSpotlight = isInSpotlight
|
||||
if (wasInSpotlight && !inSpotlight) showDialog = false
|
||||
if (inSpotlight && info != null && lastSpotlightUpdateTime > 0 && !showDialog) {
|
||||
if (System.currentTimeMillis() - lastSpotlightUpdateTime > 4000) {
|
||||
inSpotlight = false
|
||||
info = null
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
kotlinx.coroutines.delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
if (inSpotlight && info != null) {
|
||||
InfoIconOverlay(
|
||||
onClick = {
|
||||
showDialog = true
|
||||
lastSpotlightUpdateTime = System.currentTimeMillis()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDialog && info != null) {
|
||||
CreatorInfoDialog(
|
||||
creatorInfo = info!!,
|
||||
onDismiss = { showDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoIconOverlay(onClick: () -> Unit) {
|
||||
val translation = remember { context.translation.getCategory("spotlight_creator_info") }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 16.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.6f)),
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(
|
||||
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectOverlayPalette.cardOverlayColor
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = translation.getOrNull("creator_info") ?: "Creator Info",
|
||||
tint = PurrfectOverlayPalette.textPrimary,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreatorInfoDialog(creatorInfo: CreatorInfo, onDismiss: () -> Unit) {
|
||||
var snapchatterInfo by remember { mutableStateOf<Snapchatter?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val translation = remember { context.translation.getCategory("spotlight_creator_info") }
|
||||
|
||||
LaunchedEffect(creatorInfo.snapId) {
|
||||
snapchatterInfo = null
|
||||
isLoading = false
|
||||
if (creatorInfo.creatorUserId != null) {
|
||||
isLoading = true
|
||||
withContext(Dispatchers.IO) {
|
||||
val info = runCatching {
|
||||
context.feature(Messaging::class).fetchSnapchatterInfos(listOf(creatorInfo.creatorUserId!!)).firstOrNull()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to fetch creator info for ${creatorInfo.creatorUserId}", it)
|
||||
}.getOrNull()
|
||||
withContext(Dispatchers.Main) {
|
||||
snapchatterInfo = info
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
PurrfectOverlayTheme {
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = shape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
),
|
||||
shadowElevation = 0.dp,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(PurrfectOverlayPalette.cardOverlay, shape)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = translation.getOrNull("title") ?: "Creator Info",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = PurrfectOverlayPalette.textPrimary
|
||||
)
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = translation.getOrNull("close") ?: "Close",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = PurrfectOverlayPalette.textSecondary.copy(alpha = 0.2f))
|
||||
|
||||
creatorInfo.timestamp?.let { ts ->
|
||||
InfoRow(
|
||||
icon = Icons.Default.Schedule,
|
||||
label = translation.getOrNull("posted_on") ?: "Posted",
|
||||
value = formatDate(ts)
|
||||
)
|
||||
}
|
||||
|
||||
val displayName = snapchatterInfo?.displayName ?: creatorInfo.creatorDisplayName
|
||||
InfoRow(
|
||||
icon = Icons.Default.Person,
|
||||
label = translation.getOrNull("display_name") ?: "Display name",
|
||||
value = displayName
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = PurrfectOverlayPalette.glowPrimary
|
||||
)
|
||||
Text(
|
||||
translation.getOrNull("loading_username") ?: "Loading…",
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val username = snapchatterInfo?.username
|
||||
InfoRowWithCopy(
|
||||
icon = Icons.Default.AccountCircle,
|
||||
label = translation.getOrNull("username") ?: "Username",
|
||||
value = username ?: (context.translation.getOrNull("common.unknown") ?: "—"),
|
||||
canCopy = username != null,
|
||||
onCopy = {
|
||||
username?.let {
|
||||
context.androidContext.copyToClipboard(it, translation.getOrNull("username") ?: "Username")
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Check, translation.getOrNull("username_copied") ?: "Copied")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
InfoRowWithCopy(
|
||||
icon = Icons.Default.Fingerprint,
|
||||
label = translation.getOrNull("user_id") ?: "User ID",
|
||||
value = creatorInfo.creatorUserId ?: (context.translation.getOrNull("common.unknown") ?: "—"),
|
||||
canCopy = creatorInfo.creatorUserId != null,
|
||||
onCopy = {
|
||||
creatorInfo.creatorUserId?.let {
|
||||
context.androidContext.copyToClipboard(it, translation.getOrNull("user_id") ?: "User ID")
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Check, translation.getOrNull("user_id_copied") ?: "Copied")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
creatorInfo.friendLinkType?.let { linkType ->
|
||||
val statusText = when (linkType) {
|
||||
FriendLinkType.MUTUAL -> translation.getOrNull("mutual_friend") ?: "Mutual friend"
|
||||
FriendLinkType.FOLLOWING -> translation.getOrNull("following") ?: "Following"
|
||||
FriendLinkType.OUTGOING -> translation.getOrNull("friend_request_sent") ?: "Request sent"
|
||||
FriendLinkType.INCOMING -> translation.getOrNull("friend_request_received") ?: "Request received"
|
||||
FriendLinkType.BLOCKED -> translation.getOrNull("blocked") ?: "Blocked"
|
||||
FriendLinkType.DELETED -> translation.getOrNull("friend_removed") ?: "Removed"
|
||||
else -> null
|
||||
}
|
||||
if (statusText != null) {
|
||||
InfoRow(
|
||||
icon = Icons.Default.Person,
|
||||
label = translation.getOrNull("friend_status") ?: "Friend status",
|
||||
value = statusText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
value: String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.9f)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 11.sp,
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = PurrfectOverlayPalette.textPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRowWithCopy(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
canCopy: Boolean,
|
||||
onCopy: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.9f)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 11.sp,
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = PurrfectOverlayPalette.textPrimary
|
||||
)
|
||||
}
|
||||
if (canCopy) {
|
||||
IconButton(onClick = onCopy, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = PurrfectOverlayPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val ts = if (timestamp < 10000000000L) timestamp * 1000 else timestamp
|
||||
return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(ts))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user