forked from viewit/KX-Bridge-Release
595 lines
20 KiB
Kotlin
595 lines
20 KiB
Kotlin
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") }
|
||
},
|
||
)
|
||
}
|