Merge pull request 'feat: android app' (#1) from android into master

Reviewed-on: http://192.168.1.103:3000/fenopy/KX-Bridge-Release/pulls/1
This commit is contained in:
fenopy
2026-06-03 06:12:55 -05:00
23 changed files with 1026 additions and 0 deletions

9
.gitignore vendored
View File

@@ -17,3 +17,12 @@ config/*.ini
data/
!data/orca_filaments.json
# Android build artifacts
android/.gradle/
android/build/
android/app/build/
android/local.properties
android/**/*.iml
android/.idea/
android/app/release/

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
android/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,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)
}

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
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.3.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
}

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.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

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