____ _ _______ __
/ __ \ | /| / / __/ |/ /
/ /_/ / |/ |/ / _// /
\____/|__/|__/___/_||_/
owenclave / app/src/main/java/io/nekohasekai/sagernet/ui/compose/components/ExpressiveComponents.kt
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)
package io.nekohasekai.sagernet.ui.compose.components
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.FlightTakeoff
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.VpnKey
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.Morph
import androidx.graphics.shapes.RoundedPolygon
import androidx.graphics.shapes.toPath
/**
* A Compose [Shape] driven by an [androidx.graphics.shapes.Morph] progress value.
* This is the signature M3E "shape morphs when pressed" effect.
*/
class MorphPolygonShape(
private val morph: Morph,
private val percentage: Float,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
val matrix = android.graphics.Matrix()
matrix.setScale(size.width, size.height)
val androidPath = android.graphics.Path()
androidPath.set(morph.toPath(percentage))
androidPath.transform(matrix)
return Outline.Generic(androidPath.asComposePath())
}
}
/**
* A colored icon sitting inside a distinctive M3E [MaterialShapes] container
* (Cookie, Clover, Sunny, Gem, Flower, ...). Morphs to a different shape while pressed.
*/
@Composable
fun ShapedIcon(
icon: ImageVector,
containerColor: Color,
contentColor: Color,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
shape: RoundedPolygon = MaterialShapes.Cookie7Sided,
pressedShape: RoundedPolygon = MaterialShapes.Cookie12Sided,
pressed: Boolean = false,
) {
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(14.dp))
.background(containerColor),
contentAlignment = Alignment.Center,
) {
androidx.compose.material3.Icon(
imageVector = icon,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(size * 0.5f),
)
}
}
/**
* Static shaped icon (no press morph) — cheaper for lists.
*/
@Composable
fun ShapedIconStatic(
icon: ImageVector,
containerColor: Color,
contentColor: Color,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
shape: RoundedPolygon = MaterialShapes.Cookie7Sided,
) {
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(14.dp))
.background(containerColor),
contentAlignment = Alignment.Center,
) {
androidx.compose.material3.Icon(
imageVector = icon,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(size * 0.5f),
)
}
}
/**
* Per-position corner shape so a vertical stack of items reads as ONE rounded
* container: big outer corners on the first/last item, tight inner corners in
* between. Combine with a 2dp gap between items.
*/
fun groupedItemShape(index: Int, count: Int, outer: Dp = 22.dp, inner: Dp = 5.dp): RoundedCornerShape = when {
count <= 1 -> RoundedCornerShape(outer)
index == 0 -> RoundedCornerShape(topStart = outer, topEnd = outer, bottomStart = inner, bottomEnd = inner)
index == count - 1 -> RoundedCornerShape(topStart = inner, topEnd = inner, bottomStart = outer, bottomEnd = outer)
else -> RoundedCornerShape(inner)
}
/**
* A transparent clipped column that groups expressive items together.
*/
@Composable
fun ExpressiveGroup(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.fillMaxWidth(),
content = content,
)
}
/**
* A deterministic playful [MaterialShapes] polygon derived from a string seed,
* so each profile/group gets its own recognizable shape.
*/
val ExpressiveShapePalette: List = listOf(
MaterialShapes.Cookie7Sided,
MaterialShapes.Cookie9Sided,
MaterialShapes.Clover4Leaf,
MaterialShapes.Sunny,
MaterialShapes.Gem,
MaterialShapes.Flower,
MaterialShapes.Cookie12Sided,
MaterialShapes.PuffyDiamond,
MaterialShapes.Pentagon,
MaterialShapes.SoftBurst,
MaterialShapes.Cookie6Sided,
MaterialShapes.VerySunny,
)
fun shapeForSeed(seed: String): RoundedPolygon {
val idx = (seed.hashCode() % ExpressiveShapePalette.size + ExpressiveShapePalette.size) % ExpressiveShapePalette.size
return ExpressiveShapePalette[idx]
}
/**
* A curated set of Material icons suitable for proxy/server profiles.
* Indexed by [ProxyEntity.iconIndex]; -1 means "auto" (derived from name hash).
*/
val ProfileIconSet: List = listOf(
Icons.Filled.Bolt,
Icons.Filled.Cloud,
Icons.Filled.Public,
Icons.Filled.Shield,
Icons.Filled.Lock,
Icons.Filled.VpnKey,
Icons.Filled.Wifi,
Icons.Filled.FlightTakeoff,
Icons.Filled.Speed,
Icons.Filled.Hub,
Icons.Filled.Lan,
Icons.Filled.Security,
)
/** Returns the icon for a profile, using its stored index or a name-derived fallback. */
fun profileIconFor(iconIndex: Int, name: String): ImageVector {
return if (iconIndex in ProfileIconSet.indices) {
ProfileIconSet[iconIndex]
} else {
ProfileIconSet[(name.hashCode() % ProfileIconSet.size + ProfileIconSet.size) % ProfileIconSet.size]
}
}
/**
* An M3E-flavoured text field: a soft, fully-rounded tonal field with no harsh
* outline box. Replaces the default boxy [androidx.compose.material3.OutlinedTextField]
* look used across the app's dialogs and forms.
*/
@Composable
fun ExpressiveTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
placeholder: String? = null,
singleLine: Boolean = true,
leadingIcon: ImageVector? = null,
keyboardOptions: androidx.compose.foundation.text.KeyboardOptions = androidx.compose.foundation.text.KeyboardOptions.Default,
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
textStyle: androidx.compose.ui.text.TextStyle? = null,
) {
androidx.compose.material3.OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
label = label?.let { { androidx.compose.material3.Text(it) } },
placeholder = placeholder?.let { { androidx.compose.material3.Text(it) } },
leadingIcon = leadingIcon?.let {
{
androidx.compose.material3.Icon(it, contentDescription = null)
}
},
singleLine = singleLine,
shape = RoundedCornerShape(20.dp),
keyboardOptions = keyboardOptions,
visualTransformation = visualTransformation,
textStyle = textStyle ?: androidx.compose.material3.MaterialTheme.typography.bodyLarge,
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
// A subtle always-on outline so the field stays visible even when
// it sits on a same-tone dialog/popup surface.
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
disabledBorderColor = MaterialTheme.colorScheme.outlineVariant,
),
)
}