initial build

This commit is contained in:
2026-06-24 13:08:48 -05:00
parent 3b14b7d8e6
commit 564234c199
26 changed files with 1180 additions and 0 deletions

57
app/build.gradle.kts Normal file
View File

@@ -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")
}

2
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
-keep class com.kxbridge.data.model.** { *; }
-keepattributes *Annotation*

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:icon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.KxBridge"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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 },
)
}
}
}
}
}

View File

@@ -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<Bitmap> = 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
}
}
}

View File

@@ -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<Result<PrinterState>> = flow {
while (true) {
emit(fetchState())
delay(pollIntervalMs)
}
}
private fun fetchState(): Result<PrinterState> = runCatching {
val req = Request.Builder().url("$baseUrl/api/state").build()
val body = client.newCall(req).execute().use { it.body?.string() ?: "" }
json.decodeFromString<PrinterState>(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<Bitmap> = 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<Unit> = runCatching {
val req = Request.Builder().url("$baseUrl$path").post(emptyBody).build()
client.newCall(req).execute().close()
}
private fun postBody(path: String, jsonBody: String): Result<Unit> = runCatching {
val body = jsonBody.toRequestBody("application/json".toMediaType())
val req = Request.Builder().url("$baseUrl$path").post(body).build()
client.newCall(req).execute().close()
}
}

View File

@@ -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<Int> = emptyList(),
val brand: String = "",
val name: String = "",
)

View File

@@ -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<AmsSlot> = 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
}

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)
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<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)
}

View File

@@ -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>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
private val _cameraFrame = MutableStateFlow<Bitmap?>(null)
val cameraFrame: StateFlow<Bitmap?> = _cameraFrame
private val _cameraEnabled = MutableStateFlow(false)
val cameraEnabled: StateFlow<Boolean> = _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) }
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#121212" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Nozzle body -->
<path
android:fillColor="#FF6B35"
android:pathData="M38,28 L70,28 L70,62 L60,62 L60,72 L54,80 L48,72 L48,62 L38,62 Z" />
<!-- Nozzle tip -->
<path
android:fillColor="#FF6B35"
android:pathData="M48,72 L54,80 L60,72 Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KxBridge" parent="android:Theme.Material.NoActionBar" />
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">KX-Bridge</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KxBridge" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

6
build.gradle.kts Normal file
View File

@@ -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
}

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@@ -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

17
settings.gradle.kts Normal file
View File

@@ -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")