owenclave / app/src/main/java/io/nekohasekai/sagernet/ui/compose/components/ProfileCard.kt

github - dev - back to tree - upstream
package io.nekohasekai.sagernet.ui.compose.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState 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 import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.NetworkCheck import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.nekohasekai.sagernet.database.ProxyEntity @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ProfileCard( entity: ProxyEntity, selected: Boolean, onClick: () -> Unit, onEdit: () -> Unit, onShare: (() -> Unit)? = null, onDelete: () -> Unit, onPing: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, pinging: Boolean = false, connected: Boolean = false, modifier: Modifier = Modifier, ) { val containerColor by animateColorAsState( targetValue = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, label = "cardColor" ) val onContainerColor by animateColorAsState( targetValue = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, label = "cardContentColor" ) val interactionSource = remember { MutableInteractionSource() } val pressed by interactionSource.collectIsPressedAsState() val cardScale by animateFloatAsState( targetValue = if (pressed) 0.97f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), label = "cardScale", ) var showMenu by remember { mutableStateOf(false) } // Distinctive per-profile MaterialShape derived from the profile name. val seedShape = remember(entity.displayName()) { shapeForSeed(entity.displayName()) } val iconContainer by animateColorAsState( targetValue = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer, label = "iconContainer", ) val iconContent by animateColorAsState( targetValue = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer, label = "iconContent", ) Card( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 3.dp) .graphicsLayer { scaleX = cardScale scaleY = cardScale }, shape = MaterialTheme.shapes.extraLarge, colors = CardDefaults.cardColors(containerColor = containerColor), elevation = CardDefaults.cardElevation(defaultElevation = if (selected) 3.dp else 0.dp), interactionSource = interactionSource, onClick = onClick, ) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { // Icon spins while connected, decelerates to rest when disconnected. val rotation = remember { Animatable(0f) } LaunchedEffect(connected) { if (connected) { rotation.animateTo( targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(6000, easing = LinearEasing), repeatMode = RepeatMode.Restart, ), ) } else { val current = rotation.value val target = if (current > 180f) 360f else 0f rotation.animateTo( targetValue = target, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, ), ) } } ShapedIcon( icon = profileIconFor(entity.iconIndex, entity.displayName()), containerColor = iconContainer, contentColor = iconContent, size = 48.dp, shape = seedShape, pressed = pressed, modifier = Modifier.graphicsLayer { rotationZ = rotation.value }, ) Spacer(Modifier.width(14.dp)) Column( modifier = Modifier.weight(1f), ) { Text( text = entity.displayName(), style = MaterialTheme.typography.titleMedium, color = onContainerColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Spacer(Modifier.height(2.dp)) Text( text = entity.displayAddress(), style = MaterialTheme.typography.bodySmall, color = onContainerColor.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) Spacer(Modifier.height(4.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = entity.displayType(), style = MaterialTheme.typography.labelSmall, color = onContainerColor.copy(alpha = 0.6f), ) if (entity.tx > 0 || entity.rx > 0) { Text( text = formatTraffic(entity.tx, entity.rx), style = MaterialTheme.typography.labelSmall, color = onContainerColor.copy(alpha = 0.6f), ) } if (pinging) { CircularWavyProgressIndicator( modifier = Modifier.size(12.dp), ) } else if (entity.ping > 0) { Text( text = "${entity.ping}ms", style = MaterialTheme.typography.labelSmall, color = when (entity.status) { 1 -> Color(0xFF4CAF50) 3 -> Color(0xFFF44336) else -> onContainerColor.copy(alpha = 0.6f) }, ) } else if (entity.ping == -1) { Text( text = "failed", style = MaterialTheme.typography.labelSmall, color = Color(0xFFF44336), ) } } } Spacer(Modifier.width(4.dp)) Box { IconButton( onClick = { showMenu = true }, modifier = Modifier.size(40.dp), colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceBright, ), ) { Icon( Icons.Default.MoreVert, contentDescription = "Menu", tint = onContainerColor.copy(alpha = 0.7f), modifier = Modifier.size(20.dp), ) } DropdownMenu( expanded = showMenu, onDismissRequest = { showMenu = false }, shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp), containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { DropdownMenuItem( text = { Text("Edit") }, onClick = { showMenu = false; onEdit() }, leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp)) }, ) if (onShare != null) { DropdownMenuItem( text = { Text("Share") }, onClick = { showMenu = false; onShare() }, leadingIcon = { Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(20.dp)) }, ) } if (onPing != null) { DropdownMenuItem( text = { Text("Test Latency") }, onClick = { showMenu = false; onPing() }, leadingIcon = { Icon(Icons.Default.NetworkCheck, contentDescription = null, modifier = Modifier.size(20.dp)) }, ) } DropdownMenuItem( text = { Text("Delete") }, onClick = { showMenu = false; onDelete() }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(20.dp)) }, ) } } } } } private fun formatTraffic(tx: Long, rx: Long): String { val total = tx + rx return when { total < 1024 -> "${total}B" total < 1024 * 1024 -> "%.1fKB".format(total / 1024.0) total < 1024 * 1024 * 1024 -> "%.1fMB".format(total / (1024.0 * 1024)) else -> "%.2fGB".format(total / (1024.0 * 1024 * 1024)) } }