feat: android app

This commit is contained in:
2026-06-02 12:45:43 -05:00
parent c23deebde5
commit a771276deb
23 changed files with 1026 additions and 0 deletions

View File

@@ -0,0 +1,594 @@
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") }
},
)
}

View File

@@ -0,0 +1,70 @@
package com.kxbridge.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@Composable
fun SetupScreen(initialUrl: String = "", onConnect: (String) -> Unit) {
var url by remember { mutableStateOf(initialUrl.ifBlank { "http://" }) }
fun submit() {
val trimmed = url.trim()
if (trimmed.length > 7) onConnect(trimmed)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "KX-Bridge",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Enter your bridge server URL",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(40.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Server URL") },
placeholder = { Text("http://192.168.1.x:7125") },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { submit() }),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
Button(
onClick = ::submit,
modifier = Modifier.fillMaxWidth(),
enabled = url.trim().length > 7,
) {
Text("Connect")
}
}
}

View File

@@ -0,0 +1,13 @@
package com.kxbridge.ui.theme
import androidx.compose.ui.graphics.Color
val Orange400 = Color(0xFFFF6B35)
val Orange600 = Color(0xFFE84E0F) // darker for light-mode contrast on white
val Blue300 = Color(0xFF4FC3F7)
val Blue600 = Color(0xFF0288D1) // darker for light-mode contrast on white
val Surface900 = Color(0xFF121212)
val Surface800 = Color(0xFF1E1E1E)
val Surface700 = Color(0xFF2C2C2C)
val OnSurfaceDark = Color(0xFFE0E0E0)
val ErrorRed = Color(0xFFCF6679)

View File

@@ -0,0 +1,30 @@
package com.kxbridge.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val DarkColors = darkColorScheme(
primary = Orange400,
secondary = Blue300,
background = Surface900,
surface = Surface800,
surfaceVariant = Surface700,
onBackground = OnSurfaceDark,
onSurface = OnSurfaceDark,
error = ErrorRed,
)
private val LightColors = lightColorScheme(
primary = Orange600,
secondary = Blue600,
error = ErrorRed,
)
@Composable
fun KxBridgeTheme(content: @Composable () -> Unit) {
val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
MaterialTheme(colorScheme = colors, content = content)
}