• Código e Implementación¶
Componentes bases.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SimpleTopBar(title: String, onBack: () -> Unit) {
CenterAlignedTopAppBar(
title = { Text(text = title, fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Text(text = "←", fontSize = 20.sp)
}
}
)
}
@Composable
fun GradientBackground(content: @Composable ColumnScope.() -> Unit) {
val bg = Brush.verticalGradient(
colors = listOf(
Color(0xFFEFF6FF),
Color(0xFFFFFFFF)
)
)
Column(
modifier = Modifier
.fillMaxSize()
.background(bg)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
content = content
)
}
@Composable
fun PrimaryPillButton(text: String, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(46.dp),
shape = RoundedCornerShape(999.dp)
) {
Text(text = text, fontWeight = FontWeight.SemiBold)
}
}
@Composable
fun OutlinePillButton(text: String, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(46.dp),
shape = RoundedCornerShape(999.dp)
) {
Text(text = text, fontWeight = FontWeight.SemiBold)
}
}
Ventana principal
@Composable
fun HomeScreen(
onGoContainers: () -> Unit,
onGoNotifications: () -> Unit,
onGoLogin: () -> Unit
) {
GradientBackground {
// “Logo” simple estilo BR (placeholder)
Image(
painter = painterResource(id = R.drawable.logo_binraiders),
contentDescription = "Logo Bin Raiders",
modifier = Modifier
.size(300.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(Modifier.height(18.dp))
Text("¡Bienvenidos a\nBin Raiders!", fontSize = 28.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(28.dp))
PrimaryPillButton("Ver contenedores", onGoContainers)
Spacer(Modifier.height(12.dp))
OutlinePillButton("Notificaciones", onGoNotifications)
Spacer(Modifier.height(12.dp))
PrimaryPillButton("Acceder", onGoLogin)
Spacer(Modifier.height(18.dp))
Text("Powered by Raspberry Pi 4", color = Color(0xFF6B7280), fontSize = 13.sp)
}
}
Contenedores
data class ContainerItem(
val id: Int,
val name: String,
val address: String,
val imageHint: String = ""
)
Ventana Contenedores
@Composable
fun ContainersScreen(
onBack: () -> Unit,
onOpenCamera: (ContainerItem) -> Unit
) {
val containers = remember {
listOf(
ContainerItem(1, "Contenedor 1", "Dirección: Av. Diego Portales & Las Torres"),
ContainerItem(2, "Contenedor 2", "Dirección: Av. Edmundo Flores 112"),
ContainerItem(3, "Contenedor 3", "Dirección: Av. Santa María 421")
)
}
var estado by remember { mutableStateOf<EstadoResponse?>(null) }
var loading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
suspend fun refreshEstado() {
loading = true
val res = withContext(Dispatchers.IO) { ApiClient.getEstado() }
estado = res
loading = false
}
// 1) Primera carga
LaunchedEffect(Unit) {
refreshEstado()
}
// 2) Auto-refresh para demo (cada 2.5s)
LaunchedEffect(Unit) {
while (true) {
delay(2500)
refreshEstado()
}
}
Scaffold(
topBar = { SimpleTopBar(title = "Contenedores", onBack = onBack) }
) { pad ->
Column(
modifier = Modifier
.padding(pad)
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
EstadoSensorCard(
estado = estado,
loading = loading,
onRefresh = { scope.launch { refreshEstado() } }
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(containers) { c ->
ContainerCard(item = c, onOpenCamera = { onOpenCamera(c) })
}
}
}
}
}
@Composable
fun ContainerCard(
item: ContainerItem,
onOpenCamera: () -> Unit
) {
var enabled by remember { mutableStateOf(true) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Mini imagen placeholder
Box(
modifier = Modifier
.size(68.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFE5E7EB))
.border(1.dp, Color(0xFFD1D5DB), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Text("IMG", color = Color(0xFF6B7280), fontSize = 12.sp)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(item.name, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(2.dp))
Text(item.address, color = Color(0xFF6B7280), fontSize = 13.sp)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = onOpenCamera,
shape = RoundedCornerShape(10.dp),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp)
) {
Text("Ver cámara")
}
Spacer(Modifier.width(10.dp))
Switch(checked = enabled, onCheckedChange = { enabled = it })
}
}
}
}
}
Ventana camara
@Composable
fun CameraScreen(
container: ContainerItem?,
onBack: () -> Unit
) {
val streamUrl = ApiConfig.STREAM_URL
Scaffold(
topBar = { SimpleTopBar(title = "Cámara", onBack = onBack) }
) { pad ->
Column(
modifier = Modifier
.padding(pad)
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = container?.name ?: "Contenedor",
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(
text = container?.address ?: "",
color = Color(0xFF6B7280)
)
MjpegWebView(url = streamUrl)
Text(
text = "Fuente: $streamUrl",
fontSize = 12.sp,
color = Color(0xFF6B7280)
)
}
}
}
@Composable
fun MjpegWebView(url: String) {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.height(420.dp)
.clip(RoundedCornerShape(16.dp))
.border(1.dp, Color(0xFFE5E7EB), RoundedCornerShape(16.dp)),
factory = { ctx ->
WebView(ctx).apply {
webViewClient = WebViewClient()
settings.javaScriptEnabled = false
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
loadUrl(url)
}
},
update = { webView ->
if (webView.url != url) webView.loadUrl(url)
}
)
}
@Composable
fun EstadoSensorCard(
estado: EstadoResponse?,
loading: Boolean,
onRefresh: () -> Unit
) {
val pct = estado?.fill_percent ?: 0
val label = when {
loading -> "Cargando..."
estado == null -> "Sin datos"
estado.ok -> "Estado: ${estado.estado}"
else -> "Error: ${estado.error ?: "sensor_no_data"}"
}
val color = when (estado?.estado) {
"LLENO" -> Color(0xFFEF4444) // rojo
"MEDIO" -> Color(0xFFF59E0B) // amarillo
"VACIO" -> Color(0xFF22C55E) // verde
else -> Color(0xFF6B7280) // gris
}
Card(shape = RoundedCornerShape(16.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Estado del contenedor", fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
AssistChip(
onClick = {},
label = { Text(estado?.estado ?: "—") },
colors = AssistChipDefaults.assistChipColors(
labelColor = Color.White,
containerColor = color
)
)
}
Text(label, color = Color(0xFF374151))
if (estado?.ok == true) {
Text("Distancia: ${estado.distance_cm} cm")
Text("Relleno: ${estado.fill_percent}%")
LinearProgressIndicator(
progress = (pct.coerceIn(0, 100) / 100f),
modifier = Modifier.fillMaxWidth()
)
Text(
"Edad dato: ${estado.sensor_age_ms ?: 0} ms",
fontSize = 12.sp,
color = Color(0xFF6B7280)
)
}
Button(
onClick = onRefresh,
enabled = !loading,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(if (loading) "Actualizando..." else "Actualizar")
}
}
}
}