CellGuard app: on-device IMSI-catcher detection (Kotlin/Compose)
- foreground DetectorService: cgcap capture + on-device decode - framework CellInfo source: real serving+neighbor towers, signal, carrier name - FrameDecoder: 0x070F cell-info SIT decode - block-2G/3G + detection toggles via cgctl; libsu root bridge - silent status notification + edge-triggered alerts
This commit is contained in:
582
app/src/main/java/com/seteclabs/cellguard/ui/MainScreen.kt
Normal file
582
app/src/main/java/com/seteclabs/cellguard/ui/MainScreen.kt
Normal file
@@ -0,0 +1,582 @@
|
||||
package com.seteclabs.cellguard.ui
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.seteclabs.cellguard.CellGuardViewModel
|
||||
import com.seteclabs.cellguard.model.Alert
|
||||
import com.seteclabs.cellguard.model.CellGuardState
|
||||
import com.seteclabs.cellguard.model.Tower
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// ---- header status derivation ----------------------------------------------
|
||||
|
||||
private enum class HeaderState(val label: String, val color: Color) {
|
||||
ALERT("ALERT", CgRed),
|
||||
ARMED("ARMED", CgGreen),
|
||||
OFF("OFF", CgGray),
|
||||
}
|
||||
|
||||
private fun headerStateOf(s: CellGuardState): HeaderState = when {
|
||||
s.alerts.isNotEmpty() || s.suspect.isNotEmpty() -> HeaderState.ALERT
|
||||
s.blockEnabled || s.detectEnabled -> HeaderState.ARMED
|
||||
else -> HeaderState.OFF
|
||||
}
|
||||
|
||||
private fun ratColor(rat: String): Color = when (rat.uppercase()) {
|
||||
"LTE" -> CgGreen
|
||||
"NR", "5G" -> CgCyan
|
||||
"WCDMA", "TDSCDMA", "UMTS" -> CgAmber
|
||||
"GSM", "CDMA", "2G" -> CgRed
|
||||
else -> CgGray
|
||||
}
|
||||
|
||||
// Unknown/unavailable numeric fields come through as -1; show a dash instead.
|
||||
private fun dash(v: Long?): String = if (v != null && v >= 0) v.toString() else "—"
|
||||
private fun dash(v: Int?): String = if (v != null && v >= 0) v.toString() else "—"
|
||||
|
||||
/** Best label for a tower row: carrier name, else PLMN, else CI, else dash. */
|
||||
private fun towerLabel(t: Tower): String = t.op.ifBlank {
|
||||
(t.mcc + t.mnc).ifBlank { if (t.ci >= 0) t.ci.toString() else "—" }
|
||||
}
|
||||
|
||||
// ---- top-level screen -------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun MainScreen(vm: CellGuardViewModel) {
|
||||
val state by vm.state.collectAsState()
|
||||
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { inner ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(inner),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item { HeaderBar(state) }
|
||||
item {
|
||||
ToggleRow(
|
||||
title = "Block 2G/3G",
|
||||
subtitle = "Force LTE/5G, refuse legacy downgrade",
|
||||
checked = state.blockEnabled,
|
||||
onCheckedChange = vm::setBlock,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ToggleRow(
|
||||
title = "IMSI-catcher detection",
|
||||
subtitle = "Scan baseband frames for CSS signatures",
|
||||
checked = state.detectEnabled,
|
||||
onCheckedChange = vm::setDetect,
|
||||
)
|
||||
}
|
||||
item { ServingCard(state) }
|
||||
item { NeighborTable(state.neighbors) }
|
||||
item {
|
||||
SectionLabel("ALERTS", count = state.alerts.size)
|
||||
}
|
||||
if (state.alerts.isEmpty()) {
|
||||
item { EmptyHint("No alerts. Baseband looks clean.") }
|
||||
} else {
|
||||
items(state.alerts) { AlertRow(it) }
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- header -----------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun HeaderBar(state: CellGuardState) {
|
||||
val hs = headerStateOf(state)
|
||||
|
||||
// Pulse the dot only while alerting.
|
||||
val pulse = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseAlpha by pulse.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(650),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "pulseAlpha",
|
||||
)
|
||||
val dotAlpha = if (hs == HeaderState.ALERT) pulseAlpha else 1f
|
||||
val color by animateColorAsState(hs.color, label = "hdrColor")
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"CELLGUARD",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
Text(
|
||||
"baseband monitor",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(color.copy(alpha = 0.12f))
|
||||
.border(1.dp, color.copy(alpha = 0.5f), RoundedCornerShape(999.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(9.dp)
|
||||
.alpha(dotAlpha)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
hs.label,
|
||||
color = color,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- toggles ----------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
title,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
subtitle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
uncheckedThumbColor = CgOnSurfaceMuted,
|
||||
uncheckedTrackColor = CgSurfaceHi,
|
||||
uncheckedBorderColor = CgOutline,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- serving cell -----------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun ServingCard(state: CellGuardState) {
|
||||
val serving = state.serving
|
||||
val rat = serving?.rat?.takeIf { it.isNotBlank() } ?: state.rat.ifBlank { "—" }
|
||||
val operator = serving?.op?.takeIf { it.isNotBlank() }
|
||||
?: state.operator.ifBlank { "Unknown operator" }
|
||||
val plmn = state.plmn.ifBlank {
|
||||
serving?.let { "${it.mcc}${it.mnc}".takeIf { s -> s.isNotBlank() } } ?: "—"
|
||||
}
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RatBadge(rat)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
operator,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
"PLMN $plmn",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"SERVING",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Metric("CI", dash(serving?.ci), Modifier.weight(1f))
|
||||
Metric("PCI", dash(serving?.pci), Modifier.weight(1f))
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Metric("TAC", dash(serving?.tac), Modifier.weight(1f))
|
||||
Metric("EARFCN", dash(serving?.earfcn), Modifier.weight(1f))
|
||||
}
|
||||
if (serving?.rsrp != null || serving?.rsrq != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Metric(
|
||||
"RSRP",
|
||||
serving?.rsrp?.let { "$it dBm" } ?: "—",
|
||||
Modifier.weight(1f),
|
||||
)
|
||||
Metric(
|
||||
"RSRQ",
|
||||
serving?.rsrq?.let { "$it dB" } ?: "—",
|
||||
Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Metric(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier) {
|
||||
Text(
|
||||
label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
value,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RatBadge(rat: String) {
|
||||
val c = ratColor(rat)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(c.copy(alpha = 0.14f))
|
||||
.border(1.dp, c.copy(alpha = 0.55f), RoundedCornerShape(10.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
rat.uppercase(),
|
||||
color = c,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- neighbor table ---------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun NeighborTable(neighbors: List<Tower>) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 4.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"NEIGHBOR TOWERS",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
neighbors.size.toString(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
NeighborHeaderRow()
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
|
||||
|
||||
if (neighbors.isEmpty()) {
|
||||
EmptyHint("No neighbors reported.")
|
||||
} else {
|
||||
neighbors.forEachIndexed { i, t ->
|
||||
NeighborDataRow(t)
|
||||
if (i < neighbors.lastIndex) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Column weights shared between header + data rows so they line up.
|
||||
private const val W_ID = 2.2f
|
||||
private const val W_RAT = 1.5f
|
||||
private const val W_PCI = 1.1f
|
||||
private const val W_TAC = 1.3f
|
||||
private const val W_EARFCN = 1.5f
|
||||
private const val W_RSRP = 1.3f
|
||||
|
||||
@Composable
|
||||
private fun NeighborHeaderRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Th("ID", W_ID)
|
||||
Th("RAT", W_RAT)
|
||||
Th("PCI", W_PCI)
|
||||
Th("TAC", W_TAC)
|
||||
Th("EARFCN", W_EARFCN)
|
||||
Th("RSRP", W_RSRP)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NeighborDataRow(t: Tower) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Td(towerLabel(t), W_ID, MaterialTheme.colorScheme.onSurface)
|
||||
Box(Modifier.weight(W_RAT)) {
|
||||
Text(
|
||||
t.rat.uppercase().ifBlank { "—" },
|
||||
color = ratColor(t.rat),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Td(dash(t.pci), W_PCI, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Td(dash(t.tac), W_TAC, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Td(dash(t.earfcn), W_EARFCN, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Td(t.rsrp?.toString() ?: "—", W_RSRP, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun androidx.compose.foundation.layout.RowScope.Th(text: String, weight: Float) {
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(weight),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun androidx.compose.foundation.layout.RowScope.Td(text: String, weight: Float, color: Color) {
|
||||
Text(
|
||||
text,
|
||||
color = color,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(weight),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- alerts -----------------------------------------------------------------
|
||||
|
||||
private val ALERT_TIME_FMT = SimpleDateFormat("HH:mm:ss", Locale.US)
|
||||
|
||||
@Composable
|
||||
private fun AlertRow(a: Alert) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = CgRed.copy(alpha = 0.10f),
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, CgRed.copy(alpha = 0.35f), RoundedCornerShape(14.dp))
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = 5.dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CgRed),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
a.kind.uppercase().ifBlank { "ALERT" },
|
||||
color = CgRed,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
if (a.ts > 0) ALERT_TIME_FMT.format(Date(a.ts)) else "",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (a.detail.isNotBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
a.detail,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared bits ------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(text: String, count: Int) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp, end = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (count > 0) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(CgRed.copy(alpha = 0.18f))
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
count.toString(),
|
||||
color = CgRed,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyHint(text: String) {
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user