Files
KX-Bridge-Release/android/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt
2026-06-02 12:45:43 -05:00

595 lines
20 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.kxbridge.ui
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.kxbridge.data.model.AmsSlot
import com.kxbridge.data.model.PrinterState
import com.kxbridge.ui.theme.Blue300
import com.kxbridge.ui.theme.ErrorRed
import com.kxbridge.ui.theme.Orange400
import com.kxbridge.viewmodel.PrinterViewModel
import com.kxbridge.viewmodel.UiState
import kotlin.math.roundToInt
private enum class TempTarget { NOZZLE, BED }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrinterScreen(
viewModel: PrinterViewModel,
onChangeServer: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsState()
val cameraFrame by viewModel.cameraFrame.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
var showCancelDialog by remember { mutableStateOf(false) }
if (showCancelDialog) {
AlertDialog(
onDismissRequest = { showCancelDialog = false },
title = { Text("Stop print?") },
text = { Text("This will stop the current print job.") },
confirmButton = {
TextButton(onClick = {
showCancelDialog = false
viewModel.cancel()
}) { Text("Stop print", color = ErrorRed) }
},
dismissButton = {
TextButton(onClick = { showCancelDialog = false }) { Text("Keep printing") }
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
val name = (uiState as? UiState.Success)?.state?.printerName
?.takeIf { it.isNotBlank() } ?: "KX-Bridge"
Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
actions = {
IconButton(onClick = onChangeServer) {
Icon(Icons.Default.Settings, contentDescription = "Change server")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
when (val s = uiState) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Error -> ErrorContent(s.message)
is UiState.Success -> PrinterContent(
state = s.state,
cameraFrame = cameraFrame,
cameraEnabled = cameraEnabled,
onPause = viewModel::pause,
onResume = viewModel::resume,
onCancel = { showCancelDialog = true },
onSetTemp = viewModel::setTemperature,
onToggleLight = { on -> viewModel.setLight(on) },
onToggleCamera = viewModel::setCameraEnabled,
)
}
}
}
}
@Composable
private fun ErrorContent(message: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Cannot reach bridge", style = MaterialTheme.typography.titleMedium, color = ErrorRed)
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
}
}
@Composable
private fun PrinterContent(
state: PrinterState,
cameraFrame: Bitmap?,
cameraEnabled: Boolean,
onPause: () -> Unit,
onResume: () -> Unit,
onCancel: () -> Unit,
onSetTemp: (nozzle: Double, bed: Double) -> Unit,
onToggleLight: (Boolean) -> Unit,
onToggleCamera: (Boolean) -> Unit,
) {
var tempDialog by remember { mutableStateOf<TempTarget?>(null) }
if (tempDialog != null) {
TempDialog(
target = tempDialog!!,
currentNozzle = state.nozzleTarget,
currentBed = state.bedTarget,
onConfirm = { nozzle, bed ->
onSetTemp(nozzle, bed)
tempDialog = null
},
onDismiss = { tempDialog = null },
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
StatusBadge(state.printState)
CameraCard(
cameraFrame = cameraFrame,
cameraEnabled = cameraEnabled,
onToggle = onToggleCamera,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
TempCard(
label = "Nozzle",
current = state.nozzleTemp,
target = state.nozzleTarget,
activeColor = Orange400,
modifier = Modifier.weight(1f),
onClick = { tempDialog = TempTarget.NOZZLE },
)
TempCard(
label = "Bed",
current = state.bedTemp,
target = state.bedTarget,
activeColor = Blue300,
modifier = Modifier.weight(1f),
onClick = { tempDialog = TempTarget.BED },
)
}
if (state.isActive || state.printState == "complete") {
PrintProgressCard(state)
}
if (state.isActive) {
PrintControls(
isPrinting = state.isPrinting,
onPause = onPause,
onResume = onResume,
onCancel = onCancel,
)
}
LightCard(
isOn = state.lightOn,
brightness = state.lightBrightness,
onToggle = onToggleLight,
)
if (state.amsSlots.isNotEmpty()) {
FilamentCard(slots = state.amsSlots, mode = state.filamentMode)
}
}
}
@Composable
private fun StatusBadge(printState: String) {
val (label, color) = when (printState) {
"printing" -> "Printing" to Orange400
"paused" -> "Paused" to Blue300
"complete" -> "Complete" to Color(0xFF66BB6A)
"error" -> "Error" to ErrorRed
"cancelled" -> "Cancelled" to MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
else -> "Standby" to MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
}
Surface(
color = color.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.small,
) {
Text(
text = label,
color = color,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
@Composable
private fun CameraCard(
cameraFrame: Bitmap?,
cameraEnabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Camera",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Switch(checked = cameraEnabled, onCheckedChange = onToggle)
}
if (cameraEnabled) {
if (cameraFrame != null) {
Image(
bitmap = cameraFrame.asImageBitmap(),
contentDescription = "Camera stream",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f),
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
}
}
@Composable
private fun TempCard(
label: String,
current: Double,
target: Double,
activeColor: Color,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
val isHeating = target > 0.0
val indicatorColor = if (isHeating) activeColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
Card(
onClick = onClick,
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
)
Text(
"Tap to set",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
)
}
Text(
text = "${current.roundToInt()}°",
style = MaterialTheme.typography.headlineMedium,
color = indicatorColor,
fontWeight = FontWeight.Bold,
)
Text(
text = if (isHeating) "${target.roundToInt()}°" else "Off",
style = MaterialTheme.typography.bodySmall,
color = indicatorColor.copy(alpha = 0.7f),
)
}
}
}
@Composable
private fun PrintProgressCard(state: PrinterState) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (state.filename.isNotBlank()) {
Text(
text = state.filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
val pct = (state.progress * 100).roundToInt()
LinearProgressIndicator(
progress = { state.progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("$pct%", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
val curr = state.currLayer
val total = state.totalLayers
if (curr != null && total != null && total > 0) {
Text(
"Layer $curr / $total",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
)
}
}
val remain = state.remainTime
if (state.printDuration > 0 || (remain != null && remain > 0)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.printDuration > 0) {
TimeLabel(
label = "Elapsed",
seconds = state.printDuration.toInt(),
modifier = Modifier.weight(1f),
)
}
if (remain != null && remain > 0) {
val estimate = state.printDuration.toInt() + remain
TimeLabel(label = "Est. Total", seconds = estimate, modifier = Modifier.weight(1f))
TimeLabel(label = "Remaining", seconds = remain, modifier = Modifier.weight(1f))
}
}
}
}
}
}
@Composable
private fun TimeLabel(label: String, seconds: Int, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
Text(
formatDuration(seconds),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
)
}
}
private fun formatDuration(totalSeconds: Int): String {
val h = totalSeconds / 3600
val m = (totalSeconds % 3600) / 60
val s = totalSeconds % 60
return when {
h > 0 -> "${h}h ${m}m"
m > 0 -> "${m}m ${s}s"
else -> "${s}s"
}
}
@Composable
private fun PrintControls(
isPrinting: Boolean,
onPause: () -> Unit,
onResume: () -> Unit,
onCancel: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isPrinting) {
Button(onClick = onPause, modifier = Modifier.weight(1f)) { Text("Pause") }
} else {
Button(onClick = onResume, modifier = Modifier.weight(1f)) { Text("Resume") }
}
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = ErrorRed),
) {
Text("Stop")
}
}
}
@Composable
private fun LightCard(isOn: Boolean, brightness: Int, onToggle: (Boolean) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Light", style = MaterialTheme.typography.labelLarge)
Text(
if (isOn) "$brightness%" else "Off",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
}
Switch(
checked = isOn,
onCheckedChange = onToggle,
colors = SwitchDefaults.colors(
checkedThumbColor = Orange400,
checkedTrackColor = Orange400.copy(alpha = 0.5f),
),
)
}
}
}
@Composable
private fun FilamentCard(slots: List<AmsSlot>, mode: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = when (mode) {
"ace_direct" -> "AMS Direct"
"ace_hub" -> "AMS Hub"
else -> "Filament"
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
slots.forEach { slot -> FilamentSlot(slot, Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun FilamentSlot(slot: AmsSlot, modifier: Modifier = Modifier) {
val slotColor = if (slot.color.size >= 3) {
Color(slot.color[0] / 255f, slot.color[1] / 255f, slot.color[2] / 255f)
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(32.dp)
.background(slotColor, shape = CircleShape),
)
Text(
text = slot.type.ifBlank { "" },
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (slot.brand.isNotBlank()) {
Text(
text = slot.brand,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun TempDialog(
target: TempTarget,
currentNozzle: Double,
currentBed: Double,
onConfirm: (nozzle: Double, bed: Double) -> Unit,
onDismiss: () -> Unit,
) {
val label = if (target == TempTarget.NOZZLE) "Nozzle" else "Bed"
val currentValue = if (target == TempTarget.NOZZLE) currentNozzle else currentBed
var input by remember { mutableStateOf(currentValue.roundToInt().toString()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Set $label Temperature") },
text = {
OutlinedTextField(
value = input,
onValueChange = { input = it.filter { c -> c.isDigit() } },
label = { Text("Temperature (°C)") },
suffix = { Text("°C") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = {
val newTemp = input.toDoubleOrNull() ?: currentValue
if (target == TempTarget.NOZZLE) onConfirm(newTemp, currentBed)
else onConfirm(currentNozzle, newTemp)
}) { Text("Set") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}