diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..d1703d2
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,57 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "com.kxbridge"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.kxbridge"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
+ implementation(composeBom)
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.activity:activity-compose:1.9.0")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..1d5102f
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,2 @@
+-keep class com.kxbridge.data.model.** { *; }
+-keepattributes *Annotation*
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c54fa50
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/kxbridge/MainActivity.kt b/app/src/main/java/com/kxbridge/MainActivity.kt
new file mode 100644
index 0000000..64996e0
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/MainActivity.kt
@@ -0,0 +1,56 @@
+package com.kxbridge
+
+import android.content.Context
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.runtime.*
+import com.kxbridge.ui.PrinterScreen
+import com.kxbridge.ui.SetupScreen
+import com.kxbridge.ui.theme.KxBridgeTheme
+import com.kxbridge.viewmodel.PrinterViewModel
+
+private const val PREFS = "kxbridge"
+private const val KEY_URL = "server_url"
+
+class MainActivity : ComponentActivity() {
+
+ private val viewModel: PrinterViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val savedUrl = prefs.getString(KEY_URL, null)
+ if (savedUrl != null) {
+ viewModel.connect(savedUrl)
+ }
+
+ setContent {
+ KxBridgeTheme {
+ var serverUrl by remember { mutableStateOf(savedUrl ?: "") }
+ var showSetup by remember { mutableStateOf(savedUrl == null) }
+
+ if (showSetup) {
+ SetupScreen(
+ initialUrl = serverUrl,
+ onConnect = { url ->
+ prefs.edit().putString(KEY_URL, url).apply()
+ serverUrl = url
+ showSetup = false
+ viewModel.connect(url)
+ },
+ )
+ } else {
+ PrinterScreen(
+ viewModel = viewModel,
+ onChangeServer = { showSetup = true },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/kxbridge/data/MjpegReader.kt b/app/src/main/java/com/kxbridge/data/MjpegReader.kt
new file mode 100644
index 0000000..dc63aa4
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/data/MjpegReader.kt
@@ -0,0 +1,50 @@
+package com.kxbridge.data
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.IOException
+
+fun mjpegFrames(url: String, client: OkHttpClient): Flow = flow {
+ val call = client.newCall(Request.Builder().url(url).build())
+ currentCoroutineContext()[Job]?.invokeOnCompletion { call.cancel() }
+
+ val response = try { call.execute() } catch (_: IOException) { return@flow }
+ response.use {
+ val body = it.body ?: return@flow
+ val contentType = it.header("Content-Type") ?: return@flow
+ val boundaryValue = contentType.split(";")
+ .firstOrNull { s -> s.trim().startsWith("boundary=") }
+ ?.substringAfter("=")?.trim() ?: return@flow
+ val boundary = "--$boundaryValue"
+ val source = body.source()
+
+ try {
+ while (true) {
+ val line = source.readUtf8Line() ?: break
+ if (!line.startsWith(boundary)) continue
+
+ var contentLength = -1
+ while (true) {
+ val header = source.readUtf8Line() ?: break
+ if (header.isBlank()) break
+ if (header.startsWith("Content-Length:", ignoreCase = true)) {
+ contentLength = header.substringAfter(":").trim().toIntOrNull() ?: -1
+ }
+ }
+
+ if (contentLength > 0) {
+ val bytes = source.readByteArray(contentLength.toLong())
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bmp -> emit(bmp) }
+ }
+ }
+ } catch (_: IOException) {
+ // Stream closed or coroutine cancelled
+ }
+ }
+}
diff --git a/app/src/main/java/com/kxbridge/data/PrinterRepository.kt b/app/src/main/java/com/kxbridge/data/PrinterRepository.kt
new file mode 100644
index 0000000..72c9408
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/data/PrinterRepository.kt
@@ -0,0 +1,71 @@
+package com.kxbridge.data
+
+import android.graphics.Bitmap
+import com.kxbridge.data.model.PrinterState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.util.concurrent.TimeUnit
+
+class PrinterRepository(private val baseUrl: String) {
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(5, TimeUnit.SECONDS)
+ .build()
+
+ private val streamClient = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(0, TimeUnit.SECONDS)
+ .build()
+
+ private val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ coerceInputValues = true
+ }
+ private val emptyBody = ByteArray(0).toRequestBody("application/json".toMediaType())
+
+ fun stateFlow(pollIntervalMs: Long = 3_000L): Flow> = flow {
+ while (true) {
+ emit(fetchState())
+ delay(pollIntervalMs)
+ }
+ }
+
+ private fun fetchState(): Result = runCatching {
+ val req = Request.Builder().url("$baseUrl/api/state").build()
+ val body = client.newCall(req).execute().use { it.body?.string() ?: "" }
+ json.decodeFromString(body)
+ }
+
+ fun pause() = post("/printer/print/pause")
+ fun resume() = post("/printer/print/resume")
+ fun cancel() = post("/printer/print/cancel")
+
+ fun cameraStart() = post("/api/camera/start")
+ fun cameraStop() = post("/api/camera/stop")
+ fun cameraFrames(): Flow = mjpegFrames("$baseUrl/api/camera/stream", streamClient)
+
+ fun setLight(on: Boolean, brightness: Int) =
+ postBody("/api/light", """{"on":$on,"brightness":$brightness}""")
+
+ fun setTemperature(nozzle: Double, bed: Double) =
+ postBody("/api/temperature", """{"nozzle":$nozzle,"bed":$bed}""")
+
+ private fun post(path: String): Result = runCatching {
+ val req = Request.Builder().url("$baseUrl$path").post(emptyBody).build()
+ client.newCall(req).execute().close()
+ }
+
+ private fun postBody(path: String, jsonBody: String): Result = runCatching {
+ val body = jsonBody.toRequestBody("application/json".toMediaType())
+ val req = Request.Builder().url("$baseUrl$path").post(body).build()
+ client.newCall(req).execute().close()
+ }
+}
diff --git a/app/src/main/java/com/kxbridge/data/model/AmsSlot.kt b/app/src/main/java/com/kxbridge/data/model/AmsSlot.kt
new file mode 100644
index 0000000..ef24d65
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/data/model/AmsSlot.kt
@@ -0,0 +1,12 @@
+package com.kxbridge.data.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class AmsSlot(
+ val index: Int = 0,
+ val type: String = "",
+ val color: List = emptyList(),
+ val brand: String = "",
+ val name: String = "",
+)
diff --git a/app/src/main/java/com/kxbridge/data/model/PrinterState.kt b/app/src/main/java/com/kxbridge/data/model/PrinterState.kt
new file mode 100644
index 0000000..f991902
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/data/model/PrinterState.kt
@@ -0,0 +1,30 @@
+package com.kxbridge.data.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class PrinterState(
+ @SerialName("printer_name") val printerName: String = "",
+ @SerialName("print_state") val printState: String = "standby",
+ @SerialName("nozzle_temp") val nozzleTemp: Double = 0.0,
+ @SerialName("nozzle_target") val nozzleTarget: Double = 0.0,
+ @SerialName("bed_temp") val bedTemp: Double = 0.0,
+ @SerialName("bed_target") val bedTarget: Double = 0.0,
+ val progress: Double = 0.0,
+ @SerialName("print_duration") val printDuration: Double = 0.0,
+ @SerialName("remain_time") val remainTime: Int? = null,
+ @SerialName("curr_layer") val currLayer: Int? = null,
+ @SerialName("total_layers") val totalLayers: Int? = null,
+ val filename: String = "",
+ val version: String = "",
+ @SerialName("light_on") val lightOn: Boolean = false,
+ @SerialName("light_brightness") val lightBrightness: Int = 80,
+ @SerialName("camera_url") val cameraUrl: String = "",
+ @SerialName("ams_slots") val amsSlots: List = emptyList(),
+ @SerialName("filament_mode") val filamentMode: String = "toolhead",
+) {
+ val isPrinting: Boolean get() = printState == "printing"
+ val isPaused: Boolean get() = printState == "paused"
+ val isActive: Boolean get() = isPrinting || isPaused
+}
diff --git a/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt b/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt
new file mode 100644
index 0000000..3f84cbc
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt
@@ -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(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)
+
+ LightCard(
+ isOn = state.lightOn,
+ brightness = state.lightBrightness,
+ onToggle = onToggleLight,
+ )
+
+ 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,
+ )
+ }
+
+ 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") }
+ },
+ )
+}
diff --git a/app/src/main/java/com/kxbridge/ui/SetupScreen.kt b/app/src/main/java/com/kxbridge/ui/SetupScreen.kt
new file mode 100644
index 0000000..fe58b9b
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/ui/SetupScreen.kt
@@ -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")
+ }
+ }
+}
diff --git a/app/src/main/java/com/kxbridge/ui/theme/Color.kt b/app/src/main/java/com/kxbridge/ui/theme/Color.kt
new file mode 100644
index 0000000..f171246
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/ui/theme/Color.kt
@@ -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)
diff --git a/app/src/main/java/com/kxbridge/ui/theme/Theme.kt b/app/src/main/java/com/kxbridge/ui/theme/Theme.kt
new file mode 100644
index 0000000..5cea4e7
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/ui/theme/Theme.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/kxbridge/viewmodel/PrinterViewModel.kt b/app/src/main/java/com/kxbridge/viewmodel/PrinterViewModel.kt
new file mode 100644
index 0000000..1754d16
--- /dev/null
+++ b/app/src/main/java/com/kxbridge/viewmodel/PrinterViewModel.kt
@@ -0,0 +1,83 @@
+package com.kxbridge.viewmodel
+
+import android.graphics.Bitmap
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.kxbridge.data.PrinterRepository
+import com.kxbridge.data.model.PrinterState
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+sealed interface UiState {
+ data object Loading : UiState
+ data class Error(val message: String) : UiState
+ data class Success(val state: PrinterState) : UiState
+}
+
+class PrinterViewModel : ViewModel() {
+
+ private val _uiState = MutableStateFlow(UiState.Loading)
+ val uiState: StateFlow = _uiState
+
+ private val _cameraFrame = MutableStateFlow(null)
+ val cameraFrame: StateFlow = _cameraFrame
+
+ private val _cameraEnabled = MutableStateFlow(false)
+ val cameraEnabled: StateFlow = _cameraEnabled
+
+ private var pollJob: Job? = null
+ private var cameraJob: Job? = null
+ private var repo: PrinterRepository? = null
+
+ fun connect(baseUrl: String) {
+ pollJob?.cancel()
+ repo = PrinterRepository(baseUrl.trimEnd('/'))
+ _uiState.value = UiState.Loading
+ pollJob = viewModelScope.launch(Dispatchers.IO) {
+ repo!!.stateFlow().collect { result ->
+ _uiState.value = result.fold(
+ onSuccess = { UiState.Success(it) },
+ onFailure = { UiState.Error(it.message ?: "Connection failed") },
+ )
+ }
+ }
+ }
+
+ fun pause() = command { it.pause() }
+ fun resume() = command { it.resume() }
+ fun cancel() = command { it.cancel() }
+
+ fun setLight(on: Boolean, brightness: Int = 80) = command { it.setLight(on, brightness) }
+ fun setTemperature(nozzle: Double, bed: Double) = command { it.setTemperature(nozzle, bed) }
+
+ fun setCameraEnabled(enabled: Boolean) {
+ _cameraEnabled.value = enabled
+ if (enabled) {
+ command { it.cameraStart() }
+ cameraJob?.cancel()
+ cameraJob = viewModelScope.launch(Dispatchers.IO) {
+ repo?.cameraFrames()?.collect { _cameraFrame.value = it }
+ }
+ } else {
+ cameraJob?.cancel()
+ cameraJob = null
+ _cameraFrame.value = null
+ command { it.cameraStop() }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ cameraJob?.cancel()
+ if (_cameraEnabled.value) {
+ viewModelScope.launch(Dispatchers.IO) { repo?.cameraStop() }
+ }
+ }
+
+ private fun command(block: (PrinterRepository) -> Unit) {
+ viewModelScope.launch(Dispatchers.IO) { repo?.let(block) }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..65f94ad
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..b9f1e2e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..c08b4c9
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..99f41e2
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ KX-Bridge
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..c8eca6e
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..a4cd58a
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,6 @@
+plugins {
+ id("com.android.application") version "8.13.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.0" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..37f853b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..930a847
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "KX-Bridge"
+include(":app")