From a771276deb55c559da8d823cf4f32e4c171d7deb Mon Sep 17 00:00:00 2001 From: fenopy Date: Tue, 2 Jun 2026 12:45:43 -0500 Subject: [PATCH] feat: android app --- .gitignore | 9 + android/app/build.gradle.kts | 57 ++ android/app/proguard-rules.pro | 2 + android/app/src/main/AndroidManifest.xml | 27 + .../main/java/com/kxbridge/MainActivity.kt | 56 ++ .../java/com/kxbridge/ui/PrinterScreen.kt | 594 ++++++++++++++++++ .../main/java/com/kxbridge/ui/SetupScreen.kt | 70 +++ .../main/java/com/kxbridge/ui/theme/Color.kt | 13 + .../main/java/com/kxbridge/ui/theme/Theme.kt | 30 + .../kxbridge/viewmodel/PrinterViewModel.kt | 83 +++ .../res/drawable/ic_launcher_background.xml | 4 + .../res/drawable/ic_launcher_foreground.xml | 15 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../main/res/mipmap-anydpi/ic_launcher.xml | 5 + .../res/mipmap-anydpi/ic_launcher_round.xml | 5 + .../app/src/main/res/values-night/themes.xml | 4 + android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 4 + android/build.gradle.kts | 6 + android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 7 + android/settings.gradle.kts | 17 + 23 files changed, 1026 insertions(+) create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/kxbridge/MainActivity.kt create mode 100644 android/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt create mode 100644 android/app/src/main/java/com/kxbridge/ui/SetupScreen.kt create mode 100644 android/app/src/main/java/com/kxbridge/ui/theme/Color.kt create mode 100644 android/app/src/main/java/com/kxbridge/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/com/kxbridge/viewmodel/PrinterViewModel.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values-night/themes.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts diff --git a/.gitignore b/.gitignore index 167af95..c9e5ab4 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..d1703d2 --- /dev/null +++ b/android/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/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..1d5102f --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +-keep class com.kxbridge.data.model.** { *; } +-keepattributes *Annotation* diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c54fa50 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/kxbridge/MainActivity.kt b/android/app/src/main/java/com/kxbridge/MainActivity.kt new file mode 100644 index 0000000..64996e0 --- /dev/null +++ b/android/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/android/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt b/android/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt new file mode 100644 index 0000000..7fe3f7d --- /dev/null +++ b/android/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) + + 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, 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/android/app/src/main/java/com/kxbridge/ui/SetupScreen.kt b/android/app/src/main/java/com/kxbridge/ui/SetupScreen.kt new file mode 100644 index 0000000..fe58b9b --- /dev/null +++ b/android/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/android/app/src/main/java/com/kxbridge/ui/theme/Color.kt b/android/app/src/main/java/com/kxbridge/ui/theme/Color.kt new file mode 100644 index 0000000..f171246 --- /dev/null +++ b/android/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/android/app/src/main/java/com/kxbridge/ui/theme/Theme.kt b/android/app/src/main/java/com/kxbridge/ui/theme/Theme.kt new file mode 100644 index 0000000..5cea4e7 --- /dev/null +++ b/android/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/android/app/src/main/java/com/kxbridge/viewmodel/PrinterViewModel.kt b/android/app/src/main/java/com/kxbridge/viewmodel/PrinterViewModel.kt new file mode 100644 index 0000000..1754d16 --- /dev/null +++ b/android/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/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..65f94ad --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..b9f1e2e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..c08b4c9 --- /dev/null +++ b/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,4 @@ + + +