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(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, 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") } }, ) }