diff --git a/android/app/build.gradle b/android/app/build.gradle index 18e609b..f964e14 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { applicationId 'org.crabcore.u1control' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 18 - versionName "1.2.8" + versionCode 19 + versionName "3.0.0" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf b/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf new file mode 100644 index 0000000..bba7dcf Binary files /dev/null and b/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf differ diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/EdgeInsets.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/EdgeInsets.kt index 0b894b1..6da3f4b 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/EdgeInsets.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/EdgeInsets.kt @@ -24,4 +24,19 @@ object EdgeInsets { } ViewCompat.requestApplyInsets(root) } + + /** + * Pads only the bottom of [view] by the nav-bar inset, on top of the padding it + * already has. For bottom sheets, which sit against the bottom edge but must + * keep their own top and side padding untouched. + */ + fun applyBottom(view: View) { + val base = view.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(view) { target, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + target.setPadding(target.paddingLeft, target.paddingTop, target.paddingRight, base + bars.bottom) + insets + } + ViewCompat.requestApplyInsets(view) + } } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/FilamentSlotDetails.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/FilamentSlotDetails.kt new file mode 100644 index 0000000..49054aa --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/FilamentSlotDetails.kt @@ -0,0 +1,59 @@ +package org.crabcore.u1control.slicing + +import android.content.Context + +/** + * What the RN app knows about each physical filament slot beyond its colour: + * material, sub type, brand, and whether the machine reports it loaded. + * + * Colours stay in [FilamentSlotColors] because paint and mesh palettes need them + * on their own terms. This store carries the descriptive half so native screens — + * the print preprocess sheet especially — can label a lane exactly as the RN + * sheet does without re-deriving anything from Moonraker. RN resolves printer + * truth against saved settings and pushes both halves through + * [HelixSlicerModule.setFilamentSlots]. + */ +object FilamentSlotDetails { + /** Mirrors RN `FilamentSlotDisplay`; [status] is a `FilamentSlotStatus` value. */ + data class Slot( + val material: String, + val mainType: String, + val subType: String, + val brand: String, + val status: String, + ) { + val isEmpty: Boolean get() = status == "empty" + } + + private const val PREFS = "helix_filament_slot_details" + private const val COUNT = 4 + + /** Stand-in until RN has pushed. `unknown` reads as usable, never as empty. */ + private val UNKNOWN = Slot(material = "PLA", mainType = "PLA", subType = "", brand = "", status = "unknown") + + fun read(context: Context): List { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return (0 until COUNT).map { index -> + Slot( + material = prefs.getString("material$index", null) ?: UNKNOWN.material, + mainType = prefs.getString("mainType$index", null) ?: UNKNOWN.mainType, + subType = prefs.getString("subType$index", null) ?: UNKNOWN.subType, + brand = prefs.getString("brand$index", null) ?: UNKNOWN.brand, + status = prefs.getString("status$index", null) ?: UNKNOWN.status, + ) + } + } + + fun write(context: Context, slots: List) { + val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + for (index in 0 until COUNT) { + val slot = slots.getOrNull(index) ?: UNKNOWN + editor.putString("material$index", slot.material) + editor.putString("mainType$index", slot.mainType) + editor.putString("subType$index", slot.subType) + editor.putString("brand$index", slot.brand) + editor.putString("status$index", slot.status) + } + editor.apply() + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/GcodeThumbnailReader.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/GcodeThumbnailReader.kt new file mode 100644 index 0000000..9d315e3 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/GcodeThumbnailReader.kt @@ -0,0 +1,73 @@ +package org.crabcore.u1control.slicing + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import java.io.File + +/** + * Reads the largest thumbnail a slicer embedded in a .gcode header. + * + * Shared by the RN bridge ([HelixSlicerModule.getGcodeThumbnail]) and the native + * print preprocess sheet, so both show the render baked in at slice time rather + * than waiting on a Moonraker upload. + */ +object GcodeThumbnailReader { + private const val BEGIN = "; thumbnail begin" + private const val END = "; thumbnail end" + + /** Base64 PNG of the largest embedded thumbnail, or null when there is none. */ + fun readBase64(path: String): String? { + val file = File(path.removePrefix("file://")) + if (!file.exists()) return null + + var bestArea = 0 + var best: String? = null + var currentArea = 0 + var current: StringBuilder? = null + + return try { + file.bufferedReader().use { reader -> + while (true) { + val line = reader.readLine() ?: break + val trimmed = line.trim() + when { + trimmed.startsWith(BEGIN) -> { + val dims = trimmed.removePrefix(BEGIN).trim().substringBefore(' ').split('x') + currentArea = (dims.getOrNull(0)?.toIntOrNull() ?: 0) * + (dims.getOrNull(1)?.toIntOrNull() ?: 0) + current = StringBuilder() + } + trimmed.startsWith(END) -> { + val block = current + if (block != null && currentArea > bestArea) { + bestArea = currentArea + best = block.toString() + } + current = null + } + current != null && trimmed.startsWith(";") -> + current?.append(trimmed.removePrefix(";").trim()) + // Thumbnails live in the header; stop once real motion starts. + best != null && (trimmed.startsWith("G1") || trimmed.startsWith("G0")) -> return@use + } + } + } + best + } catch (_: Throwable) { + null + } + } + + fun readDataUri(path: String): String? = readBase64(path)?.let { "data:image/png;base64,$it" } + + fun readBitmap(path: String): Bitmap? { + val encoded = readBase64(path) ?: return null + return try { + val bytes = Base64.decode(encoded, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } catch (_: Throwable) { + null + } + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixCockpit.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixCockpit.kt new file mode 100644 index 0000000..677753f --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixCockpit.kt @@ -0,0 +1,35 @@ +package org.crabcore.u1control.slicing + +/** + * The RN Cockpit palette ([components/dashboard/shared.tsx]) mirrored for native + * views. + * + * The native print preprocess sheet is a port of its RN counterpart and has to + * match it, so it uses these tokens instead of the app-wide [HelixAppTheme] + * ones — including the fixed accent, which the RN sheet also uses in place of + * the user's chosen accent colour. Both preprocess dialogs then look identical + * no matter which one a print flow reaches. + */ +object HelixCockpit { + const val BG = 0xFF0B0D10.toInt() + const val SURFACE = 0xFF15181D.toInt() + const val SURFACE_ALT = 0xFF1D222A.toInt() + const val BORDER = 0xFF252A33.toInt() + const val TEXT = 0xFFF4F7FB.toInt() + const val DIM = 0xFF8B95A1.toInt() + const val ACCENT = 0xFF00D4C8.toInt() + const val ACCENT_FILL = 0xFF00B3A9.toInt() + const val ON_ACCENT = 0xFF00201E.toInt() + const val SUCCESS = 0xFF31D583.toInt() + const val WARN = 0xFFFFB020.toInt() + const val DANGER = 0xFFFF5A5C.toInt() + const val SCRIM = 0x9E000000.toInt() + + /** `P.radius` / `P.gap` from the RN palette. */ + const val RADIUS = 20 + const val GAP = 16 + + /** RN `alpha()` — the same colour at a new opacity. */ + fun alpha(color: Int, fraction: Float): Int = + (color and 0x00FFFFFF) or (((fraction.coerceIn(0f, 1f) * 255f).toInt()) shl 24) +} diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixGcodePreviewActivity.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixGcodePreviewActivity.kt index ef344af..5a713a9 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixGcodePreviewActivity.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixGcodePreviewActivity.kt @@ -9,27 +9,23 @@ import android.graphics.Paint import android.graphics.drawable.GradientDrawable import android.net.Uri import android.os.Bundle -import android.content.res.ColorStateList import android.view.Gravity import android.view.MotionEvent import android.view.View -import android.widget.CheckBox import android.widget.FrameLayout -import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView import org.json.JSONObject import org.crabcore.u1control.MainActivity -import org.crabcore.u1control.R import com.u1.slicer.gcode.GcodeParser import com.u1.slicer.gcode.ParsedGcode import com.u1.slicer.viewer.GcodeViewerView import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.File import java.net.URLEncoder @@ -78,6 +74,10 @@ class HelixGcodePreviewActivity : Activity() { // the physical U1 slot the user picked for it. Identity until changed. private val toolSlotMap = intArrayOf(0, 1, 2, 3) + // The Ticket-style preprocess sheet; held so the upload thread can drive its + // progress overlay, and nulled once the send resolves or the sheet dismisses. + private var preprocessSheet: HelixPreprocessSheet? = null + // Keep the model's name on the uploaded file (engine always writes output.gcode). private fun deriveUploadName(title: String?): String { val base = (title ?: "").trim() @@ -331,251 +331,78 @@ class HelixGcodePreviewActivity : Activity() { // ---------- Print Preprocessing dialog ---------- private fun showPrintPreprocessDialog() { - HelixThemedDialog.showFloatingCenter( - activity = this, - accent = accentColor, - title = "Print Preprocessing", - iconRes = R.drawable.ic_print, - content = buildPreprocessContent(), - secondaryLabel = "Cancel", - primaryLabel = "Print", - onPrimary = { sendToPrinter(true) }, - ) - } - - private fun buildPreprocessContent(): View { - fun dp(v: Int) = (v * resources.displayMetrics.density).toInt() - - val root = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL + val printers = HelixPrinterStore.read(this) + if (moonrakerUrl.isBlank()) { + moonrakerUrl = printers.firstOrNull { it.url.isNotBlank() || it.tailscaleUrl.isNotBlank() } + ?.let { it.url.ifBlank { it.tailscaleUrl } } ?: "" } - fun sectionTitle(text: String) = TextView(this).apply { - this.text = text - textSize = 13f - setTextColor(HelixAppTheme.TEXT) - typeface = android.graphics.Typeface.DEFAULT_BOLD - setPadding(0, dp(12), 0, dp(6)) + val slotColors = runCatching { FilamentSlotColors.read(this) }.getOrDefault(emptyList()) + val slotDetails = runCatching { FilamentSlotDetails.read(this) }.getOrDefault(emptyList()) + val lanes = (0..3).map { slot -> + val loaded = loadedToolMask < 0 || (loadedToolMask and (1 shl slot)) != 0 + val detail = slotDetails.getOrNull(slot) + PreprocessRouting.Lane( + index = slot, + color = parseHex(slotColors.getOrNull(slot) ?: "#30343A"), + brand = detail?.brand ?: "", + material = detail?.material ?: "PLA", + mainType = detail?.mainType ?: "", + subType = detail?.subType ?: "", + status = if (!loaded) "empty" else (detail?.status?.ifBlank { null } ?: "loaded"), + ) } - // Model information ------------------------------------------------------ - root.addView(sectionTitle("Model Information")) - root.addView(TextView(this).apply { - val mins = (LastSliceStore.estimatedTimeSeconds / 60f).toInt() - val grams = LastSliceStore.estimatedFilamentGrams - // Effective config the ENGINE actually sliced with (echoed in the gcode - // footer) — proves whether profile overrides like ironing really landed. - val ironing = findGcodeConfigLine(Regex("""ironing_type\s*=\s*(.+)""", RegexOption.IGNORE_CASE)) - val infillPat = findGcodeConfigLine(Regex("""sparse_infill_pattern\s*=\s*(.+)""", RegexOption.IGNORE_CASE)) - val cfgBits = listOfNotNull( - infillPat?.let { "infill $it" }, - ironing?.let { "ironing $it" }, - ).joinToString(" · ") - text = "$uploadName\n${mins} min · ${String.format("%.1f", grams)} g · ${LastSliceStore.totalLayers} layers" + - if (cfgBits.isNotEmpty()) "\n$cfgBits" else "" - textSize = 12f - setTextColor(HelixAppTheme.SUBTEXT) - }) + val required = (0..3).filter { (requiredToolMask() and (1 shl it)) != 0 } + .ifEmpty { listOf(initialTool.coerceIn(0, 3)) } - // Printer — tap to send this print to any printer saved in Helix --------- - val printers = HelixPrinterStore.read(this) - if (printers.isNotEmpty()) { - root.addView(sectionTitle("Printer")) - val printerRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(12), dp(10), dp(12), dp(10)) - background = GradientDrawable().apply { - cornerRadius = dp(10).toFloat() - setColor(HelixAppTheme.CARD) - setStroke(dp(1), HelixAppTheme.BORDER) - } - isClickable = true - } - if (moonrakerUrl.isBlank()) moonrakerUrl = printers.first().url - - // Match the connected URL to a saved printer — exact first, then by host - // (LAN vs Tailscale URLs share a printer entry). - fun hostOf(u: String) = u.substringAfter("://").substringBefore('/').substringBefore(':').lowercase() - fun nameFor(url: String): String = - printers.firstOrNull { it.url.trimEnd('/') == url.trimEnd('/') }?.name - ?: printers.firstOrNull { hostOf(it.url) == hostOf(url) }?.name - ?: hostOf(url).ifBlank { "Printer" } - - val printerName = TextView(this).apply { - text = nameFor(moonrakerUrl) - textSize = 13f - setTextColor(HelixAppTheme.TEXT) - typeface = android.graphics.Typeface.DEFAULT_BOLD - } - val printerStatus = TextView(this).apply { - text = "Checking…" - textSize = 11f - setTextColor(HelixAppTheme.SUBTEXT) - } - fun refreshPrinterStatus() { - printerStatus.text = "Checking…" - printerStatus.setTextColor(HelixAppTheme.SUBTEXT) - fetchPrinterState(moonrakerUrl) { state, color -> + val initialPrefs = mutableSetOf().apply { + if (prefAutoLevel) add(PreprocessRouting.Pref.AUTO_LEVEL) + if (prefFlowCal) add(PreprocessRouting.Pref.FLOW_CAL) + if (prefTimelapse) add(PreprocessRouting.Pref.TIMELAPSE) + } + + val config = HelixPreprocessSheet.Config( + fileName = uploadName, + estTimeSeconds = LastSliceStore.estimatedTimeSeconds.toFloat(), + estGrams = LastSliceStore.estimatedFilamentGrams.toFloat(), + layers = LastSliceStore.totalLayers, + thumbnail = runCatching { GcodeThumbnailReader.readBitmap(gcodePath) }.getOrNull(), + lanes = lanes, + required = required, + perToolGrams = parsePerToolGrams(), + printers = printers, + activePrinterUrl = moonrakerUrl, + initialPrefs = initialPrefs, + sendLabel = "Hold to start", + probePrinter = { url, cb -> + fetchPrinterState(url) { state, color, workingUrl -> runOnUiThread { - printerStatus.text = state - printerStatus.setTextColor(color) + if (workingUrl.isNotBlank()) moonrakerUrl = workingUrl + cb(HelixPreprocessSheet.PrinterState( + label = state, + reachable = workingUrl.isNotBlank(), + busy = state == "Printing" || state == "Paused", + meshProfile = null, + )) } } - } - refreshPrinterStatus() - - val nameCol = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - addView(printerName) - addView(printerStatus) - } - printerRow.addView(ImageView(this).apply { - setImageResource(R.drawable.ic_print) - imageTintList = ColorStateList.valueOf(accentColor) - layoutParams = LinearLayout.LayoutParams(dp(18), dp(18)).apply { marginEnd = dp(10) } - }) - printerRow.addView(nameCol) - printerRow.addView(TextView(this).apply { - text = "▾" - textSize = 13f - setTextColor(HelixAppTheme.SUBTEXT) - }) - printerRow.setOnClickListener { - showPrinterPicker(printerRow, printers) { picked -> - moonrakerUrl = picked.url - printerName.text = picked.name - refreshPrinterStatus() - } - } - root.addView(printerRow, LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT, - )) - } - - // Filament — one card per tool the print ACTUALLY uses. Circle + card tint = - // the model's colour for that tool; grams/types parsed from the gcode; load - // state from the printer's tool mask. Row centres itself for 1–4 cards. - root.addView(sectionTitle("Filament")) - val colours = runCatching { GcodeFilamentColors.resolve(this, gcodePath, modelPath.ifBlank { null }) } - .getOrDefault(emptyList()) - val grams = parsePerToolGrams() - val types = parseFilamentTypes() - val usedTools = (0..3).filter { (requiredToolMask() and (1 shl it)) != 0 } - .ifEmpty { listOf(initialTool.coerceIn(0, 3)) } + }, + onPrinterPicked = { printer -> moonrakerUrl = printer.url.ifBlank { printer.tailscaleUrl } }, + onSend = ::startPrint, + ) - val filRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_HORIZONTAL - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT, - ) - } + preprocessSheet = HelixPreprocessSheet(this, config).also { it.show() } + } - // Fit all used tools inside the dialog: shrink card + circle when 4-up. - val dialogContentW = minOf(dp(380), (resources.displayMetrics.widthPixels * 0.92f).toInt()) - dp(32) - val cardW = minOf(dp(88), dialogContentW / usedTools.size - dp(8)) - val circleD = (cardW * 0.55f).toInt().coerceAtMost(dp(48)) - usedTools.forEachIndexed { pos, tool -> - val hex = colours.getOrNull(tool) ?: colours.getOrNull(pos) ?: "#30343A" - val colour = parseHex(hex) - val card = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - setPadding(dp(10), dp(12), dp(10), dp(12)) - background = GradientDrawable().apply { - cornerRadius = dp(12).toFloat() - // Card bathes in the model's colour, dimmed so text stays readable. - setColor((colour and 0x00FFFFFF) or 0x2E000000) - setStroke(dp(1), (colour and 0x00FFFFFF) or 0x66000000) - } - } - val circleBg = GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(colour) - setStroke(dp(1), 0x55FFFFFF.toInt()) - } - card.addView(View(this).apply { - background = circleBg - layoutParams = LinearLayout.LayoutParams(circleD, circleD) - }) - card.addView(TextView(this).apply { - text = "T$tool" - textSize = 15f - gravity = Gravity.CENTER - setTextColor(HelixAppTheme.TEXT) - typeface = android.graphics.Typeface.DEFAULT_BOLD - setPadding(0, dp(6), 0, 0) - }) - card.addView(TextView(this).apply { - text = types.getOrNull(tool)?.ifBlank { null } ?: types.getOrNull(pos)?.ifBlank { null } ?: "PLA" - textSize = 11f - maxLines = 1 - gravity = Gravity.CENTER - setTextColor(HelixAppTheme.TEXT) - }) - val g = grams.getOrNull(tool) ?: grams.getOrNull(pos) - val gramsTxt = if (g != null && g > 0) " · ${String.format("%.1f", g)}g" else "" - val statusView = TextView(this).apply { - textSize = 10f - gravity = Gravity.CENTER - setPadding(0, dp(2), 0, 0) - } - fun refreshStatus() { - val slot = toolSlotMap[tool] - val slotLoaded = loadedToolMask < 0 || (loadedToolMask and (1 shl slot)) != 0 - val slotTxt = if (slot != tool) "→ T$slot" else if (slotLoaded) "Loaded" else "Empty" - statusView.text = slotTxt + gramsTxt - statusView.setTextColor(if (slotLoaded) HelixAppTheme.SUBTEXT else 0xFFCF6679.toInt()) - // Circle shows what will ACTUALLY print: the chosen slot's filament - // colour when remapped, the model's colour on the default slot. - val circleColour = if (slot != tool) { - parseHex(FilamentSlotColors.read(this).getOrNull(slot) ?: "#30343A") - } else { - colour - } - circleBg.setColor(circleColour) - } - refreshStatus() - card.addView(statusView) - // Tap → pick which machine slot supplies this colour. - card.isClickable = true - card.setOnClickListener { showSlotPicker(card, tool) { refreshStatus() } } - filRow.addView(card, LinearLayout.LayoutParams(cardW, LinearLayout.LayoutParams.WRAP_CONTENT).apply { - setMargins(dp(4), 0, dp(4), 0) - }) - } - root.addView(filRow) - - // Print preferences ------------------------------------------------------ - root.addView(sectionTitle("Print Preferences")) - fun prefRow(label: String, initial: Boolean, onChange: (Boolean) -> Unit) { - val rowView = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, dp(4), 0, dp(4)) - } - rowView.addView(TextView(this).apply { - text = label - textSize = 13f - setTextColor(HelixAppTheme.TEXT) - layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - }) - rowView.addView(CheckBox(this).apply { - isChecked = initial - buttonTintList = ColorStateList.valueOf(accentColor) - setOnCheckedChangeListener { _, checked -> onChange(checked) } - }) - root.addView(rowView) + private fun startPrint(assignments: Map, prefs: Set) { + for ((tool, lane) in assignments) { + if (tool in 0..3 && lane in 0..3) toolSlotMap[tool] = lane } - prefRow("Extrusion Flow Calibration", prefFlowCal) { prefFlowCal = it } - prefRow("Time-lapse Camera", prefTimelapse) { prefTimelapse = it } - prefRow("Auto Leveling", prefAutoLevel) { prefAutoLevel = it } - - return root + prefAutoLevel = prefs.contains(PreprocessRouting.Pref.AUTO_LEVEL) + prefFlowCal = prefs.contains(PreprocessRouting.Pref.FLOW_CAL) + prefTimelapse = prefs.contains(PreprocessRouting.Pref.TIMELAPSE) + sendToPrinter(true) } private fun parseHex(hex: String): Int = try { @@ -584,168 +411,78 @@ class HelixGcodePreviewActivity : Activity() { HelixAppTheme.CARD_ALT } - /** Dropdown listing the printers saved in Helix; picking one retargets the send. */ - private fun showPrinterPicker( - anchor: View, - printers: List, - onPicked: (HelixPrinterStore.Printer) -> Unit, - ) { - fun dp(v: Int) = (v * resources.displayMetrics.density).toInt() - val list = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - background = GradientDrawable().apply { - cornerRadius = dp(12).toFloat() - setColor(HelixAppTheme.CARD) - setStroke(dp(1), HelixAppTheme.BORDER) - } - setPadding(dp(6), dp(6), dp(6), dp(6)) + /** Host (no scheme/path/port) for matching LAN ↔ Tailscale urls of one printer. */ + private fun hostOf(u: String): String = + u.substringAfter("://").substringBefore('/').substringBefore(':').lowercase() + + /** + * Moonraker endpoints worth trying for [primaryUrl], best-first: + * the given url, then the matching saved printer's alternate endpoint + * (LAN ↔ Tailscale), then every other saved endpoint as a last resort. + * Lets the dialog reach a printer the user left running when away from home. + */ + private fun resolveCandidates(primaryUrl: String): List { + val out = LinkedHashSet() + val p = primaryUrl.trim().trimEnd('/') + if (p.isNotEmpty()) out.add(p) + val printers = HelixPrinterStore.read(this) + val matched = if (p.isNotEmpty()) printers.firstOrNull { + it.url.trimEnd('/') == p || + it.tailscaleUrl.trimEnd('/') == p || + (hostOf(p).isNotEmpty() && (hostOf(it.url) == hostOf(p) || hostOf(it.tailscaleUrl) == hostOf(p))) + } else null + matched?.let { + if (it.url.isNotBlank()) out.add(it.url.trimEnd('/')) + if (it.tailscaleUrl.isNotBlank()) out.add(it.tailscaleUrl.trimEnd('/')) } - val popup = android.widget.PopupWindow( - list, dp(240), LinearLayout.LayoutParams.WRAP_CONTENT, true, - ) - printers.forEach { printer -> - val active = printer.url.trimEnd('/') == moonrakerUrl.trimEnd('/') - val row = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(12), dp(9), dp(12), dp(9)) - if (active) { - background = GradientDrawable().apply { - cornerRadius = dp(8).toFloat() - setColor((accentColor and 0x00FFFFFF) or 0x26000000) - } - } - isClickable = true - setOnClickListener { - onPicked(printer) - popup.dismiss() - } - } - row.addView(TextView(this).apply { - text = printer.name - textSize = 13f - setTextColor(if (active) accentColor else HelixAppTheme.TEXT) - typeface = android.graphics.Typeface.DEFAULT_BOLD - }) - val statusLabel = TextView(this).apply { - text = "Checking…" - textSize = 11f - setTextColor(HelixAppTheme.SUBTEXT) - } - row.addView(statusLabel) - list.addView(row) - fetchPrinterState(printer.url) { state, color -> - runOnUiThread { - statusLabel.text = state - statusLabel.setTextColor(color) - } - } + printers.forEach { + if (it.url.isNotBlank()) out.add(it.url.trimEnd('/')) + if (it.tailscaleUrl.isNotBlank()) out.add(it.tailscaleUrl.trimEnd('/')) } - popup.elevation = dp(8).toFloat() - popup.showAsDropDown(anchor, 0, dp(4)) - } - - /** Quick Moonraker print_stats poll → "Idle" / "Printing" / "Paused" / "Offline". */ - private fun fetchPrinterState(url: String, onResult: (String, Int) -> Unit) { - Thread { - try { - val client = OkHttpClient.Builder() - .connectTimeout(3, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(3, java.util.concurrent.TimeUnit.SECONDS) - .build() - val req = Request.Builder() - .url("${url.trimEnd('/')}/printer/objects/query?print_stats") - .get().build() - client.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) throw IllegalStateException("HTTP ${resp.code}") - val body = resp.body?.string().orEmpty() - val state = org.json.JSONObject(body) - .optJSONObject("result")?.optJSONObject("status") - ?.optJSONObject("print_stats")?.optString("state") ?: "unknown" - when (state) { - "printing" -> onResult("Printing", accentColor) - "paused" -> onResult("Paused", 0xFFF5B45A.toInt()) - "complete", "standby", "ready", "cancelled", "idle" -> onResult("Idle", 0xFF6BCB77.toInt()) - "error" -> onResult("Error", 0xFFCF6679.toInt()) - else -> onResult("Idle", 0xFF6BCB77.toInt()) - } - } - } catch (_: Throwable) { - onResult("Offline", 0xFFCF6679.toInt()) - } - }.start() + return out.toList() } /** - * Dropdown under a filament card: the machine's four slots (colour + loaded - * state, from the Slice tab's slot settings + printer mask). Picking one maps - * this print colour onto that physical slot at send time. + * Quick Moonraker print_stats poll with LAN→Tailscale failover. Tries each + * candidate; the first to answer wins and onResult receives (state, color, + * workingUrl). "Offline" only if every candidate is unreachable — which is + * the real fix for "printer shows off when the user is away from home". */ - private fun showSlotPicker(anchor: View, tool: Int, onPicked: () -> Unit) { - fun dp(v: Int) = (v * resources.displayMetrics.density).toInt() - val slotColors = FilamentSlotColors.read(this) - - val list = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - background = GradientDrawable().apply { - cornerRadius = dp(12).toFloat() - setColor(HelixAppTheme.CARD) - setStroke(dp(1), HelixAppTheme.BORDER) - } - setPadding(dp(6), dp(6), dp(6), dp(6)) - } - - val popup = android.widget.PopupWindow( - list, - dp(170), - LinearLayout.LayoutParams.WRAP_CONTENT, - true, - ) - - for (slot in 0..3) { - val slotLoaded = loadedToolMask < 0 || (loadedToolMask and (1 shl slot)) != 0 - val row = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(10), dp(9), dp(10), dp(9)) - isClickable = true - if (toolSlotMap[tool] == slot) { - background = GradientDrawable().apply { - cornerRadius = dp(8).toFloat() - setColor((accentColor and 0x00FFFFFF) or 0x26000000) + private fun fetchPrinterState(primaryUrl: String, onResult: (String, Int, String) -> Unit) { + val candidates = resolveCandidates(primaryUrl) + Thread { + val client = OkHttpClient.Builder() + .connectTimeout(3, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(3, java.util.concurrent.TimeUnit.SECONDS) + .build() + var resolved = false + for (url in candidates) { + if (resolved) break + try { + val req = Request.Builder() + .url("$url/printer/objects/query?print_stats") + .get().build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@use // try next candidate + val body = resp.body?.string().orEmpty() + val state = org.json.JSONObject(body) + .optJSONObject("result")?.optJSONObject("status") + ?.optJSONObject("print_stats")?.optString("state") ?: "unknown" + val (label, color) = when (state) { + "printing" -> "Printing" to accentColor + "paused" -> "Paused" to 0xFFF5B45A.toInt() + "error" -> "Error" to 0xFFCF6679.toInt() + else -> "Idle" to 0xFF6BCB77.toInt() + } + resolved = true + onResult(label, color, url) } - } - setOnClickListener { - toolSlotMap[tool] = slot - onPicked() - popup.dismiss() + } catch (_: Throwable) { + // this candidate unreachable — continue to the next } } - row.addView(View(this).apply { - background = GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(parseHex(slotColors.getOrNull(slot) ?: "#30343A")) - setStroke(dp(1), 0x55FFFFFF.toInt()) - } - layoutParams = LinearLayout.LayoutParams(dp(20), dp(20)) - }) - row.addView(TextView(this).apply { - text = "T$slot" - textSize = 13f - setTextColor(HelixAppTheme.TEXT) - typeface = android.graphics.Typeface.DEFAULT_BOLD - setPadding(dp(10), 0, 0, 0) - layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - }) - row.addView(TextView(this).apply { - text = if (slotLoaded) "Loaded" else "Empty" - textSize = 11f - setTextColor(if (slotLoaded) HelixAppTheme.SUBTEXT else 0xFFCF6679.toInt()) - }) - list.addView(row) - } - - popup.elevation = dp(8).toFloat() - popup.showAsDropDown(anchor, 0, dp(4)) + if (!resolved) onResult("Offline", 0xFFCF6679.toInt(), "") + }.start() } /** Per-tool grams from the gcode footer (`; filament used [g] = a, b, c`). */ @@ -792,10 +529,14 @@ class HelixGcodePreviewActivity : Activity() { } } + private fun reportProgress(message: String, fraction: Float) { + setSendStatus(message) + preprocessSheet?.onSendProgress(message, fraction) + } + private fun sendToPrinter(alsoPrint: Boolean) { if (sending) return - val base = moonrakerUrl.trimEnd('/') - if (base.isBlank()) { setSendStatus("No printer connected in Helix."); return } + if (moonrakerUrl.isBlank()) { setSendStatus("No printer connected in Helix."); return } if (!File(gcodePath).exists()) { setSendStatus("G-code file is missing."); return } if (alsoPrint) { val missing = missingLoadedTools() @@ -809,13 +550,33 @@ class HelixGcodePreviewActivity : Activity() { val requestedFlowCalibration = alsoPrint && prefFlowCal val requestedPhysicalExtruders = physicalUsedExtruders() sending = true - setSendStatus(if (requestedTimelapse) "Preparing timelapse..." else "Uploading $uploadName...") + reportProgress(if (requestedTimelapse) "Preparing timelapse..." else "Uploading $uploadName...", 0.02f) Thread { try { + // Resolve a reachable Moonraker base (LAN → Tailscale failover) before + // upload — the status card may have probed LAN and failed when the user + // is away from home. Pick the first candidate that answers a print_stats + // probe; if none do, the printer really is offline. + val probeClient = OkHttpClient.Builder() + .connectTimeout(4, TimeUnit.SECONDS) + .readTimeout(4, TimeUnit.SECONDS) + .build() + val base = resolveCandidates(moonrakerUrl).firstOrNull { cand -> + try { + probeClient.newCall( + Request.Builder().url("$cand/printer/objects/query?print_stats").get().build(), + ).execute().use { it.isSuccessful } + } catch (_: Throwable) { false } + } + if (base == null) { + setSendStatus("No printer connected in Helix.") + return@Thread + } + moonrakerUrl = base var file = remappedGcodeFile() if (requestedTimelapse) { file = GcodeTimelapseInjector.inject(file.absolutePath, cacheDir) - setSendStatus("Uploading $uploadName...") + reportProgress("Uploading $uploadName...", 0.05f) } // Default OkHttp timeouts are 10s — a multi-MB gcode over WiFi/Tailscale // needs the same size-scaled window HelixSlicerModule.uploadGcode uses. @@ -828,14 +589,20 @@ class HelixGcodePreviewActivity : Activity() { .build() val body = MultipartBody.Builder().setType(MultipartBody.FORM) .addFormDataPart("root", "gcodes") - .addFormDataPart("file", uploadName, file.asRequestBody("text/plain".toMediaType())) + .addFormDataPart( + "file", + uploadName, + ProgressRequestBody(file, "text/plain".toMediaTypeOrNull()) { frac -> + reportProgress("Uploading $uploadName...", 0.05f + frac * 0.80f) + }, + ) .build() client.newCall(Request.Builder().url("$base/server/files/upload").post(body).build()) .execute().use { resp -> if (!resp.isSuccessful) throw IllegalStateException("Upload HTTP ${resp.code}") } if (alsoPrint) { - setSendStatus("Applying print preferences...") + reportProgress("Applying print preferences...", 0.9f) applyPrintPreferences( client, base, @@ -844,7 +611,7 @@ class HelixGcodePreviewActivity : Activity() { requestedFlowCalibration, requestedPhysicalExtruders, ) - setSendStatus("Starting $uploadName...") + reportProgress("Starting $uploadName...", 0.96f) val enc = URLEncoder.encode(uploadName, "UTF-8") client.newCall( Request.Builder().url("$base/printer/print/start?filename=$enc") @@ -853,10 +620,14 @@ class HelixGcodePreviewActivity : Activity() { if (!resp.isSuccessful) throw IllegalStateException("Print start HTTP ${resp.code}") } } - setSendStatus(if (alsoPrint) "Sent — printing $uploadName" else "Uploaded $uploadName") + reportProgress(if (alsoPrint) "Sent — printing $uploadName" else "Uploaded $uploadName", 1f) + preprocessSheet?.dismiss() + preprocessSheet = null if (alsoPrint) returnToHomeWithPrintSuccess(uploadName) } catch (error: Throwable) { - setSendStatus("Send failed: ${error.message ?: error::class.java.simpleName}") + val failMsg = error.message ?: error::class.java.simpleName + setSendStatus("Send failed: $failMsg") + preprocessSheet?.onSendFailed(failMsg) } finally { sending = false } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixIcons.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixIcons.kt new file mode 100644 index 0000000..5daff02 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixIcons.kt @@ -0,0 +1,84 @@ +package org.crabcore.u1control.slicing + +import android.content.Context +import android.graphics.Typeface +import android.util.Log +import android.view.Gravity +import android.widget.TextView + +/** + * MaterialCommunityIcons glyphs for native views. + * + * Native screens draw the same glyphs as their RN counterparts by loading the + * font from assets. Every helper degrades to a blank view if that ever fails, so + * a missing font costs an icon rather than the screen. + */ +object HelixIcons { + // Codepoints from @expo/vector-icons' MaterialCommunityIcons glyphmap. + const val PRINTER_3D = 0xF042B + const val PRINTER_NOZZLE = 0xF0E5B + const val CHEVRON_DOWN = 0xF0140 + const val CHEVRON_LEFT = 0xF0141 + const val CHEVRON_RIGHT = 0xF0142 + const val CHECK = 0xF012C + const val CLOCK = 0xF0150 + const val CALENDAR_CLOCK = 0xF00F0 + const val WEIGHT_GRAM = 0xF0D3F + const val CHECK_CIRCLE = 0xF05E0 + const val ALERT_CIRCLE = 0xF05D6 + const val CLOSE_CIRCLE = 0xF0159 + const val ALERT = 0xF0026 + const val TUNE = 0xF1542 + const val WRENCH = 0xF0BE0 + const val PALETTE_SWATCH = 0xF08B5 + const val GRID = 0xF02C1 + const val VIDEO = 0xF0BDC + const val HELP_CIRCLE = 0xF0625 + const val ACCESS_POINT = 0xF0002 + + private const val TAG = "HelixIcons" + + /** + * Our own copy of the font the JS side draws icons with. The bundler also ships + * it, but release builds obfuscate resource names, so it cannot be looked up at + * runtime — this asset is the only path that holds in every variant. + */ + private const val ASSET_PATH = "fonts/MaterialCommunityIcons.ttf" + + @Volatile private var cached: Typeface? = null + @Volatile private var attempted = false + + /** The glyph as text. These live in a supplementary plane — surrogate pair. */ + fun glyph(codepoint: Int): String = String(Character.toChars(codepoint)) + + fun font(context: Context): Typeface? { + cached?.let { return it } + synchronized(this) { + if (!attempted) { + attempted = true + cached = load(context) + } + return cached + } + } + + /** A centred icon view, blank when the font could not be loaded. */ + fun view(context: Context, codepoint: Int, sizeSp: Float, color: Int): TextView { + val face = font(context) + return TextView(context).apply { + text = if (face == null) "" else glyph(codepoint) + typeface = face + textSize = sizeSp + setTextColor(color) + gravity = Gravity.CENTER + includeFontPadding = false + } + } + + private fun load(context: Context): Typeface? = try { + Typeface.createFromAsset(context.assets, ASSET_PATH) + } catch (error: Throwable) { + Log.w(TAG, "Icon font unavailable: ${error.message}") + null + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPreprocessSheet.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPreprocessSheet.kt new file mode 100644 index 0000000..41e8412 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPreprocessSheet.kt @@ -0,0 +1,1078 @@ +package org.crabcore.u1control.slicing + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.app.Activity +import android.app.Dialog +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.ScrollView +import android.widget.TextView +import java.util.Locale +import org.crabcore.u1control.R +import org.crabcore.u1control.slicing.HelixCockpit as P +import org.crabcore.u1control.slicing.PreprocessRouting.Check +import org.crabcore.u1control.slicing.PreprocessRouting.Lane +import org.crabcore.u1control.slicing.PreprocessRouting.Pref +import org.crabcore.u1control.slicing.PreprocessRouting.RouteSource +import org.crabcore.u1control.slicing.PreprocessRouting.Tone +import org.crabcore.u1control.slicing.PreprocessRouting.Tool + +/** + * Print preprocess — the Ticket sheet, in native views. + * + * A port of [components/PrintPreprocessDialog.tsx] so the native G-code preview's + * "Upload & Print" lands on the same screen as the RN slicer and Files tabs. By + * the time it opens the decision is already made, so it answers four things at a + * glance — what, where, how long, what it costs — folds routing and options away, + * and makes the commit deliberate with a press-and-hold. + * + * Routing and blocking live in [PreprocessRouting], shared with the RN sheet. + */ +class HelixPreprocessSheet( + private val activity: Activity, + private val config: Config, +) { + data class Config( + val fileName: String, + val estTimeSeconds: Float, + val estGrams: Float, + val layers: Int, + val thumbnail: Bitmap?, + /** The machine's four physical lanes. */ + val lanes: List, + /** File tools the gcode asks for. */ + val required: List, + val perToolGrams: List, + val printers: List, + val activePrinterUrl: String, + val initialPrefs: Set, + val sendLabel: String = "Hold to start", + /** Reports reachability, busy state and bed mesh for a printer, on the UI thread. */ + val probePrinter: (String, (PrinterState) -> Unit) -> Unit, + val onPrinterPicked: (HelixPrinterStore.Printer) -> Unit, + /** file tool → physical lane, plus the chosen print preferences. */ + val onSend: (Map, Set) -> Unit, + ) + + data class PrinterState( + val label: String, + val reachable: Boolean, + val busy: Boolean, + val meshProfile: String?, + ) + + private enum class Fold { ROUTING, OPTIONS } + + private val density = activity.resources.displayMetrics.density + private val manual = mutableMapOf() + private val prefs = config.initialPrefs.toMutableSet() + + private var openFold: Fold? = null + private var printerUrl = config.activePrinterUrl + private var printerState: PrinterState? = null + private var errorMessage: String? = null + private var sending = false + + private val dialog = Dialog(activity) + private lateinit var root: FrameLayout + private lateinit var sheet: LinearLayout + private lateinit var body: LinearLayout + private lateinit var footer: FrameLayout + private var layer: View? = null + private var overlay: View? = null + private var statusLabel: TextView? = null + private var progressBar: ProgressBar? = null + private var progressPct: TextView? = null + + // ---------- view helpers ---------- + + private fun dp(value: Int): Int = (value * density).toInt() + private fun dpf(value: Float): Float = value * density + + private fun rounded(fill: Int, radiusDp: Float, stroke: Int? = null, strokeDp: Int = 1) = + GradientDrawable().apply { + cornerRadius = dpf(radiusDp) + setColor(fill) + stroke?.let { setStroke(dp(strokeDp), it) } + } + + /** Rounded on top only, like a sheet rising from the bottom edge. */ + private fun sheetBackground(radiusDp: Int) = GradientDrawable().apply { + val r = dpf(radiusDp.toFloat()) + cornerRadii = floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f) + setColor(P.BG) + setStroke(dp(1), P.BORDER) + } + + private fun oval(fill: Int, stroke: Int? = null, strokeDp: Float = 1f) = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(fill) + stroke?.let { setStroke(dpf(strokeDp).toInt(), it) } + } + + private fun text( + value: String, + sizeSp: Float, + color: Int, + bold: Boolean = false, + tracking: Float = 0f, + ) = TextView(activity).apply { + text = value + textSize = sizeSp + setTextColor(color) + if (bold) typeface = Typeface.DEFAULT_BOLD + if (tracking != 0f) letterSpacing = tracking + includeFontPadding = false + } + + /** The 4-toolhead whole-printer glyph. Multi-tone — never tint it. */ + private fun printerIconView() = ImageView(activity).apply { + setImageResource(R.drawable.ic_printer) + scaleType = ImageView.ScaleType.FIT_CENTER + } + + private fun row() = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + // Nothing here aligns on a text baseline, and baseline alignment measures + // weighted nested columns at zero width, which they never recover from. + isBaselineAligned = false + } + + private fun column() = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL } + + private fun fullWidth(height: Int = LinearLayout.LayoutParams.WRAP_CONTENT) = + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, height) + + private fun weighted(weight: Float = 1f) = + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, weight) + + /** + * Adds [child] with RN-style `gap` spacing ahead of it (skipped for the first). + * A child that already carries LayoutParams keeps them — sizes set by the + * builders must survive, only the margin is layered on. + */ + private fun LinearLayout.addSpaced( + child: View, + gapDp: Int, + params: LinearLayout.LayoutParams? = null, + ) { + val lp = params + ?: child.layoutParams as? LinearLayout.LayoutParams + ?: LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + if (childCount > 0 && gapDp > 0) { + if (orientation == LinearLayout.HORIZONTAL) lp.marginStart = dp(gapDp) + else lp.topMargin = dp(gapDp) + } + addView(child, lp) + } + + /** ScrollView with a hard height cap — the RN sheet's `maxHeight`. */ + private class CappedScrollView(context: Context, private val cap: Int) : ScrollView(context) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(cap, MeasureSpec.AT_MOST)) + } + } + + // ---------- model ---------- + + /** Always four addressable lanes, so routing can speak in T0–T3. */ + private val displayLanes: List = List(4) { index -> + config.lanes.firstOrNull { it.index == index } + ?: Lane(index = index, color = P.SURFACE_ALT, status = "empty") + } + + private val required: List = + config.required.distinct().sorted().ifEmpty { listOf(0) } + + private fun tools(): List = + PreprocessRouting.buildTools(required, displayLanes, manual, config.perToolGrams) + + private fun checks(tools: List): List = PreprocessRouting.buildChecks( + connected = printerState?.reachable ?: true, + printerBusy = printerState?.busy ?: false, + printerName = printerName(printerUrl), + tools = tools, + lanes = displayLanes, + ) + + /** Saved printer matching [url] — exact first, then by host, since LAN and + * Tailscale URLs share one entry. */ + private fun printerName(url: String): String { + fun host(value: String) = + value.substringAfter("://").substringBefore('/').substringBefore(':').lowercase() + return config.printers.firstOrNull { it.url.trimEnd('/') == url.trimEnd('/') }?.name + ?: config.printers.firstOrNull { host(it.url) == host(url) }?.name + ?: host(url).ifBlank { "Printer" } + } + + private fun toneColor(tone: Tone) = when (tone) { + Tone.PASS -> P.SUCCESS + Tone.WARN -> P.WARN + Tone.FAIL -> P.DANGER + } + + private fun toneIcon(tone: Tone) = when (tone) { + Tone.PASS -> HelixIcons.CHECK_CIRCLE + Tone.WARN -> HelixIcons.ALERT_CIRCLE + Tone.FAIL -> HelixIcons.CLOSE_CIRCLE + } + + /** RN `formatDuration` from components/PrintProgress. */ + private fun formatDuration(totalSeconds: Float): String { + if (!totalSeconds.isFinite() || totalSeconds < 0f) return "--" + val seconds = Math.round(totalSeconds) + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + if (hours > 0) return "${hours}h ${minutes}m" + val rest = seconds % 60 + return if (minutes > 0) "${minutes}m ${rest}s" else "${rest}s" + } + + private fun grams(value: Double) = String.format(Locale.US, "%.1f", value) + + // ---------- lifecycle ---------- + + fun show() { + root = FrameLayout(activity).apply { + setBackgroundColor(P.SCRIM) + setOnClickListener { requestClose() } + } + + body = column().apply { setPadding(0, dp(16), 0, dp(14)) } + + // 88% of the screen for the whole sheet, as in the RN `maxHeight`; the footer + // and grabber sit outside the scroll, so the scroll itself gets a bit less. + val scroll = CappedScrollView( + activity, + (activity.resources.displayMetrics.heightPixels * 0.7f).toInt(), + ).apply { + isVerticalScrollBarEnabled = false + addView(body, fullWidth()) + } + + footer = FrameLayout(activity) + + sheet = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + isClickable = true + background = sheetBackground(P.RADIUS + 10) + setPadding(dp(16), 0, dp(16), dp(12)) + addView(grabber()) + addView(scroll, fullWidth()) + addView(footer, fullWidth()) + } + EdgeInsets.applyBottom(sheet) + + root.addView( + sheet, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM, + ), + ) + + dialog.apply { + setContentView(root) + window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + // Back closes the top layer first, and never interrupts a send. + setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + requestClose() + true + } else { + false + } + } + show() + } + + render() + probe(printerUrl) + } + + fun dismiss() = dialog.dismiss() + + private fun requestClose() { + if (sending) return + if (layer != null) closeLayer() else dialog.dismiss() + } + + /** Drives the sending overlay from the activity's upload thread. */ + fun onSendProgress(message: String, progress: Float) = activity.runOnUiThread { + statusLabel?.text = message + val pct = (progress.coerceIn(0f, 1f) * 100f).toInt() + progressBar?.progress = pct + progressPct?.text = "$pct%" + } + + fun onSendFailed(message: String) = activity.runOnUiThread { + sending = false + errorMessage = message + overlay?.let { root.removeView(it) } + overlay = null + statusLabel = null + progressBar = null + progressPct = null + render() + } + + private fun probe(url: String) { + if (url.isBlank()) { + printerState = PrinterState("No printer", reachable = false, busy = false, meshProfile = null) + render() + return + } + config.probePrinter(url) { state -> + // Ignore a reply for a printer the user has since switched away from. + if (url == printerUrl) { + printerState = state + render() + } + } + } + + // ---------- render ---------- + + private fun render() { + body.removeAllViews() + footer.removeAllViews() + + val tools = tools() + val checks = checks(tools) + val failing = checks.filter { it.tone == Tone.FAIL && it.blocking } + val blocked = failing.isNotEmpty() + val notes = checks.filter { it.tone == Tone.WARN || it.tone == Tone.FAIL } + val rerouted = tools.filter { it.source == RouteSource.AUTO } + val routingOpen = openFold == Fold.ROUTING || blocked + + body.addSpaced(hero(), 0, fullWidth()) + body.addSpaced(statBand(tools), 11, fullWidth()) + body.addSpaced( + if (notes.isNotEmpty()) notice(notes, blocked) else cleanRow(rerouted), + 11, + fullWidth(), + ) + errorMessage?.let { body.addSpaced(text(it, 12f, P.DANGER, bold = true), 11, fullWidth()) } + + body.addSpaced(routingHead(tools, routingOpen, blocked), 11, fullWidth(dp(52))) + if (routingOpen) body.addSpaced(routingBody(tools), 4, fullWidth()) + + body.addSpaced(optionsHead(), 11, fullWidth(dp(52))) + if (openFold == Fold.OPTIONS) body.addSpaced(optionsBody(), 4, fullWidth()) + + footer.addView( + if (blocked) fixBar(failing.first().detail, tools) else holdButton(), + FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, dp(56)), + ) + } + + private fun grabber() = View(activity).apply { + background = rounded(P.BORDER, 2f) + layoutParams = LinearLayout.LayoutParams(dp(40), dp(4)).apply { + gravity = Gravity.CENTER_HORIZONTAL + topMargin = dp(10) + } + } + + private fun hero(): View { + val hero = row() + + val thumb = config.thumbnail + hero.addSpaced( + if (thumb != null) { + ImageView(activity).apply { + setImageBitmap(thumb) + scaleType = ImageView.ScaleType.FIT_CENTER + background = rounded(P.SURFACE, 16f) + clipToOutline = true + } + } else { + FrameLayout(activity).apply { background = rounded(P.SURFACE_ALT, 16f) } + }, + 0, + LinearLayout.LayoutParams(dp(72), dp(72)), + ) + + val name = config.fileName + .substringAfterLast('/') + .substringAfterLast('\\') + .replace(Regex("""\.gcode$""", RegexOption.IGNORE_CASE), "") + + val titles = column() + titles.addSpaced( + text(name, 21f, P.TEXT, bold = true, tracking = -0.026f).apply { maxLines = 2 }, + 0, + fullWidth(), + ) + titles.addSpaced(printerPill(), 7) + hero.addSpaced(titles, 14, weighted()) + return hero + } + + private fun printerPill(): View { + val pill = row().apply { + background = rounded(P.SURFACE, 999f, P.BORDER) + setPadding(dp(9), 0, dp(9), 0) + isClickable = config.printers.isNotEmpty() + setOnClickListener { if (config.printers.isNotEmpty()) openPrinterLayer() } + } + pill.addSpaced( + printerIconView().apply { + layoutParams = LinearLayout.LayoutParams(dp(13), dp(13)) + }, + 0, + ) + pill.addSpaced(text(printerName(printerUrl), 12f, P.TEXT, bold = true).apply { maxLines = 1 }, 5) + pill.addSpaced(HelixIcons.view(activity, HelixIcons.CHEVRON_DOWN, 15f, P.DIM), 5) + pill.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dp(28)) + return pill + } + + private fun statBand(tools: List): View { + val band = row().apply { + background = rounded(P.SURFACE, (P.RADIUS - 4).toFloat(), P.BORDER) + setPadding(dp(14), dp(14), dp(14), dp(14)) + weightSum = 3f + } + val shown = + if (config.estGrams > 0f) config.estGrams.toDouble() else tools.sumOf { it.grams } + + fun divider() = View(activity).apply { + setBackgroundColor(P.BORDER) + layoutParams = LinearLayout.LayoutParams(1, dp(34)) + } + + band.addSpaced( + stat(HelixIcons.CLOCK, formatDuration(config.estTimeSeconds), "Duration", P.DIM, P.TEXT), + 0, + weighted(), + ) + band.addSpaced(divider(), 12) + band.addSpaced( + stat( + HelixIcons.CALENDAR_CLOCK, + PreprocessRouting.finishClock(activity, config.estTimeSeconds), + "Done by", + P.ACCENT, + P.ACCENT, + ), + 12, + weighted(), + ) + band.addSpaced(divider(), 12) + band.addSpaced( + stat( + HelixIcons.WEIGHT_GRAM, + "${grams(shown)} g", + if (config.layers > 0) "${config.layers} layers" else "Filament", + P.DIM, + P.TEXT, + ), + 12, + weighted(), + ) + return band + } + + private fun stat( + icon: Int, + value: String, + label: String, + iconColor: Int, + valueColor: Int, + ): View { + val cell = column() + cell.addSpaced( + HelixIcons.view(activity, icon, 15f, iconColor).apply { gravity = Gravity.START }, + 0, + ) + cell.addSpaced( + text(value, 17f, valueColor, bold = true, tracking = -0.023f).apply { maxLines = 1 }, + 3, + ) + cell.addSpaced( + text(label.uppercase(Locale.US), 10f, P.DIM, bold = true, tracking = 0.06f).apply { + maxLines = 1 + }, + 3, + ) + return cell + } + + private fun notice(notes: List, blocked: Boolean): View { + val tone = if (blocked) P.DANGER else P.WARN + val box = column().apply { + background = rounded(P.alpha(tone, 0.1f), (P.RADIUS - 6).toFloat(), P.alpha(tone, 0.5f)) + setPadding(dp(12), dp(12), dp(12), dp(12)) + } + notes.forEach { note -> + val line = row() + line.addSpaced(HelixIcons.view(activity, toneIcon(note.tone), 16f, toneColor(note.tone)), 0) + line.addSpaced(text(note.detail, 12.5f, P.TEXT, bold = true), 9, weighted()) + box.addSpaced(line, 8, fullWidth()) + } + return box + } + + private fun cleanRow(rerouted: List): View { + val message = if (rerouted.isNotEmpty()) { + "Routed around empty lanes — " + + rerouted.joinToString(", ") { "T${it.fileTool} on lane ${it.assigned + 1}" } + } else { + "Lanes loaded, printer idle" + } + val line = row().apply { setPadding(dp(2), 0, dp(2), 0) } + line.addSpaced(HelixIcons.view(activity, HelixIcons.CHECK_CIRCLE, 15f, P.SUCCESS), 0) + line.addSpaced(text(message, 12f, P.DIM), 8, weighted()) + return line + } + + private fun routingHead(tools: List, open: Boolean, blocked: Boolean): View { + val head = row().apply { + background = rounded(P.SURFACE, (P.RADIUS - 6).toFloat(), P.BORDER) + setPadding(dp(13), 0, dp(13), 0) + clipChildren = false + isClickable = !blocked + setOnClickListener { + openFold = if (open && !blocked) null else Fold.ROUTING + render() + } + } + + val chips = row().apply { clipChildren = false } + tools.forEach { chips.addSpaced(laneChip(it, 26), 4) } + head.addSpaced(chips, 0) + head.addSpaced( + text("${tools.size} ${if (tools.size == 1) "lane" else "lanes"}", 12.5f, P.TEXT, bold = true), + 10, + ) + head.addSpaced(weightBar(tools), 10, LinearLayout.LayoutParams(0, dp(6), 1f)) + head.addSpaced(chevron(open), 10) + return head + } + + private fun chevron(open: Boolean) = HelixIcons.view( + activity, + if (open) HelixIcons.CHEVRON_DOWN else HelixIcons.CHEVRON_RIGHT, + 18f, + P.DIM, + ) + + private fun weightBar(tools: List): View { + val total = tools.sumOf { maxOf(it.grams, 0.01) }.takeIf { it > 0.0 } ?: 1.0 + val bar = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + background = rounded(P.SURFACE_ALT, 3f) + clipToOutline = true + } + tools.forEach { tool -> + bar.addView( + View(activity).apply { + setBackgroundColor(if (tool.lane.isEmpty) P.alpha(P.DANGER, 0.5f) else tool.lane.color) + }, + LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.MATCH_PARENT, + (maxOf(tool.grams, 0.01) / total).toFloat(), + ), + ) + } + return bar + } + + private fun routingBody(tools: List): View { + val list = column().apply { + setPadding(0, dp(4), 0, 0) + clipChildren = false + } + tools.forEach { tool -> + val line = row().apply { + setPadding(dp(4), dp(9), dp(4), dp(9)) + clipChildren = false + isClickable = true + setOnClickListener { openLaneLayer(tool.fileTool) } + } + line.addSpaced(laneChip(tool, 34), 0) + + val heading = PreprocessRouting.laneLabel(tool.lane) + when (tool.source) { + RouteSource.MANUAL -> " → lane ${tool.assigned + 1}" + RouteSource.AUTO -> " auto → lane ${tool.assigned + 1}" + RouteSource.IDENTITY -> "" + } + val labels = column() + labels.addSpaced(text(heading, 14f, P.TEXT, bold = true), 0) + labels.addSpaced( + text("lane ${tool.assigned + 1} · ${PreprocessRouting.laneDetail(tool.lane)}", 11.5f, P.DIM), + 2, + ) + line.addSpaced(labels, 12, weighted()) + + if (tool.grams > 0) line.addSpaced(text("${grams(tool.grams)} g", 12f, P.DIM, bold = true), 12) + line.addSpaced(HelixIcons.view(activity, HelixIcons.CHEVRON_RIGHT, 17f, P.DIM), 12) + + list.addSpaced(line, 0, fullWidth()) + } + return list + } + + private fun optionsHead(): View { + val summary = Pref.values().filter { prefs.contains(it) } + .joinToString(", ") { it.label } + .ifBlank { "Print preferences" } + val open = openFold == Fold.OPTIONS + val head = row().apply { + background = rounded(P.SURFACE, (P.RADIUS - 6).toFloat(), P.BORDER) + setPadding(dp(13), 0, dp(13), 0) + isClickable = true + setOnClickListener { + openFold = if (open) null else Fold.OPTIONS + render() + } + } + head.addSpaced(HelixIcons.view(activity, HelixIcons.TUNE, 17f, P.DIM), 0) + head.addSpaced(text(summary, 12.5f, P.TEXT, bold = true).apply { maxLines = 1 }, 10, weighted()) + head.addSpaced(chevron(open), 10) + return head + } + + private fun optionsBody(): View { + val list = column().apply { setPadding(0, dp(4), 0, 0) } + Pref.values().forEach { pref -> + val on = prefs.contains(pref) + val line = row().apply { + setPadding(dp(4), dp(9), dp(4), dp(9)) + isClickable = true + setOnClickListener { + if (on) prefs.remove(pref) else prefs.add(pref) + render() + } + } + line.addSpaced(HelixIcons.view(activity, pref.icon, 18f, if (on) P.ACCENT else P.DIM), 0) + val labels = column() + labels.addSpaced(text(pref.label, 14f, P.TEXT, bold = true), 0) + labels.addSpaced(text(pref.hint, 11.5f, P.DIM), 2) + line.addSpaced(labels, 12, weighted()) + line.addSpaced(toggle(on), 12) + list.addSpaced(line, 0, fullWidth()) + } + return list + } + + private fun toggle(on: Boolean): View { + val track = FrameLayout(activity).apply { + background = rounded(if (on) P.ACCENT else P.SURFACE_ALT, 12f, if (on) P.ACCENT else P.BORDER) + setPadding(dp(2), dp(2), dp(2), dp(2)) + layoutParams = LinearLayout.LayoutParams(dp(42), dp(24)) + } + val edge = if (on) Gravity.END else Gravity.START + track.addView( + View(activity).apply { background = rounded(if (on) P.ON_ACCENT else P.DIM, 9f) }, + FrameLayout.LayoutParams(dp(18), dp(18), Gravity.CENTER_VERTICAL or edge), + ) + return track + } + + private fun laneChip(tool: Tool, sizeDp: Int): View { + val empty = tool.lane.isEmpty + val ring = when { + empty -> P.alpha(P.DANGER, 0.8f) + tool.source == RouteSource.MANUAL -> P.ACCENT + else -> tool.lane.color + } + val chip = FrameLayout(activity).apply { + clipChildren = false + clipToPadding = false + background = oval(P.BG, ring, 2.5f) + layoutParams = LinearLayout.LayoutParams(dp(sizeDp), dp(sizeDp)) + } + chip.addView( + text("T${tool.fileTool}", sizeDp * 0.34f, P.TEXT, bold = true).apply { + gravity = Gravity.CENTER + }, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + if (empty) { + chip.addView( + FrameLayout(activity).apply { + background = oval(P.DANGER) + addView( + HelixIcons.view(activity, HelixIcons.ALERT, 7f, P.BG), + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + }, + FrameLayout.LayoutParams(dp(13), dp(13), Gravity.TOP or Gravity.END), + ) + } + return chip + } + + // ---------- commit ---------- + + private fun holdButton(): View { + val container = FrameLayout(activity).apply { + background = rounded(P.ACCENT_FILL, 28f) + clipToOutline = true + } + val fill = View(activity).apply { + setBackgroundColor(Color.argb(66, 255, 255, 255)) + layoutParams = FrameLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT) + } + container.addView(fill) + + val caption = text(config.sendLabel, 15f, P.ON_ACCENT, bold = true) + val label = row().apply { gravity = Gravity.CENTER } + label.addSpaced(HelixIcons.view(activity, HelixIcons.PRINTER_NOZZLE, 19f, P.ON_ACCENT), 0) + label.addSpaced(caption, 9) + container.addView( + label, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + + container.setOnTouchListener(HoldGesture(container, fill, caption)) + return container + } + + /** Press-and-hold: fills the pill over [HOLD_MS], and commits only if held through. */ + private inner class HoldGesture( + private val container: FrameLayout, + private val fill: View, + private val caption: TextView, + ) : View.OnTouchListener { + private var animator: ValueAnimator? = null + private var settled = false + + override fun onTouch(view: View, event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> if (!sending) begin(view) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> abort() + else -> return false + } + return true + } + + private fun begin(view: View) { + settled = false + caption.text = "Keep holding…" + animator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = HOLD_MS + addUpdateListener { step -> setFill(step.animatedFraction) } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (settled) return + settled = true + reset() + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + commit() + } + }) + start() + } + } + + /** Lifted early — settle first so the end listener cannot fire a send. */ + private fun abort() { + settled = true + animator?.cancel() + animator = null + reset() + } + + private fun reset() { + caption.text = config.sendLabel + setFill(0f) + } + + private fun setFill(fraction: Float) { + fill.layoutParams = (fill.layoutParams as FrameLayout.LayoutParams).apply { + width = (container.width * fraction).toInt() + } + fill.requestLayout() + } + } + + private fun fixBar(reason: String, tools: List): View { + val bar = row().apply { + gravity = Gravity.CENTER + background = rounded(P.alpha(P.DANGER, 0.16f), 28f, P.alpha(P.DANGER, 0.5f)) + setPadding(dp(16), 0, dp(16), 0) + isClickable = true + setOnClickListener { + val starved = tools.firstOrNull { it.lane.isEmpty } + if (starved != null) openLaneLayer(starved.fileTool) else openPrinterLayer() + } + } + bar.addSpaced(HelixIcons.view(activity, HelixIcons.WRENCH, 18f, P.DANGER), 0) + bar.addSpaced(text(reason, 14f, P.DANGER, bold = true).apply { maxLines = 1 }, 9, weighted()) + return bar + } + + private fun commit() { + if (sending) return + sending = true + errorMessage = null + showSendingOverlay() + config.onSend(tools().associate { it.fileTool to it.assigned }, prefs.toSet()) + } + + private fun showSendingOverlay() { + val card = column().apply { + gravity = Gravity.CENTER_HORIZONTAL + background = rounded(P.SURFACE, 24f, P.BORDER) + setPadding(dp(22), dp(22), dp(22), dp(22)) + } + card.addSpaced( + FrameLayout(activity).apply { + background = oval(P.alpha(P.ACCENT, 0.14f)) + addView( + ProgressBar(activity).apply { + isIndeterminate = true + indeterminateTintList = ColorStateList.valueOf(P.ACCENT) + }, + FrameLayout.LayoutParams(dp(34), dp(34), Gravity.CENTER), + ) + }, + 0, + LinearLayout.LayoutParams(dp(52), dp(52)), + ) + card.addSpaced(text("Starting print", 19f, P.TEXT, bold = true, tracking = -0.021f), 13) + + statusLabel = text("Preparing your print…", 13f, P.TEXT, bold = true).apply { + gravity = Gravity.CENTER + maxLines = 2 + } + card.addSpaced(requireNotNull(statusLabel), 13, fullWidth()) + + progressBar = ProgressBar(activity, null, android.R.attr.progressBarStyleHorizontal).apply { + max = 100 + progress = 0 + progressTintList = ColorStateList.valueOf(P.ACCENT) + progressBackgroundTintList = ColorStateList.valueOf(P.SURFACE_ALT) + } + progressPct = text("0%", 12f, P.DIM, bold = true).apply { gravity = Gravity.END } + + val progressRow = row() + progressRow.addSpaced(requireNotNull(progressBar), 0, LinearLayout.LayoutParams(0, dp(6), 1f)) + progressRow.addSpaced( + requireNotNull(progressPct), + 8, + LinearLayout.LayoutParams(dp(38), LinearLayout.LayoutParams.WRAP_CONTENT), + ) + card.addSpaced(progressRow, 13, fullWidth()) + + val host = FrameLayout(activity).apply { + setBackgroundColor(0xC2000000.toInt()) + isClickable = true + addView( + card, + FrameLayout.LayoutParams( + minOf(dp(380), (activity.resources.displayMetrics.widthPixels * 0.86f).toInt()), + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.CENTER, + ), + ) + } + overlay = host + root.addView( + host, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + } + + // ---------- second layer: lane and printer pickers ---------- + + /** The RN sheet pushes the main card back when a picker rises over it. */ + private fun pushBack(back: Boolean) { + sheet.animate() + .scaleX(if (back) 0.94f else 1f) + .scaleY(if (back) 0.94f else 1f) + .translationY(if (back) -dpf(14f) else 0f) + .alpha(if (back) 0.5f else 1f) + .setDuration(160) + .start() + } + + private fun closeLayer() { + layer?.let { root.removeView(it) } + layer = null + pushBack(false) + } + + private fun openLayer(title: String, hint: String?, rows: (LinearLayout) -> Unit) { + closeLayer() + + val content = column().apply { setPadding(dp(P.GAP), dp(4), dp(P.GAP), dp(P.GAP)) } + content.addSpaced(text(title, 21f, P.TEXT, bold = true, tracking = -0.019f), 0, fullWidth()) + hint?.let { content.addSpaced(text(it, 12f, P.DIM), 4, fullWidth()) } + rows(content) + + val back = row().apply { + setPadding(dp(P.GAP), dp(10), dp(P.GAP), 0) + minimumHeight = dp(40) + isClickable = true + setOnClickListener { closeLayer() } + } + back.addSpaced(HelixIcons.view(activity, HelixIcons.CHEVRON_LEFT, 18f, P.TEXT), 0) + back.addSpaced(text("Back", 13f, P.TEXT, bold = true), 2) + + val panel = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + isClickable = true + background = sheetBackground(P.RADIUS + 8) + setPadding(0, 0, 0, dp(14)) + addView(grabber()) + addView(back, fullWidth()) + addView( + CappedScrollView(activity, (activity.resources.displayMetrics.heightPixels * 0.6f).toInt()) + .apply { + isVerticalScrollBarEnabled = false + addView(content, fullWidth()) + }, + fullWidth(), + ) + } + EdgeInsets.applyBottom(panel) + + layer = panel + root.addView( + panel, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM, + ), + ) + pushBack(true) + } + + private fun pickerRow( + active: Boolean, + badge: View, + title: String, + subtitle: TextView, + onPick: () -> Unit, + ): View { + val line = row().apply { + minimumHeight = dp(56) + setPadding(dp(10), dp(6), dp(10), dp(6)) + if (active) background = rounded(P.alpha(P.ACCENT, 0.14f), (P.RADIUS - 4).toFloat()) + isClickable = true + setOnClickListener { onPick() } + } + line.addSpaced(badge, 0) + val labels = column() + labels.addSpaced(text(title, 14f, P.TEXT, bold = true), 0) + labels.addSpaced(subtitle, 2) + line.addSpaced(labels, 12, weighted()) + if (active) line.addSpaced(HelixIcons.view(activity, HelixIcons.CHECK, 18f, P.ACCENT), 12) + return line + } + + private fun openLaneLayer(fileTool: Int) { + val assigned = tools().firstOrNull { it.fileTool == fileTool }?.assigned ?: fileTool + openLayer("Lane for T$fileTool", "Choose the physical spool that feeds this tool.") { content -> + displayLanes.forEach { lane -> + val badge = FrameLayout(activity).apply { + background = oval(P.BG, if (lane.isEmpty) P.alpha(P.DANGER, 0.6f) else lane.color, 2f) + addView( + text("${lane.index + 1}", 12f, P.TEXT, bold = true).apply { gravity = Gravity.CENTER }, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + layoutParams = LinearLayout.LayoutParams(dp(34), dp(34)) + } + content.addSpaced( + pickerRow( + active = assigned == lane.index, + badge = badge, + title = PreprocessRouting.laneLabel(lane), + subtitle = text(PreprocessRouting.laneDetail(lane), 11.5f, P.DIM), + ) { + manual[fileTool] = lane.index + closeLayer() + render() + }, + 6, + fullWidth(), + ) + } + } + } + + private fun openPrinterLayer() { + openLayer("Select Printer", null) { content -> + config.printers.forEach { printer -> + val active = printer.url.trimEnd('/') == printerUrl.trimEnd('/') + val badge = FrameLayout(activity).apply { + background = rounded(P.alpha(P.ACCENT, 0.14f), 12f) + addView( + printerIconView(), + FrameLayout.LayoutParams( + dp(18), + dp(18), + Gravity.CENTER, + ), + ) + layoutParams = LinearLayout.LayoutParams(dp(36), dp(36)) + } + val subtitle = text("Checking…", 11.5f, P.DIM) + config.probePrinter(printer.url) { state -> + subtitle.text = state.label + subtitle.setTextColor(if (state.busy) P.WARN else P.DIM) + } + content.addSpaced( + pickerRow(active, badge, printer.name, subtitle) { + printerUrl = printer.url + printerState = null + config.onPrinterPicked(printer) + closeLayer() + render() + probe(printer.url) + }, + 6, + fullWidth(), + ) + } + } + } + + private companion object { + const val HOLD_MS = 700L + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPrinterStore.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPrinterStore.kt index 0d1a76f..b58180a 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPrinterStore.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPrinterStore.kt @@ -8,15 +8,22 @@ import android.content.Context * RN refreshes it via HelixSlicerModule.setPrinters whenever settings change. */ object HelixPrinterStore { - data class Printer(val name: String, val url: String) + data class Printer(val name: String, val url: String, val tailscaleUrl: String = "") private const val PREFS = "helix_printers" private const val KEY = "printers" fun write(context: Context, printers: List) { val encoded = printers - .filter { it.url.isNotBlank() } - .joinToString("\n") { "${it.name.replace("\n", " ")}|${it.url}" } + .filter { it.url.isNotBlank() || it.tailscaleUrl.isNotBlank() } + .joinToString("\n") { p -> + // JSON array per line: [name, url, tailscaleUrl]. Unambiguous + extensible. + org.json.JSONArray().apply { + put(p.name.replace("\n", " ")) + put(p.url) + put(p.tailscaleUrl) + }.toString() + } context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit().putString(KEY, encoded).apply() } @@ -25,11 +32,28 @@ object HelixPrinterStore { (context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, "") ?: "") .lineSequence() .mapNotNull { line -> - val idx = line.lastIndexOf('|') - if (idx <= 0) return@mapNotNull null - val name = line.substring(0, idx).trim() - val url = line.substring(idx + 1).trim() - if (url.isBlank()) null else Printer(name.ifBlank { "Printer" }, url) + val trimmed = line.trim() + if (trimmed.isEmpty()) return@mapNotNull null + // New format: JSON array [name, url, tailscaleUrl]. + if (trimmed.startsWith("[")) { + try { + val a = org.json.JSONArray(trimmed) + val name = a.optString(0).trim().ifBlank { "Printer" } + val url = a.optString(1).trim() + val tailscale = a.optString(2).trim() + if (url.isBlank() && tailscale.isBlank()) null + else Printer(name, url, tailscale) + } catch (_: Throwable) { + null + } + } else { + // Legacy pipe-delimited "name|url" (pre-failover installs). + val idx = trimmed.lastIndexOf('|') + if (idx <= 0) return@mapNotNull null + val name = trimmed.substring(0, idx).trim() + val url = trimmed.substring(idx + 1).trim() + if (url.isBlank()) null else Printer(name.ifBlank { "Printer" }, url) + } } .toList() } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt index 76b6d95..3288b09 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt @@ -310,6 +310,32 @@ class HelixSlicerModule( } } + /** + * Embedded slice stats for each plate of a Bambu/Orca 3MF: + * { id, layers, timeSeconds, grams }. Empty when the 3MF has no embedded + * plate G-code (geometry-only exports, STLs). + */ + @ReactMethod + fun getModelPlateStats(path: String, promise: Promise) { + try { + val file = File(path.removePrefix("file://")) + val arr = Arguments.createArray() + if (file.exists()) { + for (stats in PlateExtractor.readPlateStats(file)) { + arr.pushMap(Arguments.createMap().apply { + putInt("id", stats.id) + putInt("layers", stats.layers) + putInt("timeSeconds", stats.timeSeconds) + putDouble("grams", stats.grams) + }) + } + } + promise.resolve(arr) + } catch (error: Throwable) { + promise.reject("E_PLATE_STATS", error.message, error) + } + } + /** * Repacks a single plate of a multi-plate 3MF into its own temp 3MF (only that * plate's objects). Resolves { filePath, fileName, objectCount }. The caller @@ -675,8 +701,13 @@ class HelixSlicerModule( val list = (0 until printers.size()).mapNotNull { i -> val map = printers.getMap(i) ?: return@mapNotNull null val url = map.getString("url")?.trim().orEmpty() - if (url.isEmpty()) return@mapNotNull null - HelixPrinterStore.Printer(map.getString("name")?.trim().orEmpty().ifEmpty { "Printer" }, url) + val tailscale = map.getString("tailscaleUrl")?.trim().orEmpty() + if (url.isEmpty() && tailscale.isEmpty()) return@mapNotNull null + HelixPrinterStore.Printer( + map.getString("name")?.trim().orEmpty().ifEmpty { "Printer" }, + url, + tailscale, + ) } HelixPrinterStore.write(reactApplicationContext, list) promise.resolve(true) @@ -700,6 +731,43 @@ class HelixSlicerModule( } } + /** + * Mirrors the RN filament slot list into native prefs: colours into + * [FilamentSlotColors] (paint/preview) and the descriptive half into + * [FilamentSlotDetails] so the print preprocess sheet can label each lane + * exactly as the RN sheet does without re-deriving anything from Moonraker. + */ + @ReactMethod + fun setFilamentSlots(slots: ReadableArray, promise: Promise) { + try { + val colors = mutableListOf() + val details = mutableListOf() + for (i in 0 until slots.size()) { + val map = slots.getMap(i) ?: continue + val color = map.getString("color")?.trim().orEmpty() + if (color.isNotEmpty()) colors.add(color) + details.add( + FilamentSlotDetails.Slot( + material = map.getString("material")?.trim().orEmpty().ifEmpty { "PLA" }, + mainType = map.getString("mainType")?.trim().orEmpty(), + subType = map.getString("subType")?.trim().orEmpty(), + brand = map.getString("brand")?.trim().orEmpty(), + status = map.getString("status")?.trim().orEmpty().ifEmpty { "unknown" }, + ), + ) + } + if (colors.isNotEmpty()) { + FilamentSlotColors.write(reactApplicationContext, colors) + } + if (details.isNotEmpty()) { + FilamentSlotDetails.write(reactApplicationContext, details) + } + promise.resolve(true) + } catch (error: Throwable) { + promise.reject("E_FILAMENT_SLOTS", error.message, error) + } + } + /** * Opens the native 3D G-code toolpath preview (layer slider, travel toggle, * feature-type colors) for a sliced .gcode file. diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt index c017fdf..cfda9dc 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt @@ -770,6 +770,89 @@ object PlateExtractor { return "data:image/png;base64," + android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) } + // ------------------------------------------------------------------ + // Embedded slice stats + // + // Bambu/Orca 3MFs (everything MakerWorld serves) carry the already-sliced + // plate G-code at Metadata/plate_.gcode, and its header comments hold + // the layer count, estimated time and filament use. Reading those lets the + // app show real numbers for a picked file BEFORE any in-app slicing. + // ------------------------------------------------------------------ + + data class PlateStats( + val id: Int, + val layers: Int, + val timeSeconds: Int, + val grams: Double, + ) + + private val STAT_LAYERS = + Regex(";\\s*total layer number\\s*[:=]\\s*(\\d+)", RegexOption.IGNORE_CASE) + private val STAT_TIME = + Regex(";\\s*estimated printing time[^=\\n]*=\\s*([^\\n]+)", RegexOption.IGNORE_CASE) + private val STAT_GRAMS_TOTAL = + Regex(";\\s*total filament used \\[g\\]\\s*[:=]\\s*([\\d.]+)", RegexOption.IGNORE_CASE) + private val STAT_GRAMS_LIST = + Regex(";\\s*filament used \\[g\\]\\s*=\\s*([\\d.,\\s]+)", RegexOption.IGNORE_CASE) + private val TIME_TOKEN = Regex("(\\d+)\\s*([dhms])", RegexOption.IGNORE_CASE) + + /** Stats per plate with embedded G-code; plates without stats are omitted. */ + fun readPlateStats(file: File): List { + val out = ArrayList() + for (plate in readPlates(file)) { + // Header comments live in the first few KB; the plate body can be tens + // of MB, so never read the whole entry. + val head = readEntryHead(file, "Metadata/plate_${plate.id}.gcode", 256 * 1024) ?: continue + val layers = STAT_LAYERS.find(head)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + val timeSeconds = parseDurationSeconds(STAT_TIME.find(head)?.groupValues?.get(1)) ?: 0 + val grams = STAT_GRAMS_TOTAL.find(head)?.groupValues?.get(1)?.toDoubleOrNull() + ?: STAT_GRAMS_LIST.find(head)?.groupValues?.get(1) + ?.split(',')?.sumOf { it.trim().toDoubleOrNull() ?: 0.0 } + ?: 0.0 + if (layers > 0 || timeSeconds > 0 || grams > 0.0) { + out.add(PlateStats(plate.id, layers, timeSeconds, grams)) + } + } + return out + } + + /** "1h 2m 3s" (any subset/order of d/h/m/s tokens) → seconds, or null. */ + private fun parseDurationSeconds(raw: String?): Int? { + if (raw.isNullOrBlank()) return null + var total = 0 + var matched = false + for (match in TIME_TOKEN.findAll(raw)) { + val value = match.groupValues[1].toIntOrNull() ?: continue + matched = true + total += when (match.groupValues[2].lowercase()) { + "d" -> value * 86400 + "h" -> value * 3600 + "m" -> value * 60 + else -> value + } + } + return if (matched) total else null + } + + private fun readEntryHead(file: File, entryName: String, maxBytes: Int): String? = try { + ZipFile(file).use { zip -> + zip.getEntry(entryName)?.let { entry -> + zip.getInputStream(entry).buffered().use { input -> + val buffer = ByteArray(maxBytes) + var offset = 0 + while (offset < maxBytes) { + val read = input.read(buffer, offset, maxBytes - offset) + if (read < 0) break + offset += read + } + String(buffer, 0, offset, Charsets.UTF_8) + } + } + } + } catch (_: Throwable) { + null + } + /** Parse a 3MF 3×4 transform (12 row-major values); identity when absent. */ private fun parseTransform(s: String?): DoubleArray { val t = DoubleArray(12) diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessRouting.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessRouting.kt new file mode 100644 index 0000000..ae816ed --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessRouting.kt @@ -0,0 +1,206 @@ +package org.crabcore.u1control.slicing + +import android.content.Context +import android.text.format.DateFormat +import java.util.Calendar +import java.util.Date + +/** + * Routing and preflight checks for the print preprocess sheet — a port of + * [services/printPreprocess.ts], kept close to it so the native sheet and its RN + * counterpart never disagree about a job. + * + * Auto-routing exists because identity mapping alone blocks healthy setups: + * spools in lanes 1 and 3 with a file that wants tools 1 and 2 is printable, and + * demanding a hand-fix is the app refusing arithmetic it can do. Blocking is + * reserved for genuinely unsatisfiable jobs — fewer usable lanes than the file + * needs, printer busy, or offline. + */ +object PreprocessRouting { + /** A physical lane on the machine. Mirrors RN `PreprocessLane`. */ + data class Lane( + val index: Int, + val color: Int, + val brand: String = "", + val material: String = "PLA", + val mainType: String = "", + val subType: String = "", + val status: String = "unknown", + ) { + val isEmpty: Boolean get() = status == "empty" + } + + enum class RouteSource { IDENTITY, AUTO, MANUAL } + + /** A file tool paired with the lane that will feed it. */ + data class Tool( + val fileTool: Int, + val assigned: Int, + val grams: Double, + val lane: Lane, + val source: RouteSource, + ) + + enum class Tone { PASS, WARN, FAIL } + + data class Check( + val key: String, + val detail: String, + val tone: Tone, + val blocking: Boolean, + ) + + enum class Pref(val label: String, val hint: String, val icon: Int) { + AUTO_LEVEL("Auto leveling", "Probe the bed before the first layer", HelixIcons.GRID), + FLOW_CAL("Flow calibration", "Calibrate extrusion on the way in", HelixIcons.TUNE), + TIMELAPSE("Time-lapse", "Capture a frame every layer", HelixIcons.VIDEO), + } + + /** A lane that can feed a print. `unknown` counts — refusing on no evidence is worse. */ + private fun usable(lane: Lane?): Boolean = lane != null && !lane.isEmpty + + /** + * Works out which lane feeds each tool. + * + * Manual choices are reserved first. A tool then feeds from its own lane when + * that lane has filament; otherwise it takes the next usable lane nothing else + * has claimed. + */ + fun routeTools( + required: List, + lanes: List, + manual: Map = emptyMap(), + ): Map> { + val out = mutableMapOf>() + val taken = mutableSetOf() + + for (tool in required) { + val picked = manual[tool] + if (picked != null && lanes.getOrNull(picked) != null) { + out[tool] = picked to RouteSource.MANUAL + taken.add(picked) + } + } + + for (tool in required) { + if (out.containsKey(tool)) continue + + if (tool !in taken && usable(lanes.getOrNull(tool))) { + out[tool] = tool to RouteSource.IDENTITY + taken.add(tool) + continue + } + + val spare = lanes.firstOrNull { usable(it) && it.index !in taken } + if (spare != null) { + out[tool] = spare.index to RouteSource.AUTO + taken.add(spare.index) + } else { + out[tool] = tool to RouteSource.IDENTITY + } + } + + return out + } + + /** Builds the tool list the sheet renders. */ + fun buildTools( + required: List, + lanes: List, + manual: Map, + perToolGrams: List, + ): List { + val routing = routeTools(required, lanes, manual) + return required.map { fileTool -> + val route = routing[fileTool] ?: (fileTool to RouteSource.IDENTITY) + val lane = lanes.getOrNull(route.first) + ?: lanes.getOrNull(fileTool) + ?: Lane(index = route.first, color = 0xFF888888.toInt(), status = "empty") + Tool( + fileTool = fileTool, + assigned = route.first, + grams = perToolGrams.getOrNull(fileTool) ?: 0.0, + lane = lane, + source = route.second, + ) + } + } + + fun buildChecks( + connected: Boolean, + printerBusy: Boolean, + printerName: String, + tools: List, + lanes: List, + ): List { + val starved = tools.filter { it.lane.isEmpty } + val loadedCount = lanes.count { it.status == "loaded" } + val rerouted = tools.filter { it.source == RouteSource.AUTO } + + val filament = when { + starved.isNotEmpty() -> Check( + key = "filament", + detail = "This file needs ${tools.size} materials and only $loadedCount " + + (if (loadedCount == 1) "lane has" else "lanes have") + " filament", + tone = Tone.FAIL, + blocking = true, + ) + rerouted.isNotEmpty() -> Check( + key = "filament", + detail = rerouted.joinToString(", ") { "T${it.fileTool} to lane ${it.assigned + 1}" } + + " — your own lanes were empty", + tone = Tone.PASS, + blocking = true, + ) + else -> Check( + key = "filament", + detail = "${tools.size} of ${lanes.size} lanes feed this print", + tone = Tone.PASS, + blocking = true, + ) + } + + return listOf( + Check( + key = "connection", + detail = if (connected) "$printerName responded" else "Not connected — cannot upload the file", + tone = if (connected) Tone.PASS else Tone.FAIL, + blocking = true, + ), + Check( + key = "state", + detail = if (printerBusy) "A print is already running" else "Idle and accepting jobs", + tone = if (printerBusy) Tone.FAIL else Tone.PASS, + blocking = true, + ), + filament, + ) + } + + /** Clock time the print would finish — "4:12 PM" beats doing the arithmetic. */ + fun finishClock(context: Context, seconds: Float): String { + val done = Date(System.currentTimeMillis() + (maxOf(0f, seconds) * 1000L).toLong()) + val time = DateFormat.getTimeFormat(context).format(done) + val midnight = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val days = ((done.time - midnight.timeInMillis) / 86_400_000L).toInt() + return when { + days <= 0 -> time + days == 1 -> "$time tomorrow" + else -> "$time +${days}d" + } + } + + fun laneLabel(lane: Lane): String = + if (lane.isEmpty) "Empty" else lane.mainType.ifBlank { lane.material.ifBlank { "PLA" } } + + fun laneDetail(lane: Lane): String { + if (lane.isEmpty) return "No spool loaded" + val bits = listOf(lane.subType, lane.brand).filter { it.isNotBlank() } + return if (bits.isEmpty()) "Loaded" else bits.joinToString(" · ") + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/ProgressRequestBody.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/ProgressRequestBody.kt new file mode 100644 index 0000000..1b6375f --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/ProgressRequestBody.kt @@ -0,0 +1,43 @@ +package org.crabcore.u1control.slicing + +import java.io.File +import okhttp3.MediaType +import okhttp3.RequestBody +import okio.BufferedSink + +/** + * A file body that reports how much of itself has been written, so an upload can + * drive a real progress bar instead of an indeterminate spinner. Multi-MB G-code + * over WiFi or Tailscale is slow enough that the difference matters. + */ +class ProgressRequestBody( + private val file: File, + private val mediaType: MediaType?, + private val onProgress: (Float) -> Unit, +) : RequestBody() { + override fun contentType(): MediaType? = mediaType + + override fun contentLength(): Long = file.length() + + override fun writeTo(sink: BufferedSink) { + val total = file.length().coerceAtLeast(1L) + var written = 0L + var reported = -1 + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read == -1) break + sink.write(buffer, 0, read) + written += read + // Only on whole-percent changes — OkHttp writes in small chunks and the + // UI has no use for thousands of updates. + val percent = ((written * 100) / total).toInt() + if (percent != reported) { + reported = percent + onProgress(percent / 100f) + } + } + } + } +} diff --git a/android/app/src/main/res/drawable/ic_printer.xml b/android/app/src/main/res/drawable/ic_printer.xml new file mode 100644 index 0000000..486542c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_printer.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app.json b/app.json index 057deaa..a00ac7f 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Helix", "slug": "u1-control", - "version": "1.2.8", + "version": "3.0.0", "orientation": "portrait", "scheme": "u1control", "userInterfaceStyle": "dark", @@ -24,7 +24,7 @@ }, "android": { "package": "org.crabcore.u1control", - "versionCode": 18, + "versionCode": 19, "softwareKeyboardLayoutMode": "resize", "permissions": [ "android.permission.POST_NOTIFICATIONS" diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index dc86cac..73ce58c 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -8,7 +8,7 @@ import { useSettings } from '../../hooks/useSettings'; import { t } from '../../services/i18n'; import { getSharedModelFile, takeNativePrintSentNotice } from '../../services/nativeSlicer'; import { setPendingModel } from '../../services/pendingModel'; -import { colors } from '../../constants/theme'; +import { COCKPIT } from '../../components/dashboard/shared'; type IconName = React.ComponentProps['name']; @@ -66,27 +66,25 @@ export default function TabLayout() { <> @@ -113,7 +111,13 @@ export default function TabLayout() { /> setNativePrintFilename(null)} actions={[ diff --git a/app/(tabs)/console.tsx b/app/(tabs)/console.tsx index bf4c87b..24de70c 100644 --- a/app/(tabs)/console.tsx +++ b/app/(tabs)/console.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { FlatList, KeyboardAvoidingView, @@ -8,6 +8,7 @@ import { StyleSheet, Text, TouchableOpacity, + useWindowDimensions, View, } from 'react-native'; import { TextInput } from 'react-native'; @@ -27,10 +28,13 @@ function lineColor(line: ConsoleLine): string { export default function ConsoleScreen() { const { consoleLines, sendGcode, clearConsole, connection, gcodeHelp } = useMoonraker(); + const helpButtonRef = useRef(null); const [input, setInput] = useState(''); const [helpOpen, setHelpOpen] = useState(false); const [helpFilter, setHelpFilter] = useState(''); + const [helpAnchor, setHelpAnchor] = useState({ x: 0, y: 0, width: 38, height: 48 }); const insets = useSafeAreaInsets(); + const window = useWindowDimensions(); const data = useMemo(() => [...consoleLines].reverse(), [consoleLines]); const helpCommands = useMemo( @@ -44,14 +48,21 @@ export default function ConsoleScreen() { [gcodeHelp, helpFilter] ); + const openHelp = (filter = '') => { + setHelpFilter(filter); + helpButtonRef.current?.measureInWindow((x, y, width, height) => { + setHelpAnchor({ x, y, width, height }); + setHelpOpen(true); + }); + }; + const send = () => { const cmd = input.trim(); if (!cmd) return; setInput(''); const helpMatch = cmd.match(/^\/help(?:\s+(.+))?$/i); if (helpMatch) { - setHelpFilter(helpMatch[1]?.trim() ?? ''); - setHelpOpen(true); + openHelp(helpMatch[1]?.trim() ?? ''); return; } sendGcode(cmd); @@ -94,11 +105,9 @@ export default function ConsoleScreen() { returnKeyType="send" /> { - setHelpFilter(''); - setHelpOpen(true); - }} + onPress={() => openHelp()} > ? @@ -119,23 +128,38 @@ export default function ConsoleScreen() { setHelpOpen(false)}> - {/* Modal hosts its own window, so the screen-level KeyboardAvoidingView - doesn't reach it — lift the sheet above the keyboard here, and pad - past the Android nav bar (issue #5). */} - setHelpOpen(false)}> - true} - > + setHelpOpen(false)} /> + + ? + + {t('Printer commands')} {t('Type /help or /help BED in the console.')} - setHelpOpen(false)}> - X - )} - - + @@ -253,18 +276,25 @@ const styles = StyleSheet.create({ fontSize: 13, }, modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.58)', - justifyContent: 'flex-end', + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.35)', + }, + helpMirror: { + position: 'absolute', + borderRadius: 8, + backgroundColor: colors.cardAlt, + borderWidth: 1, + borderColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', }, helpSheet: { - maxHeight: '76%', - backgroundColor: colors.bg, - borderTopLeftRadius: 18, - borderTopRightRadius: 18, + position: 'absolute', + backgroundColor: colors.card, + borderRadius: 16, borderWidth: 1, borderColor: colors.border, - padding: spacing.lg, + padding: 12, }, helpHeader: { flexDirection: 'row', @@ -287,20 +317,6 @@ const styles = StyleSheet.create({ fontWeight: '700', marginTop: 3, }, - closeBtn: { - width: 34, - height: 34, - borderRadius: 8, - backgroundColor: colors.cardAlt, - alignItems: 'center', - justifyContent: 'center', - }, - closeText: { - color: colors.text, - fontSize: 22, - lineHeight: 24, - fontWeight: '700', - }, helpSearch: { backgroundColor: colors.card, borderRadius: 8, @@ -317,9 +333,11 @@ const styles = StyleSheet.create({ flexGrow: 0, }, helpRow: { + minHeight: 52, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, - paddingVertical: spacing.sm, + paddingVertical: 8, + justifyContent: 'center', }, helpCommand: { color: colors.primary, diff --git a/app/(tabs)/files.tsx b/app/(tabs)/files.tsx index a236fa4..fc671ab 100644 --- a/app/(tabs)/files.tsx +++ b/app/(tabs)/files.tsx @@ -1,20 +1,20 @@ +// Files — the Shelf list, History and Timelapse. +// +// The list, search and per-file metadata live in useFileLibrary; this screen +// owns the print flow that opens when a file is tapped (printer choice, slot +// assignment, tool remapping and the upload/start sequence). import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - FlatList, - Image, - RefreshControl, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useMoonraker } from '../../hooks/useMoonraker'; -import { api, FileEntry, printerConnectionUrl, thumbnailUrl } from '../../services/moonraker'; -import HistoryView from '../../components/HistoryView'; -import TimelapseView from '../../components/TimelapseView'; +import { api, printerConnectionUrl, thumbnailUrl } from '../../services/moonraker'; +import ShelfFiles from '../../components/files/ShelfFiles'; +import HistoryPanel from '../../components/files/HistoryPanel'; +import TimelapsePanel from '../../components/files/TimelapsePanel'; +import { COCKPIT as P, alpha } from '../../components/dashboard/shared'; +import { useFileLibrary, type LibraryFile } from '../../hooks/useFileLibrary'; +import { usePrintHistory } from '../../hooks/usePrintHistory'; import { t } from '../../services/i18n'; -import { colors, spacing } from '../../constants/theme'; import { useThemedAlert } from '../../hooks/useThemedAlert'; import { useSettings } from '../../hooks/useSettings'; import PrintPreprocessDialog, { type PrintPref } from '../../components/PrintPreprocessDialog'; @@ -22,70 +22,25 @@ import type { FilamentSlotDisplay } from '../../components/FilamentSlotsEditor'; import { normalizeFilamentSlotColors } from '../../constants/filamentColors'; import * as FileSystem from 'expo-file-system/legacy'; import { fileUrl } from '../../services/moonraker'; -import { uploadGcodeToPrinter } from '../../services/nativeSlicer'; +import { injectTimelapseMacros, uploadGcodeToPrinter } from '../../services/nativeSlicer'; +import { routeTools } from '../../services/printPreprocess'; -// path|modified -> thumbnail URL, null = file genuinely has no thumbnail. -// cached at module level so scrolling doesn't re-hit /server/files/metadata -// for every row. modified is in the key so re-sliced files bust the cache. -const thumbCache = new Map(); +type Mode = 'files' | 'history' | 'timelapse'; -function FileThumb({ base, file }: { base: string; file: FileEntry }) { - const cacheKey = `${file.path}|${file.modified}`; - const [thumb, setThumb] = useState(thumbCache.get(cacheKey)); - - useEffect(() => { - if (thumb !== undefined || !base) return; - let live = true; - (async () => { - try { - const meta: any = await api.metadata(base, file.path); - const thumbs: any[] = Array.isArray(meta?.thumbnails) ? meta.thumbnails : []; - const best = thumbs.reduce( - (a, b) => (!a || (b?.width ?? 0) > (a.width ?? 0) ? b : a), - null as any - ); - const url = best?.relative_path ? thumbnailUrl(base, file.path, best.relative_path) : null; - thumbCache.set(cacheKey, url); - if (live) setThumb(url); - } catch { - thumbCache.set(cacheKey, null); - if (live) setThumb(null); - } - })(); - return () => { - live = false; - }; - }, [base, cacheKey, thumb, file.path]); - - if (thumb) { - return ; - } - return ( - - - - ); -} - -function formatSize(bytes: number): string { - if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; - return `${bytes} B`; -} - -function formatDate(epoch: number): string { - const d = new Date(epoch * 1000); - return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} +const MODES: { key: Mode; label: string }[] = [ + { key: 'files', label: 'Files' }, + { key: 'history', label: 'History' }, + { key: 'timelapse', label: 'Timelapse' }, +]; export default function FilesScreen() { const { connection, activeUrl, status } = useMoonraker(); const { settings } = useSettings(); - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [mode, setMode] = useState<'files' | 'history' | 'timelapse'>('files'); - const [selectedFile, setSelectedFile] = useState(null); + const connected = connection === 'connected'; + const [mode, setMode] = useState('files'); + const library = useFileLibrary(); + const history = usePrintHistory(activeUrl, connected); + const [selectedFile, setSelectedFile] = useState(null); const [selectedMeta, setSelectedMeta] = useState(null); const [modalLoading, setModalLoading] = useState(false); const [modalError, setModalError] = useState(null); @@ -103,25 +58,7 @@ export default function FilesScreen() { const printState: string = status.print_stats?.state ?? ''; - const refresh = useCallback(async () => { - if (!activeUrl) return; - setLoading(true); - setError(''); - try { - const list = await api.listFiles(activeUrl); - setFiles([...list].sort((a, b) => b.modified - a.modified)); - } catch (e: any) { - setError(String(e?.message ?? e)); - } finally { - setLoading(false); - } - }, [activeUrl]); - - useEffect(() => { - if (connection === 'connected') refresh(); - }, [connection, refresh]); - - const openPrintModal = useCallback(async (file: FileEntry) => { + const openPrintModal = useCallback(async (file: LibraryFile) => { if (printState === 'printing' || printState === 'paused') { showAlert({ title: t('Printer busy'), @@ -250,7 +187,7 @@ export default function FilesScreen() { const targetUrl = selectedPrinter.url; setSending(true); setModalError(null); - setSendProgress(0.2); + setSendProgress(0.05); try { const materialMismatch = findMaterialMismatch(selectedMeta, assignments, availableSlots); if (materialMismatch) { @@ -258,95 +195,123 @@ export default function FilesScreen() { `This file was sliced for ${materialMismatch.fileMaterial}, but ${materialMismatch.slotName} is loaded in T${materialMismatch.loadedSlot}. Re-slice the model with the loaded material before printing.`, ); } - await api.runGcode( - targetUrl, - `SET_MAIN_STATE MAIN_STATE=IDLE\nSET_PRINT_PREFERENCES BED_LEVEL=${prefs.autoLevel ? 1 : 0} TIME_LAPSE_CAMERA=${prefs.timelapse ? 1 : 0} FLOW_CALIBRATE=${prefs.flowCal ? 1 : 0} FLOW_CALIBRATE_EXTRUDERS=0,1,2,3`, + + // Route file tools onto loaded lanes (manual wins → identity-if-loaded → first-free-loaded lane). + const required = fileSlots.length ? fileSlots.map((slot) => slot.index) : [0]; + const routing = routeTools(required, loadedSlots, assignments); + const effectiveAssignments = Object.fromEntries( + required.map((tool) => [tool, routing[tool]?.lane ?? tool]), ); - setSendProgress(0.65); + const usedExtruders = [...new Set(Object.values(effectiveAssignments) as number[])].sort((a, b) => a - b); + + // Busy check — refuse if the target printer is already printing/paused. + const before = await api.queryObjects<{ print_stats?: { state?: string } }>(targetUrl, ['print_stats']); + const currentState = before?.status?.print_stats?.state; + if (currentState === 'printing' || currentState === 'paused') { + throw new Error(`Printer is already ${currentState}.`); + } + + // Decide whether we need a local copy: remapped lanes or injected timelapse macros. + const needsRemap = targetUrl !== activeUrl + || Object.entries(effectiveAssignments).some(([fileTool, lane]) => Number(fileTool) !== lane); let printPath = selectedFile.path; - if (targetUrl !== activeUrl || Object.entries(assignments).some(([fileTool, loadedSlot]) => Number(fileTool) !== loadedSlot)) { + if (needsRemap || prefs.timelapse) { + setSendProgress(0.15); const sourcePath = `${FileSystem.cacheDirectory ?? ''}helix-reprint-source-${Date.now()}.gcode`; - const outputPath = `${FileSystem.cacheDirectory ?? ''}helix-reprint-${Date.now()}.gcode`; await FileSystem.downloadAsync(fileUrl(activeUrl, 'gcodes', selectedFile.path), sourcePath); - const source = await FileSystem.readAsStringAsync(sourcePath); - const remapped = remapGcodeTools(source, assignments); - await FileSystem.writeAsStringAsync(outputPath, remapped); + let workPath = sourcePath; + if (needsRemap) { + const source = await FileSystem.readAsStringAsync(sourcePath); + const remapped = remapGcodeTools(source, effectiveAssignments); + const outputPath = `${FileSystem.cacheDirectory ?? ''}helix-reprint-${Date.now()}.gcode`; + await FileSystem.writeAsStringAsync(outputPath, remapped); + workPath = outputPath; + } + if (prefs.timelapse) { + setSendProgress(0.35); + workPath = await injectTimelapseMacros(workPath); + } const uploadName = `${fileStem(selectedFile.path)}_helix_reprint_${Date.now()}.gcode`; - const uploaded = await uploadGcodeToPrinter(targetUrl, uploadName, outputPath); + const uploaded = await uploadGcodeToPrinter(targetUrl, uploadName, workPath); printPath = uploaded?.path ?? uploadName; } + + // Apply preferences (always explicit — firmware caches prior job state). + setSendProgress(0.7); + await api.runGcode( + targetUrl, + `SET_MAIN_STATE MAIN_STATE=IDLE\nSET_PRINT_USED_EXTRUDERS EXTRUDERS=${usedExtruders.join(',')}\nSET_PRINT_PREFERENCES BED_LEVEL=${prefs.autoLevel ? 1 : 0} TIME_LAPSE_CAMERA=${prefs.timelapse ? 1 : 0} FLOW_CALIBRATE=${prefs.flowCal ? 1 : 0} FLOW_CALIBRATE_EXTRUDERS=0,1,2,3`, + ); + + // Verify the firmware accepted the preferences; abort if not. + const applied = await api.queryObjects<{ + print_task_config?: { + auto_bed_leveling?: boolean; + time_lapse_camera?: boolean; + flow_calibrate?: boolean; + flow_calib_extruders?: boolean[]; + extruders_used?: boolean[]; + }; + }>(targetUrl, ['print_task_config']); + const taskConfig = applied?.status?.print_task_config; + if ( + taskConfig?.auto_bed_leveling !== prefs.autoLevel || + taskConfig?.time_lapse_camera !== prefs.timelapse || + taskConfig?.flow_calibrate !== prefs.flowCal || + taskConfig?.flow_calib_extruders?.length !== 4 || + !taskConfig?.flow_calib_extruders?.every(Boolean) || + taskConfig?.extruders_used?.length !== 4 || + !taskConfig?.extruders_used?.every((used, tool) => used === usedExtruders.includes(tool)) + ) { + throw new Error('Printer rejected the selected print preferences.'); + } + + setSendProgress(0.92); await api.startPrint(targetUrl, printPath); setSendProgress(1); - const printed = printPath; closePrintModal(); - showAlert({ title: t('Print started'), message: printed, icon: 'check-circle' }); + showAlert({ title: t('Print started'), message: printPath, icon: 'check-circle' }); } catch (e: any) { setModalError(String(e?.message ?? e)); } finally { setSending(false); } - }, [activeUrl, assignments, closePrintModal, selectedFile, showAlert]); - - const empty = useMemo( - () => ( - - {connection !== 'connected' - ? t('Not connected') - : error - ? `${t('Error')}: ${error}` - : t('No G-code files on printer')} - - ), - [connection, error] - ); + }, [activeUrl, assignments, availableSlots, closePrintModal, fileSlots, loadedSlots, selectedFile, selectedMeta, selectedPrinter, showAlert]); + + // The library already fetched the largest thumbnail for the row, so the + // dialog can show it immediately instead of waiting on the metadata call. + const dialogThumbnail = selectedFile + ? selectedFile.thumbUri + ?? (selectedMeta ? metadataThumbnail(activeUrl, selectedFile.path, selectedMeta) : null) + : null; return ( - <> - + + - {(['files', 'history', 'timelapse'] as const).map((m) => ( - setMode(m)} - > - - {m === 'files' ? t('Files') : m === 'history' ? t('History') : t('Timelapse')} - - - ))} - - - {mode === 'history' ? ( - - ) : mode === 'timelapse' ? ( - - ) : ( - item.path} - contentContainerStyle={styles.listContent} - refreshControl={ - - } - ListEmptyComponent={empty} - renderItem={({ item }) => ( - openPrintModal(item)}> - - - - {item.path} - - - {formatSize(item.size)} - {formatDate(item.modified)} - - - + {MODES.map((m) => { + const on = m.key === mode; + return ( + setMode(m.key)} + style={[styles.segment, on && styles.segmentOn]} + > + {t(m.label)} + + ); + })} + + + {mode === 'files' ? ( + + ) : mode === 'history' ? ( + + ) : ( + )} - /> - )} - + + {alertDialog} ({ id: printer.id, name: printer.name, @@ -378,86 +343,29 @@ export default function FilesScreen() { onSend={reprint} sendLabel="Print Again" /> - + ); } const styles = StyleSheet.create({ - screen: { - flex: 1, - backgroundColor: colors.bg, - }, + root: { flex: 1, backgroundColor: P.bg }, + flex: { flex: 1 }, + segmentRow: { flexDirection: 'row', - margin: spacing.lg, + gap: 4, + margin: 16, marginBottom: 0, - backgroundColor: colors.card, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.border, - padding: 3, - gap: 3, - }, - segment: { - flex: 1, - paddingVertical: spacing.sm, - alignItems: 'center', - borderRadius: 6, - }, - segmentText: { - color: colors.subtext, - fontSize: 13, - fontWeight: '600', - }, - segmentTextActive: { - color: '#fff', - }, - listContent: { - padding: spacing.lg, - gap: spacing.sm, - }, - fileCard: { - backgroundColor: colors.card, - borderRadius: 8, + padding: 4, + borderRadius: 999, + backgroundColor: P.surface, borderWidth: 1, - borderColor: colors.border, - padding: spacing.sm, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - }, - thumb: { - width: 56, - height: 56, - borderRadius: 6, - backgroundColor: colors.cardAlt, - }, - thumbPlaceholder: { - alignItems: 'center', - justifyContent: 'center', - }, - fileInfo: { - flex: 1, - }, - fileName: { - color: colors.text, - fontSize: 13, - fontWeight: '600', - }, - fileMeta: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 4, - }, - metaText: { - color: colors.subtext, - fontSize: 11, - }, - empty: { - color: colors.subtext, - textAlign: 'center', - marginTop: spacing.xl * 2, + borderColor: P.border, }, + segment: { flex: 1, height: 40, borderRadius: 999, alignItems: 'center', justifyContent: 'center' }, + segmentOn: { backgroundColor: alpha(P.accent, 0.18) }, + segmentText: { color: P.dim, fontSize: 13, fontWeight: '800' }, + segmentTextOn: { color: P.accent }, }); function metadataThumbnail(base: string, path: string, meta: any): string | null { diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 4565366..dbbb001 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,1424 +1,25 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - Alert, - Image, - Modal, - Pressable, - ScrollView, - StyleSheet, - Text, - useWindowDimensions, - View, -} from 'react-native'; +// Home — the redesigned Cockpit dashboard. +// +// All home logic lives in components/dashboard/Dashboard.tsx (fed by +// hooks/useDashboardModel + the dashboard/parts modules). This is just the +// route shell. +import React from 'react'; +import { StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useFocusEffect, useRouter } from 'expo-router'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { useMoonraker } from '../../hooks/useMoonraker'; -import { useSettings } from '../../hooks/useSettings'; -import type { PrinterEntry } from '../../hooks/useSettings'; -import { - api, - normalizeMoonrakerUrl, - printerConnectionUrl, - resolveCameraUrl, - resolveSnapshotUrl, - thumbnailUrl, -} from '../../services/moonraker'; -import { - findMachineChamberTemperatureSource, - findPandaBreathTemperatureSource, -} from '../../services/chamberTemperature'; -import { formatDuration } from '../../components/PrintProgress'; -import CameraFeed, { CameraStat } from '../../components/CameraFeed'; -import FilamentDashboardCard from '../../components/FilamentDashboardCard'; -import NotificationBell from '../../components/NotificationBell'; -import ThemedDialog from '../../components/ThemedDialog'; -import ControlsPanel from '../../components/ControlsPanel'; -import MacrosPanel from '../../components/MacrosPanel'; -import { - ArcGauge, - Card, - Chevron, - FadeInUp, - GlowBackdrop, - LiveDot, - PopIn, - PressableScale, - ProgressBar, -} from '../../components/ui'; -import { t } from '../../services/i18n'; -import { takePrintSentNotice, type PrintSentNotice } from '../../services/printSentBus'; -import { displayTemperature } from '../../services/temperature'; -import type { TemperatureUnit } from '../../services/temperature'; -import { colors, radius, shadow, spacing, withAlpha } from '../../constants/theme'; -import { normalizeFilamentSlotColors } from '../../constants/filamentColors'; -import { setFilamentSlotColors } from '../../services/nativeSlicer'; -import { - calculatePrintEtas, - fetchLatestM73, - smoothRemainingEstimate, - type CapturedM73Estimate, -} from '../../services/printEta'; +import Dashboard from '../../components/dashboard/Dashboard'; +import { COCKPIT } from '../../components/dashboard/shared'; -type IconName = React.ComponentProps['name']; - -interface PickerPrinterStatus { - connected: boolean; - state: string; - progress: number; -} - -type PrinterStatusQuery = { - print_stats?: { state?: string }; - display_status?: { progress?: number }; -}; - -interface PickerAnchor { - x: number; - y: number; - width: number; - height: number; -} - -interface MetadataThumbnail { - width?: number; - relative_path?: string; -} - -const M73_REFRESH_MS = 45_000; - -function stateColor(state: string): string { - switch (state) { - case 'printing': return colors.primary; - case 'paused': return colors.warning; - case 'error': return colors.danger; - case 'cancelled': - case 'canceled': return colors.warning; - case 'complete': - case 'ready': return colors.success; - default: return colors.subtext; - } -} - -function statusText(state: string): string { - switch (state) { - case 'printing': return t('Printing'); - case 'paused': return t('Paused'); - case 'complete': return t('Success'); - case 'error': return t('Failed'); - case 'cancelled': - case 'canceled': return t('Cancelled'); - case 'ready': return t('Ready'); - default: return t('Idle'); - } -} - -function connectionModeIcon(mode: string): IconName { - if (mode === 'lan') return 'wifi'; - if (mode === 'tailscale') return 'vpn'; - return 'swap-horizontal'; -} - -function connectionModeLabel(mode: string): string { - if (mode === 'lan') return 'LAN'; - if (mode === 'tailscale') return 'Tailscale'; - return 'Auto'; -} - -function shortUrl(url: string): string { - return url.replace(/^https?:\/\//i, '').replace(/\/$/, '') || t('No URL set'); -} - -function fileLabel(filename: string): string { - return filename.split('/').pop() || t('No file'); -} - -function clampProgress(progress: number): number { - if (!Number.isFinite(progress)) return 0; - return Math.max(0, Math.min(1, progress)); -} - -function clampNumber(value: number, min: number, max: number): number { - if (max < min) return min; - return Math.max(min, Math.min(max, value)); -} - -function printerPollUrl(printer: PrinterEntry): string { - return printerConnectionUrl(printer); -} - -function printerBusy(state: string): boolean { - return state === 'printing' || state === 'paused'; -} - -function pickerStatusLabel(status: PickerPrinterStatus): string { - if (!status.connected) return t('Offline'); - if (printerBusy(status.state)) return t('Busy'); - return t('Idle'); -} - -function pickerStatusColor(status: PickerPrinterStatus): string { - if (!status.connected) return colors.danger; - if (status.state === 'paused') return colors.warning; - if (printerBusy(status.state)) return colors.primary; - return colors.success; -} - -function isGuiWebcam(webcam: { name: string; stream_url: string }): boolean { - return webcam.name.toLowerCase() === 'gui' || /\/screen(?:\/|$)/i.test(webcam.stream_url); -} - -export default function Dashboard() { - const { - status, - connection, - klippyState, - activeUrl, - rpc, - reconnect, - sendGcode, - gcodeHelp, - webcams, - } = useMoonraker(); - const { settings, update } = useSettings(); - const router = useRouter(); - const window = useWindowDimensions(); - const show = settings.dashboard; - const unit = settings.temperatureUnit; - const pickerTriggerRef = useRef(null); - - // I hate this mf I kept wondering why it kept popping up. - // crabcore - const [dismissedJob, setDismissedJob] = useState(''); - const [observedLiveFilename, setObservedLiveFilename] = useState(''); - const [pickerOpen, setPickerOpen] = useState(false); - const [pickerAnchor, setPickerAnchor] = useState(null); - const [pickerStatuses, setPickerStatuses] = useState>({}); - const [thumb, setThumb] = useState(null); - const [subtitle, setSubtitle] = useState(''); - const [ledOverride, setLedOverride] = useState<{ key: string; on: boolean } | null>(null); - const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); - const [estopConfirmOpen, setEstopConfirmOpen] = useState(false); - const [estopDontAsk, setEstopDontAsk] = useState(false); - const [printSentNotice, setPrintSentNotice] = useState(null); - - useFocusEffect( - useCallback(() => { - const notice = takePrintSentNotice(); - if (notice) setPrintSentNotice(notice); - }, []) - ); - - const ledKey = useMemo( - () => Object.keys(status).find((k) => /^(led|neopixel|dotstar) /.test(k)), - [status] - ); - const ledColors: number[][] = status[ledKey ?? '']?.color_data ?? []; - const reportedLedOn = ledColors.some((c) => Array.isArray(c) && c.some((v) => v > 0)); - const activeLedOverride = ledOverride && ledOverride.key === ledKey ? ledOverride : null; - const ledOn = activeLedOverride ? activeLedOverride.on : reportedLedOn; - useEffect(() => { - const pendingLed = ledOverride; - if (!pendingLed || pendingLed.key !== ledKey || !ledColors.length) return; - if (reportedLedOn === pendingLed.on) setLedOverride(null); - }, [ledColors.length, ledKey, ledOverride, reportedLedOn]); - const toggleLed = () => { - if (!ledKey) return; - const name = ledKey.replace(/^(led|neopixel|dotstar)\s+/, ''); - const hasWhite = (ledColors[0]?.length ?? 0) >= 4; - const v = ledOn ? 0 : 1; - setLedOverride({ key: ledKey, on: !ledOn }); - sendGcode( - hasWhite - ? `SET_LED LED=${name} RED=0 GREEN=0 BLUE=0 WHITE=${v} SYNC=0` - : `SET_LED LED=${name} RED=${v} GREEN=${v} BLUE=${v} SYNC=0` - ); - }; - - const ps = status.print_stats ?? {}; - const vsd = status.virtual_sdcard ?? {}; - const state: string = ps.state ?? 'unknown'; - const printing = state === 'printing'; - const paused = state === 'paused'; - const connected = connection === 'connected' && klippyState === 'ready'; - const activeJob = printing || paused; - const connecting = connection === 'connecting'; - - const showErr = (e: unknown) => Alert.alert('Error', e instanceof Error ? e.message : String(e)); - const doPause = () => rpc('printer.print.pause').catch(showErr); - const doResume = () => rpc('printer.print.resume').catch(showErr); - const doCancel = () => setCancelConfirmOpen(true); - const confirmCancel = () => { - setCancelConfirmOpen(false); - rpc('printer.print.cancel').catch(showErr); - }; - const fireEstop = () => { - rpc('printer.emergency_stop').catch(() => {}); - const primary = normalizeMoonrakerUrl(settings.primaryUrl); - const tailscale = normalizeMoonrakerUrl(settings.tailscaleUrl); - if (primary) api.emergencyStop(primary).catch(() => {}); - if (tailscale && tailscale !== primary) api.emergencyStop(tailscale).catch(() => {}); - }; - const doEstop = () => { - if (!settings.estopConfirm) { - fireEstop(); - return; - } - setEstopDontAsk(false); - setEstopConfirmOpen(true); - }; - const confirmEstop = () => { - setEstopConfirmOpen(false); - fireEstop(); - if (estopDontAsk) update({ estopConfirm: false }).catch(() => {}); - }; - - const extruderNames = ['extruder', 'extruder1', 'extruder2', 'extruder3']; - const chamberTempSource = useMemo(() => findMachineChamberTemperatureSource(status), [status]); - const pandaBreathTempSource = useMemo( - () => show.pandaBreath ? findPandaBreathTemperatureSource(status) : null, - [show.pandaBreath, status] - ); - - const filename: string = ps.filename ?? ''; - const durationValue = Number(ps.print_duration); - const duration = Number.isFinite(durationValue) && durationValue >= 0 ? durationValue : 0; - const filePositionValue = Number(vsd.file_position); - const filePositionRef = useRef(0); - const printDurationRef = useRef(0); - filePositionRef.current = - Number.isFinite(filePositionValue) && filePositionValue > 0 ? filePositionValue : 0; - printDurationRef.current = duration; - - const [slicerEstimate, setSlicerEstimate] = useState(null); - const [m73Estimate, setM73Estimate] = useState(null); - useEffect(() => { - let live = true; - setSlicerEstimate(null); - setThumb(null); - setSubtitle(''); - if (!filename || !activeUrl) return; - api - .metadata(activeUrl, filename) - .then((m: { estimated_time?: number; thumbnails?: MetadataThumbnail[]; filament_name?: string | string[] }) => { - if (!live) return; - setSlicerEstimate(typeof m?.estimated_time === 'number' ? m.estimated_time : null); - const thumbs = Array.isArray(m?.thumbnails) ? m.thumbnails : []; - const best = thumbs.reduce( - (winner, current) => (!winner || (current.width ?? 0) > (winner.width ?? 0) ? current : winner), - null - ); - setThumb(best?.relative_path ? thumbnailUrl(activeUrl, filename, best.relative_path) : null); - const fil = Array.isArray(m?.filament_name) ? m.filament_name[0] : m?.filament_name; - setSubtitle(typeof fil === 'string' ? fil.split(/[;,]/)[0].trim() : ''); - }) - .catch(() => {}); - return () => { - live = false; - }; - }, [filename, activeUrl]); - - useEffect(() => { - setM73Estimate(null); - if (!activeJob || !filename || !activeUrl) return; - - let live = true; - let controller: AbortController | null = null; - const refresh = async () => { - const filePosition = filePositionRef.current; - if (filePosition <= 0) return; - controller?.abort(); - controller = new AbortController(); - try { - const estimate = await fetchLatestM73( - activeUrl, - filename, - filePosition, - controller.signal - ); - if (live) { - setM73Estimate( - estimate - ? { - ...estimate, - printDurationAtCapture: printDurationRef.current, - } - : null - ); - } - } catch { - // M73 is optional. Metadata and byte progress remain available as fallbacks. - } - }; - - refresh(); - const timer = setInterval(refresh, M73_REFRESH_MS); - return () => { - live = false; - clearInterval(timer); - controller?.abort(); - }; - }, [activeJob, activeUrl, filename]); - - const progress = clampProgress(vsd.progress ?? status.display_status?.progress ?? 0); - const printEtas = useMemo( - () => - calculatePrintEtas({ - printDuration: duration, - slicerTotalSeconds: slicerEstimate, - m73: m73Estimate, - fallbackProgress: progress, - }), - [duration, m73Estimate, progress, slicerEstimate] - ); - const [liveRemainingEstimate, setLiveRemainingEstimate] = useState(null); - const smoothedDurationRef = useRef(duration); - const smoothedJobRef = useRef(filename); - useEffect(() => { - const jobChanged = smoothedJobRef.current !== filename; - const elapsedPrintSeconds = Math.max(0, duration - smoothedDurationRef.current); - smoothedDurationRef.current = duration; - smoothedJobRef.current = filename; - if (!activeJob) { - setLiveRemainingEstimate(null); - return; - } - setLiveRemainingEstimate((previous) => - jobChanged - ? printEtas.liveRemainingSeconds - : smoothRemainingEstimate( - previous, - printEtas.liveRemainingSeconds, - elapsedPrintSeconds - ) - ); - }, [activeJob, duration, filename, printEtas.liveRemainingSeconds]); - const remaining = - liveRemainingEstimate == null ? '--' : formatDuration(liveRemainingEstimate); - const layer = - ps.info?.current_layer != null && ps.info?.total_layer != null && ps.info.total_layer > 0 - ? `${ps.info.current_layer} / ${ps.info.total_layer}` - : ''; - - const cameraStats = useMemo(() => { - if (!activeJob) return []; - const out: CameraStat[] = []; - if (printEtas.slicerRemainingSeconds != null) { - out.push({ - label: t('Slicer remaining'), - value: formatDuration(printEtas.slicerRemainingSeconds), - }); - } - if (liveRemainingEstimate != null) { - out.push({ - label: t('Est. remaining'), - value: formatDuration(liveRemainingEstimate), - }); - } - if (typeof ps.info?.current_layer === 'number' && ps.info.current_layer > 0 && duration > 0) { - out.push({ label: t('Per layer'), value: formatDuration(duration / ps.info.current_layer) }); - } - return out; - }, [ - activeJob, - duration, - liveRemainingEstimate, - printEtas.slicerRemainingSeconds, - ps.info?.current_layer, - ]); - - const homedAxes: string = status.toolhead?.homed_axes ?? ''; - const homed = homedAxes.includes('x') && homedAxes.includes('y') && homedAxes.includes('z'); - const canMove = connected && !activeJob; - const doHome = () => sendGcode('G28'); - const doDock = () => sendGcode(homed ? 'PARK_EXTRUDER' : 'G28\nPARK_EXTRUDER'); - - const finished = ['complete', 'cancelled', 'error'].includes(state); - const jobKey = `${ps.filename ?? ''}|${state}`; - const observedFinished = finished && !!filename && observedLiveFilename === filename; - const displayState = finished && !observedFinished && connected ? 'ready' : state; - - useEffect(() => { - if (!activeJob || !filename) return; - setObservedLiveFilename(filename); - setDismissedJob(''); - }, [activeJob, filename]); - - const reprint = async () => { - if (!filename) return; - try { - await api.startPrint(activeUrl, filename); - } catch (e: unknown) { - showErr(e); - } - }; - - const switchPrinter = (p: (typeof settings.printers)[number]) => { - setPickerOpen(false); - update({ - activePrinterId: p.id, - primaryUrl: p.url, - tailscaleUrl: p.tailscaleUrl, - cameraUrl: p.cameraUrl, - connectionMode: p.connectionMode, - }); - }; - - const openPrinterPicker = () => { - if (pickerOpen) { - setPickerOpen(false); - return; - } - if (!pickerTriggerRef.current) { - setPickerAnchor(null); - setPickerOpen(true); - return; - } - pickerTriggerRef.current.measureInWindow((x, y, width, height) => { - setPickerAnchor({ x, y, width, height }); - setPickerOpen(true); - }); - }; - - useEffect(() => { - if (!pickerOpen) return; - let live = true; - const pollPrinters = async () => { - const next: Record = {}; - for (const p of settings.printers) { - if (p.id === settings.activePrinterId) continue; - const url = printerPollUrl(p); - if (!url) { - next[p.id] = { connected: false, state: 'offline', progress: 0 }; - continue; - } - try { - const res = await api.queryObjects(normalizeMoonrakerUrl(url), [ - 'print_stats', - 'display_status', - ]); - next[p.id] = { - connected: true, - state: res?.status?.print_stats?.state ?? 'unknown', - progress: clampProgress(res?.status?.display_status?.progress ?? 0), - }; - } catch { - next[p.id] = { connected: false, state: 'offline', progress: 0 }; - } - } - if (live) setPickerStatuses(next); - }; - pollPrinters(); - const timer = setInterval(pollPrinters, 15000); - return () => { - live = false; - clearInterval(timer); - }; - }, [pickerOpen, settings.printers, settings.activePrinterId]); - - const activePrinter = settings.printers.find((p) => p.id === settings.activePrinterId); - const selectedPrinterUrl = activePrinter ? printerConnectionUrl(activePrinter) : ''; - const activeBaseUrl = activeUrl || selectedPrinterUrl; - const mainCameraUrl = resolveCameraUrl(settings.cameraUrl, activeBaseUrl); - const mainWebcam = webcams.find((w) => resolveCameraUrl(w.stream_url, activeBaseUrl) === mainCameraUrl); - const mainSnapshotUrl = resolveSnapshotUrl(mainWebcam?.snapshot_url, settings.cameraUrl, activeBaseUrl); - const hasCamera = show.camera && !!mainCameraUrl; - - const updateFilamentSlots = useCallback(async (next: string[], changedIndex?: number) => { - const normalized = normalizeFilamentSlotColors(next); - await update({ filamentSlotColors: normalized }); - try { - await setFilamentSlotColors(normalized); - } catch { - // Native slicer settings are optional on platforms without the module. - } - if (activeBaseUrl) { - try { - const channels = changedIndex == null ? normalized.map((_, index) => index) : [changedIndex]; - await Promise.all(channels.map((channel) => api.setFilamentSlot( - activeBaseUrl, - channel, - { - VENDOR: settings.filamentSlotBrands[channel] || 'Generic', - MAIN_TYPE: settings.filamentSlotMaterials[channel] || 'PLA', - SUB_TYPE: settings.filamentSlotSubtypes[channel] || status.filament_detect?.info?.[channel]?.SUB_TYPE || 'Basic', - RGB_1: parseInt(normalized[channel].replace('#', '').slice(0, 6), 16), - ALPHA: 255, - }, - ))); - } catch (error) { - Alert.alert('Printer update unavailable', error instanceof Error ? error.message : 'Helix saved the value locally.'); - } - } - }, [activeBaseUrl, settings.filamentSlotBrands, settings.filamentSlotMaterials, settings.filamentSlotSubtypes, status, update]); - - const updateFilamentMaterials = useCallback(async (next: string[], changedIndex?: number) => { - await update({ filamentSlotMaterials: next }); - if (activeBaseUrl) { - try { - const channels = changedIndex == null ? next.map((_, index) => index) : [changedIndex]; - await Promise.all(channels.map((channel) => api.setFilamentSlot( - activeBaseUrl, - channel, - { - VENDOR: settings.filamentSlotBrands[channel] || 'Generic', - MAIN_TYPE: next[channel] || 'PLA', - SUB_TYPE: settings.filamentSlotSubtypes[channel] || status.filament_detect?.info?.[channel]?.SUB_TYPE || 'Basic', - RGB_1: parseInt(normalizeFilamentSlotColors(settings.filamentSlotColors)[channel].replace('#', '').slice(0, 6), 16), - ALPHA: 255, - }, - ))); - } catch (error) { - Alert.alert('Printer update unavailable', error instanceof Error ? error.message : 'Helix saved the value locally.'); - } - } - }, [activeBaseUrl, settings.filamentSlotBrands, settings.filamentSlotColors, settings.filamentSlotSubtypes, status, update]); - - const updateFilamentBrands = useCallback(async (next: string[], changedIndex?: number) => { - await update({ filamentSlotBrands: next }); - if (activeBaseUrl) { - try { - const channels = changedIndex == null ? next.map((_, index) => index) : [changedIndex]; - await Promise.all(channels.map((channel) => api.setFilamentSlot( - activeBaseUrl, - channel, - { - VENDOR: next[channel] || 'Generic', - MAIN_TYPE: settings.filamentSlotMaterials[channel] || 'PLA', - SUB_TYPE: settings.filamentSlotSubtypes[channel] || status.filament_detect?.info?.[channel]?.SUB_TYPE || 'Basic', - RGB_1: parseInt(normalizeFilamentSlotColors(settings.filamentSlotColors)[channel].replace('#', '').slice(0, 6), 16), - ALPHA: 255, - }, - ))); - } catch (error) { - Alert.alert('Printer update unavailable', error instanceof Error ? error.message : 'Helix saved the value locally.'); - } - } - }, [activeBaseUrl, settings.filamentSlotBrands, settings.filamentSlotColors, settings.filamentSlotMaterials, settings.filamentSlotSubtypes, status, update]); - - const updateFilamentSubtypes = useCallback(async (next: string[], changedIndex?: number) => { - const normalized = Array.from({ length: 4 }, (_, i) => { - const value = next[i]?.trim(); - return value || settings.filamentSlotSubtypes[i] || 'Basic'; - }); - await update({ filamentSlotSubtypes: normalized }); - if (activeBaseUrl) { - try { - const channels = changedIndex == null ? normalized.map((_, index) => index) : [changedIndex]; - await Promise.all(channels.map((channel) => api.setFilamentSlot( - activeBaseUrl, - channel, - { - VENDOR: settings.filamentSlotBrands[channel] || 'Generic', - MAIN_TYPE: settings.filamentSlotMaterials[channel] || 'PLA', - SUB_TYPE: normalized[channel], - RGB_1: parseInt(normalizeFilamentSlotColors(settings.filamentSlotColors)[channel].replace('#', '').slice(0, 6), 16), - ALPHA: 255, - }, - ))); - } catch (error) { - Alert.alert('Printer update unavailable', error instanceof Error ? error.message : 'Helix saved the value locally.'); - } - } - }, [activeBaseUrl, settings.filamentSlotBrands, settings.filamentSlotColors, settings.filamentSlotMaterials, settings.filamentSlotSubtypes, update]); - - const printerName = activePrinter?.name?.trim() || t('Printer'); - const connectionHost = shortUrl(activeUrl || selectedPrinterUrl); - - const bed = status.heater_bed ?? {}; - const chamber = chamberTempSource?.data ?? null; - const pandaBreathTemp = pandaBreathTempSource?.data ?? null; - - // all heaters for the device slide (bed, cavity, Panda Breath, every toolhead) - const temps: { key: string; label: string; temperature?: number; target?: number }[] = [ - { key: 'bed', label: t('Bed'), temperature: bed.temperature, target: bed.target }, - ...(chamber - ? [{ - key: 'chamber', - label: t(chamberTempSource?.label ?? 'Chamber'), - temperature: chamber.temperature, - target: chamber.target, - }] - : []), - ...(pandaBreathTemp - ? [{ - key: 'panda_breath', - label: t(pandaBreathTempSource?.label ?? 'Panda Breath'), - temperature: pandaBreathTemp.temperature, - target: pandaBreathTemp.target, - }] - : []), - ...extruderNames - .filter((n) => n === 'extruder' || status[n]) - .map((n, i) => ({ key: n, label: `T${i}`, temperature: status[n]?.temperature, target: status[n]?.target })), - ]; - - const jobTitle = filename - ? fileLabel(filename) - : connected - ? t('Ready to print') - : t('Waiting for printer'); - const showJobCard = - show.progress && (activeJob || (observedFinished && dismissedJob !== jobKey)); - const pct = `${Math.round(progress * 100)}%`; - - - const showCameraValues = show.temps || show.homeDock; - const pickerMargin = spacing.lg; - const pickerWidth = Math.min( - window.width - pickerMargin * 2, - Math.max(320, pickerAnchor?.width ?? 0) - ); - const pickerLeft = pickerAnchor - ? clampNumber( - pickerAnchor.x + pickerAnchor.width / 2 - pickerWidth / 2, - pickerMargin, - window.width - pickerWidth - pickerMargin - ) - : Math.max(pickerMargin, (window.width - pickerWidth) / 2); - const pickerTop = pickerAnchor - ? pickerAnchor.y + pickerAnchor.height + spacing.xs - : 76; - const pickerMaxHeight = Math.max( - 180, - Math.min(430, window.height - pickerTop - spacing.md) - ); - - return ( - - - - {/* header */} - - - - - - - {printerName} - - - - - - {printing ? ( - - ) : ( - - )} - - {connection === 'connected' ? t('Online') : connecting ? `${connectionHost}...` : t('Offline')} - - - - - - - - - - {connection === 'connected' && klippyState !== 'ready' && ( - - - Klipper: {klippyState} - - )} - - {hasCamera && ( - - - - )} - - {showCameraValues && ( - - - - )} - - {show.estop && ( - - - - - - {t('EMERGENCY STOP')} - - - - )} - - {/* print status card */} - {showJobCard && ( - - - - - {thumb ? ( - - ) : ( - - )} - - - - {jobTitle} - - {subtitle ? ( - - {subtitle} - - ) : null} - - {pct} - - {statusText(displayState)} - - - - {(layer || remaining !== '--') && ( - - {[layer && `${t('Layer')} ${layer}`, activeJob && remaining !== '--' && `${remaining} ${t('left')}`] - .filter(Boolean) - .join(' · ')} - - )} - - - - {activeJob && show.actions ? ( - - {printing && ( - - )} - {paused && ( - - )} - - - ) : observedFinished ? ( - - - setDismissedJob(jobKey)} - /> - - ) : null} - - - )} - - {show.controls && ( - - - {t('Controls')} - - - - )} - - {show.macros && ( - - - {t('Macros')} - - - - )} - - {/* extra cameras */} - {webcams - .filter((w) => resolveCameraUrl(w.stream_url, activeBaseUrl) !== mainCameraUrl) - .filter((w) => (isGuiWebcam(w) ? show.gui || show.filaments : show.camera)) - .map((w) => ( - - {isGuiWebcam(w) ? ( - // Keep the printer GUI WebView mounted while its section is hidden. - // Pause its polling instead; remounting /screen/ can trigger "No Signal". - - {w.name} - - - ) : show.camera ? ( - <> - {w.name} - - - ) : null} - {isGuiWebcam(w) && show.filaments && ( - - )} - - ))} - - - setPickerOpen(false)}> - - setPickerOpen(false)} /> - - {t('Devices')} - - {settings.printers.map((p) => { - const active = p.id === settings.activePrinterId; - const rowStatus = active - ? { connected: connection === 'connected', state: displayState, progress } - : pickerStatuses[p.id] ?? { connected: false, state: 'checking', progress: 0 }; - const checking = !active && !pickerStatuses[p.id]; - const rowUrl = printerConnectionUrl(p); - return ( - switchPrinter(p)} - activeScale={0.98} - > - - - - - {p.name} - - - - - {shortUrl(rowUrl)} - - - - - {checking - ? t('Checking connection') - : rowStatus.connected - ? `${t('Connected')} · ${connectionModeLabel(p.connectionMode)}` - : `${t('Offline')} · ${connectionModeLabel(p.connectionMode)}`} - {rowStatus.connected && printerBusy(rowStatus.state) - ? ` · ${Math.round(rowStatus.progress * 100)}%` - : ''} - - - - {active && } - - ); - })} - { - setPickerOpen(false); - router.push('/settings'); - }} - activeScale={0.98} - > - - - - - {t('Add printer')} - - {t('Open Settings')} - - - - - - - - - - setPrintSentNotice(null)} - actions={[ - { - text: t('OK'), - icon: 'check', - variant: 'primary', - onPress: () => setPrintSentNotice(null), - }, - ]} - /> - - setCancelConfirmOpen(false)} - actions={[ - { text: t('No'), onPress: () => setCancelConfirmOpen(false) }, - { - text: t('Yes, cancel'), - variant: 'danger', - onPress: confirmCancel, - }, - ]} - /> - - setEstopConfirmOpen(false)} - actions={[ - { text: t('Cancel'), onPress: () => setEstopConfirmOpen(false) }, - { text: t('Stop now'), variant: 'danger', icon: 'alert-octagon', onPress: confirmEstop }, - ]} - > - setEstopDontAsk((v) => !v)} - accessibilityRole="checkbox" - accessibilityState={{ checked: estopDontAsk }} - > - - {t("Don't ask again")} - - - - ); -} - -function StatusBadge({ label, color }: { label: string; color: string }) { - return ( - - - {label} - - - ); -} - -function CameraValues({ - temps, - unit, - showMotion, - canMove, - onHome, - onDock, -}: { - temps: { key: string; label: string; temperature?: number; target?: number }[]; - unit: TemperatureUnit; - showMotion: boolean; - canMove: boolean; - onHome: () => void; - onDock: () => void; -}) { +export default function HomeScreen() { return ( - - {temps.length > 0 && ( - - {temps.map((tp) => ( - - ))} - - )} - {showMotion && ( - - - - - )} + + + + ); } -function MiniTemp({ - label, - temperature, - target, - unit, -}: { - label: string; - temperature?: number; - target?: number; - unit: TemperatureUnit; -}) { - const temp = typeof temperature === 'number' ? temperature : 0; - const tgt = typeof target === 'number' ? target : 0; - const color = tgt > 0 ? colors.hot : temp >= 50 ? colors.warning : colors.cold; - const progress = tgt > 0 ? clampProgress(temp / tgt) : clampProgress(temp / 250); - return ( - - - - - - {Math.round(displayTemperature(temp, unit))}° - - - - - {label} - - - ); -} - -function JobButton({ - icon, - label, - onPress, - disabled, - tone = 'default', -}: { - icon: IconName; - label: string; - onPress: () => void; - disabled?: boolean; - tone?: 'default' | 'danger' | 'ghost'; -}) { - const bg = tone === 'danger' ? colors.danger : tone === 'ghost' ? colors.cardAlt : colors.primary; - const fg = tone === 'ghost' ? colors.text : '#fff'; - return ( - - - - - {label} - - - - ); -} - const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: colors.bg }, - screen: { flex: 1, backgroundColor: colors.bg }, - content: { - paddingHorizontal: spacing.lg, - paddingTop: spacing.xs, - paddingBottom: 110, - gap: spacing.md, - }, - headerStack: { - gap: spacing.xs, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.sm, - minHeight: 50, - position: 'relative', - }, - headerLeft: { - flex: 1, - minWidth: 0, - alignItems: 'center', - paddingHorizontal: 48, - }, - nameAnchor: { - alignSelf: 'center', - maxWidth: '100%', - }, - nameRow: { - alignSelf: 'center', - maxWidth: '100%', - flexDirection: 'row', - alignItems: 'center', - gap: 2, - paddingLeft: spacing.xs, - paddingRight: spacing.xs, - paddingVertical: 2, - borderRadius: radius.md, - borderWidth: 1, - borderColor: 'transparent', - }, - nameRowOpen: { - backgroundColor: colors.card, - borderColor: colors.border, - }, - printerName: { - color: colors.text, - fontSize: 24, - lineHeight: 28, - fontWeight: '800', - flexShrink: 1, - textAlign: 'center', - }, - onlineRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 2 }, - onlineDot: { width: 8, height: 8, borderRadius: 4 }, - onlineText: { color: colors.subtext, fontSize: 12, fontWeight: '700', flexShrink: 1 }, - iconButton: { - width: 40, - height: 40, - borderRadius: radius.md, - backgroundColor: colors.card, - borderWidth: 1, - borderColor: colors.border, - alignItems: 'center', - justifyContent: 'center', - position: 'absolute', - right: 0, - top: 0, - }, - warnBanner: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - backgroundColor: withAlpha(colors.warning, 0.14), - borderRadius: radius.md, - padding: spacing.md, - }, - warnText: { color: colors.warning, fontSize: 13, fontWeight: '700' }, - cameraValues: { - gap: spacing.sm, - marginTop: -spacing.xs, - }, - valueTemps: { - flexDirection: 'row', - gap: spacing.sm, - paddingRight: spacing.lg, - }, - valueButtons: { - flexDirection: 'row', - gap: spacing.sm, - }, - miniTemp: { - alignItems: 'center', - width: 72, - backgroundColor: colors.card, - borderRadius: radius.md, - borderWidth: 1, - borderColor: colors.border, - paddingVertical: spacing.sm, - }, - miniGauge: { width: 50, height: 50, alignItems: 'center', justifyContent: 'center' }, - miniGaugeCenter: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' }, - miniValue: { fontSize: 13, fontWeight: '900' }, - miniLabel: { color: colors.subtext, fontSize: 11, fontWeight: '700', marginTop: 2 }, - jobCard: { gap: spacing.md }, - jobRow: { flexDirection: 'row', gap: spacing.md }, - thumbBox: { - width: 76, - height: 76, - borderRadius: radius.md, - backgroundColor: colors.cardAlt, - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden', - }, - thumb: { width: '100%', height: '100%' }, - jobInfo: { flex: 1, minWidth: 0 }, - jobTitle: { color: colors.text, fontSize: 16, fontWeight: '800' }, - jobSubtitle: { color: colors.subtext, fontSize: 12, fontWeight: '600', marginTop: 1 }, - jobPctRow: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', marginTop: 6 }, - jobPct: { color: colors.text, fontSize: 22, fontWeight: '900' }, - jobStatus: { fontSize: 13, fontWeight: '800' }, - jobMeta: { color: colors.subtext, fontSize: 12, fontWeight: '600', marginTop: 6 }, - jobActions: { flexDirection: 'row', gap: spacing.sm }, - jobBtn: { - minHeight: 46, - borderRadius: radius.md, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - paddingHorizontal: spacing.sm, - }, - jobBtnText: { fontSize: 13, fontWeight: '800', flexShrink: 1 }, - disabled: { opacity: 0.4 }, - sectionHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: spacing.xs }, - sectionTitle: { color: colors.text, fontSize: 18, fontWeight: '800' }, - estopWrap: { alignItems: 'center', justifyContent: 'center' }, - estopBar: { - width: '100%', - minHeight: 50, - borderRadius: radius.md, - backgroundColor: colors.danger, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.sm, - ...shadow.card, - }, - estopText: { color: '#fff', fontSize: 14, fontWeight: '900', letterSpacing: 1.2 }, - estopDontAskRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - marginBottom: spacing.lg, - }, - estopDontAskText: { color: colors.text, fontSize: 13, fontWeight: '600' }, - extraCamera: { gap: spacing.xs }, - cameraName: { color: colors.subtext, fontSize: 12, fontWeight: '700' }, - pickerModalLayer: { - flex: 1, - }, - pickerScrim: { - backgroundColor: 'rgba(0,0,0,0.35)', - }, - pickerSheet: { - position: 'absolute', - backgroundColor: colors.card, - borderRadius: radius.lg, - borderWidth: 1, - borderColor: colors.border, - overflow: 'hidden', - ...shadow.hero, - }, - pickerHeader: { - color: colors.text, - fontSize: 18, - lineHeight: 23, - fontWeight: '900', - letterSpacing: 0, - textAlign: 'center', - paddingHorizontal: spacing.lg, - paddingTop: spacing.lg, - paddingBottom: spacing.md, - }, - pickerList: { paddingBottom: spacing.xs }, - pickerRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - minHeight: 70, - paddingVertical: spacing.md, - paddingHorizontal: spacing.lg, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.border, - }, - pickerRowActive: { - backgroundColor: withAlpha(colors.primary, 0.12), - }, - pickerBody: { flex: 1, minWidth: 0 }, - pickerDot: { - width: 12, - height: 12, - borderRadius: 6, - }, - pickerNameRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - minWidth: 0, - }, - statusBadge: { - borderRadius: radius.pill, - borderWidth: 1, - paddingHorizontal: spacing.sm, - paddingVertical: 3, - maxWidth: 92, - }, - statusBadgeText: { - fontSize: 11, - fontWeight: '900', - }, - pickerMetaRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 5, - marginTop: 3, - }, - pickerMeta: { - color: colors.subtext, - fontSize: 12, - fontWeight: '700', - flex: 1, - }, - addPrinterIcon: { - width: 34, - height: 34, - borderRadius: radius.md, - backgroundColor: withAlpha(colors.primary, 0.16), - alignItems: 'center', - justifyContent: 'center', - }, - pickerName: { color: colors.text, fontSize: 17, fontWeight: '800', flex: 1, minWidth: 0 }, - pickerUrl: { color: colors.subtext, fontSize: 13, marginTop: 2 }, + root: { flex: 1, backgroundColor: COCKPIT.bg }, + flex: { flex: 1 }, }); diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index d143652..5a2f669 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { - Alert, KeyboardAvoidingView, - Modal, Platform, + Pressable, ScrollView, StyleSheet, Switch, @@ -13,11 +12,10 @@ import { TouchableOpacity, View, } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useFocusEffect, useRouter } from 'expo-router'; import { - ConnectionMode, DashboardSections, PrinterEntry, Settings, @@ -27,6 +25,7 @@ import { useMoonraker } from '../../hooks/useMoonraker'; import AboutCard from '../../components/settings/AboutCard'; import BackupCard from '../../components/settings/BackupCard'; import MacroDisplayCard from '../../components/settings/MacroDisplayCard'; +import PrinterEditorModal from '../../components/settings/PrinterEditorModal'; import ThemedDialog from '../../components/ThemedDialog'; import { buildSettingsSavePatch, hasDraftChanges } from '../../services/settingsDraft'; import { @@ -39,17 +38,25 @@ import { sendNtfy, } from '../../services/notifications'; import { LANGUAGES, t } from '../../services/i18n'; -import { colors, spacing } from '../../constants/theme'; +import { colors, spacing } from '../../components/settings/cockpitTheme'; +import { useThemedAlert } from '../../hooks/useThemedAlert'; +import { COCKPIT, type IconName } from '../../components/dashboard/shared'; +import { + AttentionPanel, + IndexRow, + ScreenTitle, + SectionHeader, + type Commit, +} from '../../components/settings/nova'; import { api, applyConfigIfChanged, + isTailscaleUrl, normalizeBaseUrl, normalizeMoonrakerUrl, printerConnectionUrl, - validatePrinterConnectionTarget, } from '../../services/moonraker'; import { getMakerWorldCookies } from '../../services/nativeSlicer'; -import type { PrinterConnectionValidationError } from '../../services/moonraker'; const ACCENTS = [ { name: 'Fluidd Blue', hex: '#2196f3' }, @@ -62,20 +69,37 @@ const ACCENTS = [ { name: 'Purple', hex: '#ab47bc' }, ]; +// Only sections the redesigned dashboard actually renders. The old UI had +// actions / homeDock / controls panels; they were deleted in the ui-refresh +// redesign, so their toggles gated nothing — keeping them here just confused +// the "X of N shown" count and made live toggles look broken. Panda Breath is +// rebuilt as its own section, so it stays. const SECTION_LABELS: { key: keyof DashboardSections; label: string }[] = [ { key: 'progress', label: 'Progress' }, - { key: 'actions', label: 'Quick actions' }, { key: 'estop', label: 'Emergency stop' }, - { key: 'homeDock', label: 'Home & Dock' }, - { key: 'controls', label: 'Controls' }, - { key: 'pandaBreath', label: 'Panda Breath controls' }, { key: 'temps', label: 'Temperatures' }, { key: 'camera', label: 'Camera' }, { key: 'gui', label: 'GUI screen' }, { key: 'filaments', label: 'Filaments' }, + { key: 'pandaBreath', label: 'Panda Breath' }, { key: 'macros', label: 'Macros' }, ]; +// The notify-on toggles, in display order. Used both to render the list and to +// work out whether Notifications is holding unapplied changes. +const NOTIFY_KEYS = [ + 'notifyPrintComplete', + 'notifyPrintFailed', + 'notifyPrintPaused', + 'notifyPrintCancelled', + 'notifyPrintProgress', + 'notifyFilamentRunout', + 'notifySwapComplete', + 'notifyPrinterError', + 'notifyPrinterDisconnected', + 'notifyTempWarning', +] as const satisfies readonly (keyof Settings)[]; + const NOTIFICATION_MODES: { value: Settings['notificationMode']; label: string; @@ -87,39 +111,24 @@ const NOTIFICATION_MODES: { { value: 'fcm', label: 'Firebase push', icon: 'cloud-upload-outline' }, ]; -const CONNECTION_MODES: { - value: ConnectionMode; - label: string; - icon: React.ComponentProps['name']; -}[] = [ - { value: 'lan', label: 'LAN only', icon: 'wifi' }, - { value: 'auto', label: 'Auto', icon: 'swap-horizontal' }, - { value: 'tailscale', label: 'Tailscale only', icon: 'vpn' }, -]; - -function alertPrinterConnectionError(error: PrinterConnectionValidationError | null): boolean { - if (!error) return false; - - if (error === 'missing-tailscale-url') { - Alert.alert(t('Missing Tailscale URL'), t('Tailscale-only mode needs a Tailscale URL.')); - return true; - } - - Alert.alert(t('Missing printer URL'), t('Enter the printer IP or Moonraker URL.')); - return true; -} +type RestartService = 'klippy' | 'moonraker'; export default function SettingsScreen() { + const { showAlert, alertDialog } = useThemedAlert(); const { settings, loaded, update } = useSettings(); const { connection, activeUrl, klippyState, reconnect } = useMoonraker(); const [draft, setDraft] = useState(null); - const [addingPrinter, setAddingPrinter] = useState(false); - const [newName, setNewName] = useState(''); - const [newUrl, setNewUrl] = useState(''); - const [newTailscaleUrl, setNewTailscaleUrl] = useState(''); - const [newConnectionMode, setNewConnectionMode] = useState('lan'); + // Non-null while the Add printer modal is open: a blank entry the modal + // fills in, so adding looks and behaves exactly like editing. + const [newPrinter, setNewPrinter] = useState(null); const [editingPrinterId, setEditingPrinterId] = useState(null); const [saveDialogOpen, setSaveDialogOpen] = useState(false); + // null = the index. Nova's whole bet: one screen at a time, so a Save button + // always governs exactly what's in front of you. + const [section, setSection] = useState(null); + const [pendingRemoval, setPendingRemoval] = useState(null); + const [pendingRestart, setPendingRestart] = useState(null); + const [restartingService, setRestartingService] = useState(null); useEffect(() => { if (!loaded) return; @@ -136,6 +145,35 @@ export default function SettingsScreen() { draft.printers.find((p) => p.id === draft.activePrinterId) ?? null; const visibleActiveUrl = activeUrl || (activePrinterForDisplay ? printerConnectionUrl(activePrinterForDisplay) : ''); + const activeConnectionName = isTailscaleUrl(visibleActiveUrl) ? 'Tailscale' : 'LAN'; + + const confirmServiceRestart = async () => { + const service = pendingRestart; + if (!service || !visibleActiveUrl || restartingService) return; + + setPendingRestart(null); + setRestartingService(service); + try { + if (service === 'klippy') await api.restartKlippy(visibleActiveUrl); + else await api.restartMoonraker(visibleActiveUrl); + + const name = service === 'klippy' ? 'Klippy' : 'Moonraker'; + showAlert({ + title: `${name} restart requested`, + message: `${name} accepted the restart request over ${activeConnectionName}. Helix will reconnect automatically when it is available again.`, + icon: 'restart', + }); + } catch (error) { + const name = service === 'klippy' ? 'Klippy' : 'Moonraker'; + showAlert({ + title: `${name} restart failed`, + message: error instanceof Error ? error.message : String(error), + icon: 'alert-circle-outline', + }); + } finally { + setRestartingService(null); + } + }; const dirty = hasDraftChanges(draft, settings); @@ -175,70 +213,68 @@ export default function SettingsScreen() { const removePrinter = (p: PrinterEntry) => { if (settings.printers.length <= 1) return; - Alert.alert(t('Remove printer?'), p.name, [ - { text: t('Cancel'), style: 'cancel' }, - { - text: t('Remove'), - style: 'destructive', - onPress: () => { - const printers = settings.printers.filter((x) => x.id !== p.id); - const macroDisplayByPrinter = { ...settings.macroDisplayByPrinter }; - delete macroDisplayByPrinter[p.id]; - const patch: Partial = { printers, macroDisplayByPrinter }; - const nextDraft: Settings = { ...draft, printers, macroDisplayByPrinter }; - if (settings.activePrinterId === p.id) { - const next = printers[0]; - patch.activePrinterId = next.id; - patch.primaryUrl = next.url; - patch.tailscaleUrl = next.tailscaleUrl; - patch.cameraUrl = next.cameraUrl; - patch.connectionMode = next.connectionMode; - nextDraft.activePrinterId = next.id; - nextDraft.primaryUrl = next.url; - nextDraft.tailscaleUrl = next.tailscaleUrl; - nextDraft.cameraUrl = next.cameraUrl; - nextDraft.connectionMode = next.connectionMode; - } - setDraft(nextDraft); - update(patch); - }, - }, - ]); + // Was a native platform alert, which ignored the theme entirely. Now the same + // Focus treatment as every other destructive confirm. + setPendingRemoval(p); }; - const saveEditedPrinter = async (printer: PrinterEntry): Promise => { - const entry: PrinterEntry = { - ...printer, - name: printer.name.trim() || 'Snapmaker U1', - url: normalizeMoonrakerUrl(printer.url), - tailscaleUrl: normalizeMoonrakerUrl(printer.tailscaleUrl), - cameraUrl: printer.cameraUrl.trim() || '/webcam/webrtc', - }; - - if (alertPrinterConnectionError( - validatePrinterConnectionTarget(entry.connectionMode, entry.url, entry.tailscaleUrl) - )) { - return false; + const confirmRemovePrinter = (p: PrinterEntry) => { + setPendingRemoval(null); + const printers = settings.printers.filter((x) => x.id !== p.id); + const macroDisplayByPrinter = { ...settings.macroDisplayByPrinter }; + delete macroDisplayByPrinter[p.id]; + const patch: Partial = { printers, macroDisplayByPrinter }; + const nextDraft: Settings = { ...draft, printers, macroDisplayByPrinter }; + if (settings.activePrinterId === p.id) { + const next = printers[0]; + patch.activePrinterId = next.id; + patch.primaryUrl = next.url; + patch.tailscaleUrl = next.tailscaleUrl; + patch.cameraUrl = next.cameraUrl; + patch.connectionMode = next.connectionMode; + nextDraft.activePrinterId = next.id; + nextDraft.primaryUrl = next.url; + nextDraft.tailscaleUrl = next.tailscaleUrl; + nextDraft.cameraUrl = next.cameraUrl; + nextDraft.connectionMode = next.connectionMode; } + setDraft(nextDraft); + update(patch); + }; - const printers = draft.printers.map((p) => (p.id === entry.id ? entry : p)); + const saveEditedPrinter = async (printer: PrinterEntry): Promise => { + const printers = draft.printers.map((p) => (p.id === printer.id ? printer : p)); const patch: Partial = { printers }; const nextDraft: Settings = { ...draft, printers }; - if (draft.activePrinterId === entry.id) { - patch.primaryUrl = entry.url; - patch.tailscaleUrl = entry.tailscaleUrl; - patch.cameraUrl = entry.cameraUrl; - patch.connectionMode = entry.connectionMode; - nextDraft.primaryUrl = entry.url; - nextDraft.tailscaleUrl = entry.tailscaleUrl; - nextDraft.cameraUrl = entry.cameraUrl; - nextDraft.connectionMode = entry.connectionMode; + if (draft.activePrinterId === printer.id) { + patch.primaryUrl = printer.url; + patch.tailscaleUrl = printer.tailscaleUrl; + patch.cameraUrl = printer.cameraUrl; + patch.connectionMode = printer.connectionMode; + nextDraft.primaryUrl = printer.url; + nextDraft.tailscaleUrl = printer.tailscaleUrl; + nextDraft.cameraUrl = printer.cameraUrl; + nextDraft.connectionMode = printer.connectionMode; } setDraft(nextDraft); await update(patch); - setEditingPrinterId(null); + return true; + }; + + const saveNewPrinter = async (printer: PrinterEntry): Promise => { + const printers = [...settings.printers, printer]; + const patch: Partial = { + printers, + activePrinterId: printer.id, + primaryUrl: printer.url, + tailscaleUrl: printer.tailscaleUrl, + cameraUrl: printer.cameraUrl, + connectionMode: printer.connectionMode, + }; + setDraft({ ...draft, ...patch }); + await update(patch); return true; }; @@ -259,7 +295,6 @@ export default function SettingsScreen() { update({ dashboard: { ...settings.dashboard, - controls: key === 'pandaBreath' && value ? true : settings.dashboard.controls, [key]: value, }, }); @@ -270,12 +305,12 @@ export default function SettingsScreen() { if (Platform.OS === 'android') { ToastAndroid.show(message, ToastAndroid.LONG); } else { - Alert.alert('Notifications', message); + showAlert({ title: 'Notifications', message, icon: 'bell-alert-outline' }); } }; if (draft.notificationMode === 'off') { - report('Choose a notification mode first.'); + report(t('Choose a notification mode first.')); return; } @@ -288,7 +323,7 @@ export default function SettingsScreen() { if (Object.keys(patch).length) set(patch); const ok = await sendNtfy(server, topic, 'Helix test', 'Printer alerts are working.', 3, 'printer'); - report(ok ? 'Test sent. Check your notification tray.' : 'Test failed. Check the ntfy settings.'); + report(ok ? t('Test sent. Check your notification tray.') : t('Test failed. Check the ntfy settings.')); return; } @@ -296,387 +331,550 @@ export default function SettingsScreen() { const configured = activeUrl && settings.activePrinterId ? await configureFcmForPrinter(activeUrl, settings.activePrinterId) : await sendFcmTestNotification(); - report(configured ? 'Test sent. Check your notification tray.' : 'Test failed. Check Firebase setup.'); + report(configured ? t('Test sent. Check your notification tray.') : t('Test failed. Check Firebase setup.')); return; } const ok = await notifyLocal('Helix test', 'Local printer alerts are working.'); - report(ok ? 'Test sent. Check your notification tray.' : 'Test failed. Check notification permission.'); + report(ok ? t('Test sent. Check your notification tray.') : t('Test failed. Check notification permission.')); }; + // Only these fields go through the draft, so only these can be "changed but + // not applied". Everything else on this screen writes immediately. + const notificationsDirty = + draft.notificationMode !== settings.notificationMode || + draft.ntfyServer !== settings.ntfyServer || + draft.ntfyTopic !== settings.ntfyTopic || + NOTIFY_KEYS.some((key) => draft[key] !== settings[key]); + const filamentDirty = draft.aceUnits !== settings.aceUnits; + + const attention = [ + notificationsDirty + ? { key: 'notifications', title: t('Notifications'), icon: 'bell-outline' as IconName } + : null, + filamentDirty + ? { key: 'filament', title: 'Filament & ACE', icon: 'palette-swatch' as IconName } + : null, + ].filter(Boolean) as { key: string; title: string; icon: IconName }[]; + + const online = connection === 'connected'; + const activePrinter = draft.printers.find((p) => p.id === draft.activePrinterId); + const sectionsOn = SECTION_LABELS.filter(({ key }) => settings.dashboard[key]).length; + const notifyOn = NOTIFY_KEYS.filter((key) => draft[key]).length; + const accentName = ACCENTS.find((a) => a.hex === draft.accentColor)?.name ?? 'Custom'; + const languageName = LANGUAGES.find((l) => l.code === draft.language)?.label ?? draft.language; + + const SECTIONS: { + key: string; + title: string; + icon: IconName; + commit: Commit; + summary: string; + dirty?: boolean; + warn?: boolean; + }[] = [ + { + key: 'connection', + title: t('Connection'), + icon: 'lan-connect', + commit: 'instant', + summary: online + ? `${t('Connected')} · klippy ${klippyState}` + : `${connection.toUpperCase()} — ${visibleActiveUrl || t('no URL')}`, + warn: !online, + }, + { + key: 'printers', + title: t('Printers'), + icon: 'printer-3d', + commit: 'instant', + summary: + draft.printers.length === 1 + ? activePrinter?.name ?? t('No printer') + : `${draft.printers.length} ${t('printers')} · ${activePrinter?.name ?? ''}`, + }, + { + key: 'dashboard', + title: t('Dashboard sections'), + icon: 'view-dashboard-outline', + commit: 'instant', + summary: `${sectionsOn} ${t('of')} ${SECTION_LABELS.length} ${t('sections shown')}`, + }, + { + key: 'appearance', + title: t('Appearance'), + icon: 'palette-outline', + commit: 'live', + summary: `${accentName} · ${languageName} · ${draft.temperatureUnit === 'c' ? '°C' : '°F'}`, + }, + { + key: 'notifications', + title: t('Notifications'), + icon: 'bell-outline', + commit: 'draft', + summary: + draft.notificationMode === 'off' + ? t('Off') + : `${NOTIFICATION_MODES.find((m) => m.value === draft.notificationMode)?.label ?? ''} · ${notifyOn}/${NOTIFY_KEYS.length}`, + dirty: notificationsDirty, + }, + { + key: 'filament', + title: 'Filament & ACE', + icon: 'palette-swatch', + commit: 'draft', + summary: `${draft.aceUnits} ACE ${draft.aceUnits === 1 ? 'unit' : 'units'} · Spoolman`, + dirty: filamentDirty, + }, + { + key: 'integrations', + title: 'MakerWorld', + icon: 'cloud-outline', + commit: 'instant', + summary: t('Log in to import shared models'), + }, + { + key: 'about', + title: t('Backup & About'), + icon: 'information-outline', + commit: 'instant', + summary: t('Export, import, updates'), + }, + ]; + + const current = SECTIONS.find((s) => s.key === section) ?? null; + return ( - - - {t('Connection')} - - {connection.toUpperCase()} — {visibleActiveUrl || 'no URL'} (klippy: {klippyState}) - - - {t('Reconnect now')} - - - - - {t('Printers')} - {settings.printers.map((p) => ( - - switchPrinter(p)}> - - - {p.name} - - {printerConnectionUrl(p) || t('No URL set')} - - - - setEditingPrinterId(p.id)} - accessibilityLabel={`Edit ${p.name}`} - > - - - {settings.printers.length > 1 && ( - removePrinter(p)}> - - - )} - - ))} - {addingPrinter ? ( - - - - + + {current === null ? ( + <> + + + setDraft(settings)} /> - - { - const url = normalizeMoonrakerUrl(newUrl); - const tailscaleUrl = normalizeMoonrakerUrl(newTailscaleUrl); - if (alertPrinterConnectionError( - validatePrinterConnectionTarget(newConnectionMode, url, tailscaleUrl) - )) { - return; - } - const entry: PrinterEntry = { - id: `p${Date.now()}`, - name: newName.trim() || `Snapmaker ${settings.printers.length + 1}`, - url, - tailscaleUrl, - cameraUrl: '/webcam/webrtc', - connectionMode: newConnectionMode, - }; - const printers = [...settings.printers, entry]; - update({ - printers, - activePrinterId: entry.id, - primaryUrl: entry.url, - tailscaleUrl: entry.tailscaleUrl, - cameraUrl: entry.cameraUrl, - connectionMode: entry.connectionMode, - }); - setDraft({ - ...draft, - printers, - activePrinterId: entry.id, - primaryUrl: entry.url, - tailscaleUrl: entry.tailscaleUrl, - cameraUrl: entry.cameraUrl, - connectionMode: entry.connectionMode, - }); - setNewName(''); - setNewUrl(''); - setNewTailscaleUrl(''); - setNewConnectionMode('lan'); - setAddingPrinter(false); - }} - > - {t('Add printer')} - - + + + {SECTIONS.map((s, i) => ( + setSection(s.key)} + /> + ))} + + ) : ( - setAddingPrinter(true)}> - + {t('Add printer')} - - )} - - - setEditingPrinterId(null)} - onSave={saveEditedPrinter} - /> - - - {t('Dashboard sections')} - - {SECTION_LABELS.map(({ key, label }) => ( - updateDashboardSection(key, v)} + <> + setSection(null)} /> - ))} - update({ estopConfirm: v })} - /> - - - - - - - {t('Theme')} - {t('Accent color')} - - {ACCENTS.map((a) => ( - setLive({ accentColor: a.hex })} - > - {draft.accentColor === a.hex && ( - - )} - - ))} - - {t('Language')} - - {LANGUAGES.map((l) => ( - setLive({ language: l.code })} - > - - {l.label} - - - ))} - - {t('Temperature units')} - - {(['c', 'f'] as const).map((unit) => { - const active = draft.temperatureUnit === unit; - return ( - setLive({ temperatureUnit: unit })} - > - - {unit === 'c' ? '\u00B0C' : '\u00B0F'} + + {section === 'connection' ? ( + + + {connection.toUpperCase()} — {visibleActiveUrl || 'no URL'} (klippy: {klippyState}) - - ); - })} - - - - - - - {t('ACE units')} - - set({ aceUnits: Math.max(1, draft.aceUnits - 1) })} - > - − - - {draft.aceUnits} - set({ aceUnits: Math.min(4, draft.aceUnits + 1) })} - > - + - - - - - - {t('Notifications')} - - {NOTIFICATION_MODES.map((mode) => { - const active = draft.notificationMode === mode.value; - return ( - setNotificationMode(mode.value)} - > - - - {t(mode.label)} + + + {t('Reconnect now')} + + setPendingRestart('klippy')} + > + + + {restartingService === 'klippy' ? 'Restarting Klippy…' : 'Restart Klippy'} + + + setPendingRestart('moonraker')} + > + + + {restartingService === 'moonraker' + ? 'Restarting Moonraker…' + : 'Restart Moonraker'} + + + + Commands use the active {activeConnectionName} address shown above. + + ) : null} + + {section === 'printers' ? ( + + {settings.printers.map((p) => ( + + switchPrinter(p)}> + + + {p.name} + + {printerConnectionUrl(p) || t('No URL set')} + + + + setEditingPrinterId(p.id)} + accessibilityLabel={`Edit ${p.name}`} + > + + + {settings.printers.length > 1 && ( + removePrinter(p)}> + + + )} + + ))} + + setNewPrinter({ + id: `p${Date.now()}`, + name: `Snapmaker ${settings.printers.length + 1}`, + url: '', + tailscaleUrl: '', + cameraUrl: '/webcam/webrtc', + connectionMode: 'lan', + }) + } + > + + {t('Add printer')} + + + ) : null} + + {section === 'dashboard' ? ( + + + {SECTION_LABELS.map(({ key, label }) => ( + updateDashboardSection(key, v)} + /> + ))} + update({ estopConfirm: v })} + /> + + + ) : null} + + {section === 'appearance' ? ( + <> + + {t('Accent color')} + + {ACCENTS.map((a) => ( + setLive({ accentColor: a.hex })} + > + {draft.accentColor === a.hex && ( + + )} + + ))} + + {t('Language')} + + {LANGUAGES.map((l) => ( + setLive({ language: l.code })} + > + + {l.label} + + + ))} + + + {t('Temperature units')} + + + {(['c', 'f'] as const).map((unit) => { + const active = draft.temperatureUnit === unit; + return ( + setLive({ temperatureUnit: unit })} + > + + {unit === 'c' ? '°C' : '°F'} + + + ); + })} + + + + + ) : null} + + {section === 'notifications' ? ( + + + {NOTIFICATION_MODES.map((mode) => { + const active = draft.notificationMode === mode.value; + return ( + setNotificationMode(mode.value)} + > + + + {t(mode.label)} + + + ); + })} + + + {draft.notificationMode === 'ntfy' && ( + + ntfy server + set({ ntfyServer: v })} + placeholder="https://ntfy.sh" + placeholderTextColor={colors.subtext} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + /> + ntfy topic + + set({ ntfyTopic: v })} + placeholder="helix-random-topic" + placeholderTextColor={colors.subtext} + autoCapitalize="none" + autoCorrect={false} + /> + + + + + + )} + + + {t('Notify on')} + set({ notifyPrintComplete: v })} + /> + set({ notifyPrintFailed: v })} + /> + set({ notifyPrintPaused: v })} + /> + set({ notifyPrintCancelled: v })} + /> + set({ notifyPrintProgress: v })} + /> + set({ notifyFilamentRunout: v })} + /> + set({ notifySwapComplete: v })} + /> + set({ notifyPrinterError: v })} + /> + set({ notifyPrinterDisconnected: v })} + /> + set({ notifyTempWarning: v })} + /> + + {t('Send test notification')} + + + ) : null} + + {section === 'filament' ? ( + <> + + {t('ACE units')} + + set({ aceUnits: Math.max(1, draft.aceUnits - 1) })} + > + − + + {draft.aceUnits} + set({ aceUnits: Math.min(4, draft.aceUnits + 1) })} + > + + + + + + + + ) : null} + + {section === 'integrations' ? : null} + + {section === 'about' ? ( + <> + {/* Import replaces saved settings behind the draft's back — drop + the draft so it re-seeds from the imported values. */} + setDraft(null)} /> + + + ) : null} + + {/* The Save button exists only on the screen it governs, and says + "now" because the change isn't live until it's pressed. */} + {current.commit === 'draft' && dirty ? ( + + + {t('Save & Apply now')} - ); - })} - - - {draft.notificationMode === 'ntfy' && ( - - ntfy server - set({ ntfyServer: v })} - placeholder="https://ntfy.sh" - placeholderTextColor={colors.subtext} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - /> - ntfy topic - - set({ ntfyTopic: v })} - placeholder="helix-random-topic" - placeholderTextColor={colors.subtext} - autoCapitalize="none" - autoCorrect={false} - /> - - - - - + ) : null} + )} + + + + setEditingPrinterId(null)} + onSave={saveEditedPrinter} + /> + setNewPrinter(null)} + onSave={saveNewPrinter} + /> + + setPendingRestart(null)} + actions={[ + { + text: pendingRestart === 'klippy' ? 'Restart Klippy' : 'Restart Moonraker', + icon: 'restart', + variant: pendingRestart === 'klippy' ? 'danger' : 'primary', + onPress: confirmServiceRestart, + }, + { text: t('Cancel'), onPress: () => setPendingRestart(null) }, + ]} + /> + + {/* Destructive, so it takes the Focus treatment like the e-stop. */} + setPendingRemoval(null)} + actions={[ + { + text: t('Remove'), + icon: 'trash-can-outline', + variant: 'danger', + onPress: () => pendingRemoval && confirmRemovePrinter(pendingRemoval), + }, + { text: t('Cancel'), onPress: () => setPendingRemoval(null) }, + ]} + /> - - {t('Notify on')} - set({ notifyPrintComplete: v })} - /> - set({ notifyPrintFailed: v })} - /> - set({ notifyPrintPaused: v })} - /> - set({ notifyPrintCancelled: v })} - /> - set({ notifyPrintProgress: v })} - /> - set({ notifyFilamentRunout: v })} - /> - set({ notifySwapComplete: v })} - /> - set({ notifyPrinterError: v })} - /> - set({ notifyPrinterDisconnected: v })} - /> - set({ notifyTempWarning: v })} - /> - - {t('Send test notification')} - - - - {dirty && ( - - {t('Save & Apply')} - - )} - - - - {/* Import replaces saved settings behind the draft's back — drop the - draft so it re-seeds from the imported values. */} - setDraft(null)} /> - - - + {alertDialog} ); } @@ -741,6 +940,7 @@ function MakerWorldCard() { // touching the printer — same upload+restart flow as the Spoolman tab. function SpoolmanCard({ activeUrl }: { activeUrl: string }) { const { settings } = useSettings(); + const { showAlert, alertDialog } = useThemedAlert(); const [current, setCurrent] = useState(null); const [input, setInput] = useState(''); const [busy, setBusy] = useState(false); @@ -776,9 +976,17 @@ function SpoolmanCard({ activeUrl }: { activeUrl: string }) { ); if (changed) await new Promise((r) => setTimeout(r, 8000)); setCurrent(server); - Alert.alert(t('Saved'), t('Printer now reports filament usage to this Spoolman server.')); + showAlert({ + title: t('Saved'), + message: t('Printer now reports filament usage to this Spoolman server.'), + icon: 'check-circle-outline', + }); } catch (e: unknown) { - Alert.alert(t('Error'), e instanceof Error ? e.message : String(e)); + showAlert({ + title: t('Error'), + message: e instanceof Error ? e.message : String(e), + icon: 'alert-circle-outline', + }); } finally { setBusy(false); } @@ -818,171 +1026,7 @@ function SpoolmanCard({ activeUrl }: { activeUrl: string }) { {t('The Spoolman address is stored on the printer itself, so every device using it stays in sync.')} - - ); -} - -function PrinterEditorModal({ - printer, - onClose, - onSave, -}: { - printer: PrinterEntry | null; - onClose: () => void; - onSave: (printer: PrinterEntry) => Promise; -}) { - const [name, setName] = useState(''); - const [url, setUrl] = useState(''); - const [tailscaleUrl, setTailscaleUrl] = useState(''); - const [cameraUrl, setCameraUrl] = useState(''); - const [connectionMode, setConnectionMode] = useState('lan'); - const [saving, setSaving] = useState(false); - // Issue #5: bottom sheets draw edge-to-edge, so pad past the Android nav bar. - const insets = useSafeAreaInsets(); - - useEffect(() => { - if (!printer) return; - setName(printer.name); - setUrl(printer.url); - setTailscaleUrl(printer.tailscaleUrl); - setCameraUrl(printer.cameraUrl); - setConnectionMode(printer.connectionMode); - }, [printer]); - - if (!printer) return null; - - const save = async () => { - setSaving(true); - try { - await onSave({ - ...printer, - name, - url, - tailscaleUrl, - cameraUrl, - connectionMode, - }); - } finally { - setSaving(false); - } - }; - - return ( - - {/* Modal hosts its own window, so the activity's keyboard resize doesn't - apply — "padding" is needed on Android too (issue #5). */} - - - - - - - - {t('Edit printer')} - - - - - - - - - - - - - {t('Connection mode')} - - - - LAN only never uses Tailscale. Tailscale only never falls back to Wi-Fi. Auto tries - LAN, then Tailscale. - - - - - - {t('Cancel')} - - - - - {saving ? t('Saving...') : t('Save printer')} - - - - - - - ); -} - -function ConnectionModeSelector({ - value, - onChange, -}: { - value: ConnectionMode; - onChange: (mode: ConnectionMode) => void; -}) { - return ( - - {CONNECTION_MODES.map((mode) => { - const active = value === mode.value; - return ( - onChange(mode.value)} - > - - - {t(mode.label)} - - - ); - })} + {alertDialog} ); } @@ -997,23 +1041,25 @@ function DashboardSectionTile({ onChange: (v: boolean) => void; }) { return ( - onChange(!value)} - accessibilityRole="switch" - accessibilityState={{ checked: value }} - > - - {label} - - - - - + + onChange(!value)} + accessibilityRole="switch" + accessibilityState={{ checked: value }} + accessibilityLabel={label} + > + + {label} + + + + ); } @@ -1076,11 +1122,19 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.bg, }, + safe: { flex: 1 }, content: { padding: spacing.lg, gap: spacing.md, paddingBottom: spacing.xl * 2, }, + indexCard: { + borderRadius: COCKPIT.radius, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.card, + overflow: 'hidden', + }, card: { backgroundColor: colors.card, borderRadius: 10, @@ -1169,6 +1223,27 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: '600', }, + connectionBtn: { + minHeight: 44, + marginTop: spacing.sm, + borderRadius: 8, + backgroundColor: colors.cardAlt, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + }, + connectionRoute: { + color: colors.subtext, + fontSize: 11, + lineHeight: 16, + marginTop: spacing.md, + textAlign: 'center', + }, + disabledBtn: { + opacity: 0.45, + }, printerRow: { flexDirection: 'row', alignItems: 'center', @@ -1198,10 +1273,6 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - addForm: { - gap: spacing.sm, - marginTop: spacing.sm, - }, sectionGrid: { flexDirection: 'row', flexWrap: 'wrap', @@ -1213,6 +1284,11 @@ const styles = StyleSheet.create({ borderRadius: 8, backgroundColor: colors.cardAlt, padding: spacing.sm, + flexDirection: 'column', + justifyContent: 'space-between', + }, + sectionTileLabel: { + flex: 1, }, sectionTileText: { color: colors.text, @@ -1221,10 +1297,6 @@ const styles = StyleSheet.create({ fontWeight: '800', minHeight: 30, }, - sectionSwitchWrap: { - alignSelf: 'flex-start', - marginTop: 'auto', - }, swatchRow: { flexDirection: 'row', flexWrap: 'wrap', @@ -1303,95 +1375,17 @@ const styles = StyleSheet.create({ marginVertical: spacing.md, }, saveBtn: { - borderRadius: 10, - paddingVertical: spacing.lg, - alignItems: 'center', - }, - saveText: { - color: '#fff', - fontSize: 15, - fontWeight: '800', - }, - modalWrap: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.62)', - justifyContent: 'flex-end', - }, - modalCard: { - maxHeight: '88%', - backgroundColor: colors.bg, - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - borderWidth: 1, - borderColor: colors.border, - padding: spacing.lg, - }, - modalHeader: { + height: 56, + borderRadius: 999, + backgroundColor: COCKPIT.accentFill, flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.md, - marginBottom: spacing.md, - }, - modalTitleRow: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - }, - modalIcon: { - width: 34, - height: 34, - borderRadius: 17, - backgroundColor: colors.card, - borderWidth: 1, - borderColor: colors.border, - alignItems: 'center', justifyContent: 'center', + gap: 9, }, - modalTitle: { - flex: 1, - color: colors.text, - fontSize: 16, - fontWeight: '800', - }, - modalContent: { - gap: spacing.md, - paddingBottom: spacing.md, - }, - modalActions: { - flexDirection: 'row', - gap: spacing.sm, - paddingTop: spacing.md, - }, - secondaryAction: { - flex: 1, - minHeight: 46, - borderRadius: 8, - backgroundColor: colors.cardAlt, - borderWidth: 1, - borderColor: colors.border, - alignItems: 'center', - justifyContent: 'center', - }, - secondaryActionText: { - color: colors.text, - fontSize: 13, - fontWeight: '800', - }, - primaryAction: { - flex: 1, - minHeight: 46, - borderRadius: 8, - backgroundColor: colors.primary, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - }, - primaryActionText: { - color: '#fff', - fontSize: 13, + saveText: { + color: COCKPIT.onAccent, + fontSize: 15, fontWeight: '800', }, }); diff --git a/app/(tabs)/slicer.tsx b/app/(tabs)/slicer.tsx index b8af267..0500570 100644 --- a/app/(tabs)/slicer.tsx +++ b/app/(tabs)/slicer.tsx @@ -1,18 +1,31 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, - Alert, - Image, + Pressable, RefreshControl, ScrollView, StyleSheet, Text, - TouchableOpacity, + useWindowDimensions, View, } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useFocusEffect, useRouter } from 'expo-router'; -import { colors, spacing } from '../../constants/theme'; +import { colors } from '../../constants/theme'; +import { alpha, COCKPIT as P } from '../../components/dashboard/shared'; +import { ProgressBar } from '../../components/ui/progress'; +import { formatDuration } from '../../components/PrintProgress'; +import FilamentEditor from '../../components/dashboard/parts/FilamentEditor'; +import { + ActionBar, + Banner, + HeroCard, + PlateStrip, + Secondary, + StatRow, + ToolRail, +} from '../../components/slicer/parts'; import { addExtractProgressListener, clearLastSlice, @@ -33,27 +46,32 @@ import { openNativeModelPreview, injectTimelapseMacros, pickModelFile, - setFilamentSlotColors, + setFilamentSlots, + type NativeFilamentSlot, collapseModelToTool, remapModelExtruders, sliceModelFile, setNativePrinters, SharedMakerWorldLink, uploadGcodeToPrinter, + getModelPlateStats, + type ModelPlateStats, type SharedModelFile, } from '../../services/nativeSlicer'; import { useMoonraker } from '../../hooks/useMoonraker'; +import { fetchMakerWorldPlateStats } from '../../services/makerWorld'; import { useACE } from '../../hooks/useACE'; import type { AceUnit } from '../../hooks/useACE'; import { useSettings } from '../../hooks/useSettings'; -import FilamentSlotsEditor, { type FilamentSlotDisplay } from '../../components/FilamentSlotsEditor'; +import { type FilamentSlotDisplay } from '../../components/FilamentSlotsEditor'; import { normalizeFilamentSlotColors } from '../../constants/filamentColors'; import { takeMwDownload } from '../../services/mwBus'; import { subscribePendingModel, takePendingModel } from '../../services/pendingModel'; import { setPrintSentNotice } from '../../services/printSentBus'; import PrintPreprocessDialog, { type PrintPref } from '../../components/PrintPreprocessDialog'; -import { api, printerConnectionUrl, thumbnailUrl } from '../../services/moonraker'; +import { api, normalizeMoonrakerUrl, printerConnectionUrl, thumbnailUrl } from '../../services/moonraker'; import { resolveNativeMaterialProfiles } from '../../services/filamentProfiles'; +import { useThemedAlert } from '../../hooks/useThemedAlert'; const MW_DESIGN_RE = /(?:https?:\/\/)?(?:www\.)?makerworld\.com\/(?:\w+\/)?models\/(\d+)/i; // The specific print profile/instance the user is viewing, e.g. @@ -115,6 +133,8 @@ type ToolLoadInfo = { blockReason: string | null; }; +const PAGE = 16; + const EXTRACT_SAYINGS = [ 'Slicing the un-sliceable…', 'Convincing triangles to behave…', @@ -130,6 +150,7 @@ const EXTRACT_SAYINGS = [ export default function SliceLabScreen() { const router = useRouter(); + const { showAlert, alertDialog } = useThemedAlert(); const [result, setResult] = useState({ state: 'loading' }); const [sharedLink, setSharedLink] = useState(null); const [download, setDownload] = useState({ @@ -144,6 +165,17 @@ export default function SliceLabScreen() { const [plates, setPlates] = useState([]); const [selectedPlate, setSelectedPlate] = useState<{ id: number; name: string } | null>(null); const [platesFor, setPlatesFor] = useState(null); + // Embedded 3MF plate render, shown in the hero as soon as a model is picked — + // before any slicing happens. Null for STLs and 3MFs without thumbnails. + const [modelThumb, setModelThumb] = useState(null); + // Slice stats baked into the 3MF by the original slicer (MakerWorld etc.) — + // lets the stat cards show real numbers before any in-app slice. Empty for + // geometry-only 3MFs and STLs. + const [modelStats, setModelStats] = useState([]); + // MakerWorld API stats for in-app downloads (the 3MFs usually lack embedded + // plate G-code, so the native scan above finds nothing). Keyed by file path + // so stats from a previous download can't bleed into a freshly opened file. + const [mwStats, setMwStats] = useState<{ forPath: string; stats: ModelPlateStats[] } | null>(null); const [extracting, setExtracting] = useState(false); const [extractProgress, setExtractProgress] = useState<{ percent: number; phase: string } | null>(null); const [sayingIdx, setSayingIdx] = useState(0); @@ -166,8 +198,6 @@ export default function SliceLabScreen() { const { activeUrl, connection, status, objectList } = useMoonraker(); const ace = useACE(); const { settings, update: updateSettings, loaded: settingsLoaded } = useSettings(); - const selectedPrinter = settings.printers.find((printer) => printer.id === settings.activePrinterId); - const selectedPrinterUrl = selectedPrinter ? printerConnectionUrl(selectedPrinter) : ''; const toolLoad = useMemo( () => resolveToolLoad(status, objectList, ace.units, ace.hardwareDetected, connection), [status, objectList, ace.units, ace.hardwareDetected, connection], @@ -187,11 +217,21 @@ export default function SliceLabScreen() { [filamentSlots], ); - // Keep native paint/preview prefs aligned with the saved slot colours. + // Keep native prefs aligned with the full per-slot filament picture so the + // print preprocess sheet can label each lane exactly as the RN slicer does. useEffect(() => { if (!settingsLoaded) return; - setFilamentSlotColors(effectiveFilamentSlotColors).catch(() => {}); - }, [settingsLoaded, effectiveFilamentSlotColors]); + setFilamentSlots( + filamentSlots.map((slot): NativeFilamentSlot => ({ + color: slot.color, + material: slot.material, + mainType: slot.mainType ?? '', + subType: slot.subType ?? '', + brand: slot.brand ?? '', + status: slot.status, + })), + ).catch(() => {}); + }, [settingsLoaded, filamentSlots]); // Rotate the playful "sayings" while the prepare overlay is up so there's // always motion even between native progress ticks. @@ -202,12 +242,20 @@ export default function SliceLabScreen() { }, [extracting]); // Mirror the printer list for the native print dialog's printer picker. + // Send BOTH the preferred url and the alternate (LAN ↔ Tailscale) so the + // native dialog can fail over when the user is away from home (LAN down). useEffect(() => { if (!settingsLoaded) return; setNativePrinters( settings.printers - .map((p) => ({ name: p.name, url: printerConnectionUrl(p) })) - .filter((p) => p.url), + .map((p) => { + const lan = normalizeMoonrakerUrl(p.url || ''); + const tailscale = normalizeMoonrakerUrl(p.tailscaleUrl || ''); + const primary = printerConnectionUrl(p); + const alternate = primary === lan ? tailscale : lan; + return { name: p.name, url: primary, tailscaleUrl: alternate }; + }) + .filter((p) => p.url || p.tailscaleUrl), ).catch(() => {}); }, [settingsLoaded, settings.printers]); @@ -235,6 +283,7 @@ export default function SliceLabScreen() { setPlates([]); setSelectedPlate(null); setPlatesFor(null); + setMwStats(null); setDownload({ state: 'success', message: `Opened ${openedFile.fileName}.`, @@ -255,9 +304,9 @@ export default function SliceLabScreen() { } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/cancel/i.test(message)) return; - Alert.alert('Upload', message); + showAlert({ title: 'Upload', message, icon: 'alert-circle-outline' }); } - }, [applyOpenedFile]); + }, [applyOpenedFile, showAlert]); const clearModel = useCallback(() => { handledUrlRef.current = null; @@ -269,6 +318,9 @@ export default function SliceLabScreen() { setPlates([]); setSelectedPlate(null); setPlatesFor(null); + setModelThumb(null); + setModelStats([]); + setMwStats(null); setDownload({ state: 'idle', message: '' }); }, []); @@ -338,6 +390,7 @@ export default function SliceLabScreen() { setUpload({ state: 'idle' }); setPrintStart({ state: 'idle' }); clearLastSlice().catch(() => {}); + setMwStats(null); setDownload({ state: 'downloading', message: 'Opening MakerWorld…' }); try { const designId = MW_DESIGN_RE.exec(url)?.[1]; @@ -355,6 +408,17 @@ export default function SliceLabScreen() { message: 'Model ready.', result: downloaded, }); + // The 3MF itself usually has no embedded slice stats — the design API + // does (time + filament per plate). Layers stay '--': not published. + const plateStats = await fetchMakerWorldPlateStats(designId, instanceId || null); + setMwStats( + plateStats.length > 0 + ? { + forPath: downloaded.filePath, + stats: plateStats.map((s) => ({ ...s, layers: 0 })), + } + : null + ); } catch (error) { handledUrlRef.current = null; const message = error instanceof Error ? error.message : String(error); @@ -497,6 +561,8 @@ export default function SliceLabScreen() { setPlates([]); setSelectedPlate(null); setPlatesFor(null); + setModelThumb(null); + setModelStats([]); return; } if (platesFor === modelFilePath) return; @@ -506,11 +572,20 @@ export default function SliceLabScreen() { setPlatesFor(modelFilePath); setPlates(found.length > 1 ? found : []); setSelectedPlate(null); + setModelThumb(found[0]?.thumbnail ?? null); }) .catch(() => { if (!active) return; setPlates([]); setSelectedPlate(null); + setModelThumb(null); + }); + getModelPlateStats(modelFilePath) + .then((stats) => { + if (active) setModelStats(stats); + }) + .catch(() => { + if (active) setModelStats([]); }); return () => { active = false; @@ -523,6 +598,7 @@ export default function SliceLabScreen() { // Selection only — extraction happens in prepareAndSlice so tapping a // plate card is instant instead of blocking on the native repack. setSelectedPlate({ id: plate.id, name: plate.name }); + setModelThumb(plate.thumbnail ?? null); setSlice({ state: 'idle' }); setUpload({ state: 'idle' }); setPrintStart({ state: 'idle' }); @@ -533,11 +609,11 @@ export default function SliceLabScreen() { const prepareAndSlice = useCallback(async () => { if (download.state !== 'success') return; if (plates.length > 1 && !selectedPlate) { - Alert.alert('Plates', 'This model has multiple plates — pick one to slice first.'); + showAlert({ title: 'Plates', message: 'This model has multiple plates — pick one to slice first.' }); return; } if (toolLoad.blockReason) { - Alert.alert('Filament', toolLoad.blockReason); + showAlert({ title: 'Filament', message: toolLoad.blockReason, icon: 'alert-circle-outline' }); return; } let path = download.result.filePath; @@ -572,129 +648,17 @@ export default function SliceLabScreen() { materialProfiles, ); } catch (error) { - Alert.alert('Prepare & Slice', error instanceof Error ? error.message : String(error)); + showAlert({ + title: 'Prepare & Slice', + message: error instanceof Error ? error.message : String(error), + icon: 'alert-circle-outline', + }); } finally { sub.remove(); setExtractProgress(null); setExtracting(false); } - }, [activeUrl, connection, download, effectiveFilamentSlotColors, filamentSlots, toolLoad, plates, selectedPlate]); - - const updateFilamentSlots = useCallback( - async (next: string[], changedIndex?: number) => { - const normalized = normalizeFilamentSlotColors(next); - await updateSettings({ filamentSlotColors: normalized }); - try { - await setFilamentSlotColors(normalized); - } catch { - // Native module unavailable on non-Android — settings still saved. - } - if (activeUrl) { - try { - const channels = changedIndex == null ? normalized.map((_, index) => index) : [changedIndex]; - await Promise.all(channels.map((channel) => api.setFilamentSlot( - activeUrl, - channel, - { - VENDOR: settings.filamentSlotBrands[channel] || 'Generic', - MAIN_TYPE: settings.filamentSlotMaterials[channel] || 'PLA', - SUB_TYPE: settings.filamentSlotSubtypes[channel] || status.filament_detect?.info?.[channel]?.SUB_TYPE || 'Basic', - RGB_1: parseInt(normalized[channel].replace('#', '').slice(0, 6), 16), - ALPHA: 255, - }, - ))); - } catch (error) { - Alert.alert('Printer update unavailable', error instanceof Error ? error.message : 'Helix saved the value locally.'); - } - } - }, - [activeUrl, settings.filamentSlotBrands, settings.filamentSlotMaterials, settings.filamentSlotSubtypes, status, updateSettings], - ); - - const updateFilamentMaterials = useCallback( - async (next: string[], changedIndex?: number) => { - const normalized = Array.from({ length: 4 }, (_, i) => { - const value = next[i]?.trim().toUpperCase(); - return value || settings.filamentSlotMaterials[i] || 'PLA'; - }); - await updateSettings({ filamentSlotMaterials: normalized }); - if (activeUrl) { - try { - const channels = changedIndex == null ? normalized.map((_, index) => index) : [changedIndex]; - await Promise.all(channels.map((channel) => api.setFilamentSlot( - activeUrl, - channel, - { - VENDOR: settings.filamentSlotBrands[channel] || 'Generic', - MAIN_TYPE: normalized[channel], - SUB_TYPE: settings.filamentSlotSubtypes[channel] || status.filament_detect?.info?.[channel]?.SUB_TYPE || 'Basic', - RGB_1: parseInt(normalizeFilamentSlotColors(settings.filamentSlotColors)[channel].replace('#', '').slice(0, 6), 16), - ALPHA: 255, - }, - ))); - } catch (error) { - Alert.alert('Printer update unavailable', error instanceof Error ? error.message : 'Helix saved the value locally.'); - } - } - }, - [activeUrl, settings.filamentSlotBrands, settings.filamentSlotColors, settings.filamentSlotMaterials, settings.filamentSlotSubtypes, status, updateSettings], - ); - - const updateFilamentBrands = useCallback( - async (next: string[], changedIndex?: number) => { - await updateSettings({ filamentSlotBrands: next }); - const printerUrl = activeUrl || selectedPrinterUrl; - if (printerUrl) { - try { - const channels = changedIndex == null ? next.map((_, index) => index) : [changedIndex]; - await Promise.all(channels.map((channel) => api.setFilamentSlot( - printerUrl, - channel, - { - VENDOR: next[channel] || 'Generic', - MAIN_TYPE: settings.filamentSlotMaterials[channel] || 'PLA', - SUB_TYPE: settings.filamentSlotSubtypes[channel] || status.filament_detect?.info?.[channel]?.SUB_TYPE || 'Basic', - RGB_1: parseInt(normalizeFilamentSlotColors(settings.filamentSlotColors)[channel].replace('#', '').slice(0, 6), 16), - ALPHA: 255, - }, - ))); - } catch (error) { - Alert.alert('Printer update unavailable', error instanceof Error ? error.message : 'Helix saved the value locally.'); - } - } - }, - [activeUrl, selectedPrinterUrl, settings.filamentSlotBrands, settings.filamentSlotColors, settings.filamentSlotMaterials, settings.filamentSlotSubtypes, status, updateSettings], - ); - - const updateFilamentSubtypes = useCallback( - async (next: string[], changedIndex?: number) => { - const normalized = Array.from({ length: 4 }, (_, i) => { - const value = next[i]?.trim(); - return value || settings.filamentSlotSubtypes[i] || 'Basic'; - }); - await updateSettings({ filamentSlotSubtypes: normalized }); - const printerUrl = activeUrl || selectedPrinterUrl; - if (printerUrl) { - try { - const channels = changedIndex == null ? normalized.map((_, index) => index) : [changedIndex]; - await Promise.all(channels.map((channel) => api.setFilamentSlot( - printerUrl, - channel, - { - VENDOR: settings.filamentSlotBrands[channel] || 'Generic', - MAIN_TYPE: settings.filamentSlotMaterials[channel] || 'PLA', - SUB_TYPE: normalized[channel], - RGB_1: parseInt(normalizeFilamentSlotColors(settings.filamentSlotColors)[channel].replace('#', '').slice(0, 6), 16), - ALPHA: 255, - }, - ))); - } catch (error) { - Alert.alert('Printer update unavailable', error instanceof Error ? error.message : 'Helix saved the value locally.'); - } - } - }, - [activeUrl, selectedPrinterUrl, settings.filamentSlotBrands, settings.filamentSlotColors, settings.filamentSlotMaterials, settings.filamentSlotSubtypes, updateSettings], - ); + }, [activeUrl, connection, download, effectiveFilamentSlotColors, filamentSlots, showAlert, toolLoad, plates, selectedPlate]); const openToolpathPreview = useCallback(async () => { if (slice.state !== 'success') return; @@ -711,9 +675,13 @@ export default function SliceLabScreen() { slice.result.usedToolMask ?? (1 << initialTool), ); } catch (error) { - Alert.alert('Toolpath Preview', error instanceof Error ? error.message : String(error)); + showAlert({ + title: 'Toolpath Preview', + message: error instanceof Error ? error.message : String(error), + icon: 'alert-circle-outline', + }); } - }, [activeUrl, connection, download, slice, toolLoad]); + }, [activeUrl, connection, download, showAlert, slice, toolLoad]); const openPreprocess = useCallback(() => { if (slice.state !== 'success') return; @@ -811,8 +779,8 @@ export default function SliceLabScreen() { } gcodePath = resliced.gcodePath; } else { - // Per-colour remap: keep multi-colour, route each file colour to its slot. - setPrintStart({ state: 'starting', message: 'Re-slicing per-colour…' }); + // Per-color remap: keep multi-color, route each file color to its slot. + setPrintStart({ state: 'starting', message: 'Re-slicing per-color…' }); setSendProgress(0.05); const extruderMap: Record = {}; for (const ft of fileTools) extruderMap[ft + 1] = fullTarget[ft]; @@ -917,6 +885,18 @@ export default function SliceLabScreen() { const printerReady = connection === 'connected' && Boolean(activeUrl); const hasModel = download.state === 'success'; const sliced = slice.state === 'success'; + // Pre-slice stats: the file's own embedded G-code first (any 3MF), then the + // MakerWorld API numbers for in-app downloads. Selected plate wins, then + // plate 1 — real numbers on the cards before anything is sliced here. + const plateStats = + modelStats.length > 0 + ? modelStats + : mwStats && mwStats.forPath === modelFilePath + ? mwStats.stats + : []; + const embeddedStats = sliced + ? null + : plateStats.find((s) => s.id === selectedPlate?.id) ?? plateStats[0] ?? null; const slicedInitialTool = slice.state === 'success' ? slice.result.initialTool ?? toolLoad.selectedTool : toolLoad.selectedTool; @@ -957,205 +937,220 @@ export default function SliceLabScreen() { }; }, [slicedGcodePath]); - return ( - <> - } - > - - Filaments - T0-T3 filament colors and materials. - - - {toolLoad.known - ? `Single-colour slices use T${toolLoad.selectedTool}.` - : 'Filament load is unknown until Helix can read the printer.'} - - {toolLoad.blockReason ? ( - {toolLoad.blockReason} - ) : null} - + const [editingSlot, setEditingSlot] = useState(null); + const { width } = useWindowDimensions(); + const heroHeight = Math.round((width - PAGE * 2) * (9 / 16)); + + const heroState = !hasModel + ? download.state === 'downloading' + ? { label: 'IMPORTING', color: P.warn } + : download.state === 'error' + ? { label: 'IMPORT FAILED', color: P.danger } + : { label: 'NO MODEL', color: P.dim } + : extracting + ? { label: 'PREPARING', color: P.warn } + : sliced + ? { label: 'SLICED', color: P.success } + : toolLoad.blockReason + ? { label: 'BLOCKED', color: P.danger } + : { label: 'READY TO SLICE', color: P.accent }; + + // The pinned action says why it can't run rather than just going grey. The + // order matters: the reason nearest the user's next tap wins. + const primaryAction = (() => { + if (!hasModel) { + const importing = download.state === 'downloading'; + return { + icon: 'tray-arrow-up' as const, + label: importing ? 'Importing…' : 'Upload .3mf / .stl', + enabled: !importing, + onPress: pickLocalModel, + }; + } + if (extracting) { + return { icon: 'progress-clock' as const, label: 'Preparing…', enabled: false, onPress: noop }; + } + if (sliced) { + return { + icon: 'printer-3d' as const, + label: printerReady ? 'Upload & Print' : 'Printer offline', + enabled: printerReady, + onPress: openPreprocess, + }; + } + if (toolLoad.blockReason) { + return { + icon: 'alert-circle-outline' as const, + label: 'Load filament to slice', + enabled: false, + onPress: noop, + }; + } + if (!ready) { + return { icon: 'progress-wrench' as const, label: 'Slicer starting…', enabled: false, onPress: noop }; + } + if (plates.length > 1 && !selectedPlate) { + return { icon: 'cube-scan' as const, label: 'Pick a plate above', enabled: false, onPress: noop }; + } + return { icon: 'cube-scan' as const, label: 'Prepare & Slice', enabled: true, onPress: prepareAndSlice }; + })(); - - Model - - - Upload .3mf / .stl - - + + + } > - {download.message} - - {download.state === 'downloading' ? ( - - - Importing… - - ) : null} - {hasModel ? ( - - - {download.result.fileName} - - - - - - ) : null} - {!mwAuthed ? ( - - MakerWorld login is in{' '} - router.push('/settings')}> - Settings - - {' '}— required to import shared models. - - ) : null} - {sharedLink?.makerWorldUrl && !hasModel && download.state !== 'downloading' ? ( - startDownload(sharedLink.makerWorldUrl!, true)} - activeOpacity={0.85} - > - - Import from link - - ) : null} - {hasModel && plates.length > 1 ? ( - - - {plates.length} plates — pick one to slice - - + + + ) : ( + + )} + + {extracting && extractProgress ? ( + + ) : null} + + {toolLoad.blockReason ? ( + + ) : null} + + {sliced && missingPrintTools ? ( + + ) : null} + + {!ready ? ( + + ) : null} + + {!mwAuthed ? ( + router.push('/settings')} + /> + ) : null} + + {printStart.state === 'error' ? ( + + ) : null} + + {download.message && !toolLoad.blockReason ? ( + - {plates.map((plate) => { - const active = selectedPlate?.id === plate.id; - return ( - choosePlate(plate)} - disabled={extracting} - activeOpacity={0.85} - > - {plate.thumbnail ? ( - - ) : ( - - - - )} - {plate.name} - - {plate.objectCount} obj{plate.objectCount === 1 ? '' : 's'} - - {active ? ( - - - - ) : null} - - ); - })} - - - ) : null} - {hasModel ? ( - 1 && !selectedPlate)) && - styles.buttonOff, - ]} - disabled={ - !ready || Boolean(toolLoad.blockReason) || extracting || - (plates.length > 1 && !selectedPlate) - } - onPress={prepareAndSlice} - activeOpacity={0.85} - > - - - {extracting - ? 'Preparing plate…' - : plates.length > 1 && !selectedPlate - ? 'Pick a plate above' - : 'Prepare & Slice'} + {download.message} - - ) : null} - + ) : null} - {sliced && hasModel ? ( - - Send to printer - {sliceThumb ? ( - + {sharedLink?.makerWorldUrl && !hasModel && download.state !== 'downloading' ? ( + startDownload(sharedLink.makerWorldUrl!, true)} + /> ) : null} - - {slice.result.totalLayers} layers · {Math.round(slice.result.estimatedTimeSeconds / 60)} min ·{' '} - {slice.result.estimatedFilamentGrams.toFixed(1)} g - - - - - {printerReady ? 'Upload & Print' : 'Printer offline'} - - - - - Cancel - - {printStart.state === 'error' ? ( - {printStart.message} + + {hasModel && plates.length > 1 ? ( + ) : null} - + + 0 + ? String(embeddedStats.layers) + : null + } + time={ + sliced + ? formatDuration(slice.result.estimatedTimeSeconds) + : embeddedStats && embeddedStats.timeSeconds > 0 + ? formatDuration(embeddedStats.timeSeconds) + : null + } + grams={ + sliced + ? `${slice.result.estimatedFilamentGrams.toFixed(1)} g` + : embeddedStats && embeddedStats.grams > 0 + ? `${embeddedStats.grams.toFixed(1)} g` + : null + } + /> + + + + {sliced ? : null} + + + + + + {editingSlot != null ? ( + setEditingSlot(null)} /> ) : null} - {extracting ? ( + + + {selectedPlate ? `Preparing ${selectedPlate.name}` : 'Preparing model'} @@ -1175,7 +1170,7 @@ export default function SliceLabScreen() { ) : ( <> - + Opening slicer… )} @@ -1211,10 +1206,13 @@ export default function SliceLabScreen() { errorMessage={printStart.state === 'error' ? printStart.message : null} onSend={uploadAndPrint} /> - + {alertDialog} + ); } +function noop() {} + function buildPrinterUploadFilename(sourceName: string | null | undefined, gcodePath: string): string { const source = sourceName?.trim() || fileBaseName(gcodePath) || 'print'; const stem = fileBaseName(source).replace(/\.[^.]+$/, ''); @@ -1471,368 +1469,64 @@ function maskToTools(mask: number): string { } const styles = StyleSheet.create({ - screen: { - flex: 1, - backgroundColor: colors.bg, - }, - content: { - padding: spacing.lg, - paddingBottom: spacing.xl + 80, - gap: spacing.md, - }, - card: { - backgroundColor: colors.card, - borderColor: colors.border, - borderWidth: 1, - borderRadius: 8, - padding: spacing.md, - gap: spacing.sm, - }, - loadingRow: { - minHeight: 48, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - }, + root: { flex: 1, backgroundColor: P.bg }, + flex: { flex: 1 }, + // Bottom padding clears the pinned action bar. + content: { padding: PAGE, paddingBottom: 108, gap: 11, flexGrow: 1 }, + status: { fontSize: 12, fontWeight: '700' }, + + // Prepare overlay, restyled into the Cockpit palette with the rest of the tab. + // Kept as a full-screen block rather than folded inline: extraction locks the + // screen anyway, and the sayings need somewhere to live. prepareOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(8, 10, 12, 0.82)', - justifyContent: 'center', + ...StyleSheet.absoluteFillObject, + backgroundColor: alpha('#000000', 0.74), alignItems: 'center', - padding: spacing.lg, + justifyContent: 'center', + padding: 28, }, prepareCard: { - backgroundColor: colors.card, - borderColor: colors.border, + width: '100%', + maxWidth: 380, + borderRadius: 24, borderWidth: 1, - borderRadius: 14, - padding: spacing.lg, - width: '82%', - maxWidth: 340, + borderColor: P.border, + backgroundColor: P.surface, + padding: 22, + gap: 13, + alignItems: 'center', + }, + prepareIcon: { + width: 52, + height: 52, + borderRadius: 26, alignItems: 'center', - gap: spacing.md, + justifyContent: 'center', + backgroundColor: alpha(P.accent, 0.12), }, prepareTitle: { - color: colors.text, - fontSize: 15, - lineHeight: 19, + color: P.text, + fontSize: 19, fontWeight: '800', + letterSpacing: -0.4, textAlign: 'center', }, + prepareSub: { color: P.dim, fontSize: 13, fontWeight: '600' }, progressTrack: { width: '100%', height: 8, borderRadius: 4, - backgroundColor: colors.border, + backgroundColor: P.surfaceAlt, overflow: 'hidden', }, - progressFill: { - height: '100%', - borderRadius: 4, - backgroundColor: colors.primary, - }, - progressPct: { - color: colors.text, - fontSize: 22, - lineHeight: 26, - fontWeight: '800', - fontVariant: ['tabular-nums'], - textAlign: 'center', - }, - preparePhase: { - color: colors.subtext, - fontSize: 13, - lineHeight: 17, - fontWeight: '600', - textAlign: 'center', - }, + progressFill: { height: '100%', borderRadius: 4, backgroundColor: P.accent }, + progressPct: { color: P.text, fontSize: 30, fontWeight: '800', letterSpacing: -1 }, + preparePhase: { color: P.accent, fontSize: 12, fontWeight: '800', textAlign: 'center' }, prepareSaying: { - color: colors.subtext, - fontSize: 12, - lineHeight: 16, - fontStyle: 'italic', - fontWeight: '500', - textAlign: 'center', - }, - prepareSub: { - color: colors.subtext, + color: P.dim, fontSize: 12, - lineHeight: 16, fontWeight: '600', + fontStyle: 'italic', textAlign: 'center', }, - body: { - color: colors.text, - fontSize: 13, - lineHeight: 18, - fontWeight: '700', - }, - sectionTitle: { - color: colors.text, - fontSize: 14, - lineHeight: 18, - fontWeight: '800', - }, - linkText: { - color: colors.primary, - fontSize: 13, - lineHeight: 18, - fontWeight: '700', - }, - mutedText: { - color: colors.subtext, - fontSize: 13, - lineHeight: 18, - fontWeight: '700', - }, - loadedRow: { - gap: spacing.xs, - paddingTop: spacing.xs, - }, - loadedLabel: { - color: colors.subtext, - fontSize: 11, - lineHeight: 14, - fontWeight: '800', - textTransform: 'uppercase', - }, - toolBadges: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: spacing.xs, - }, - toolBadge: { - minWidth: 70, - borderRadius: 7, - borderWidth: 1, - paddingHorizontal: spacing.sm, - paddingVertical: 7, - gap: 2, - }, - toolBadgeLoaded: { - backgroundColor: '#13251a', - borderColor: '#245f3b', - }, - toolBadgeEmpty: { - backgroundColor: '#2a1b1b', - borderColor: '#653030', - }, - toolBadgeBusy: { - backgroundColor: '#332a16', - borderColor: '#624f22', - }, - toolBadgeUnknown: { - backgroundColor: colors.cardAlt, - borderColor: colors.border, - }, - toolBadgeName: { - color: colors.text, - fontSize: 12, - lineHeight: 15, - fontWeight: '900', - }, - toolBadgeStatus: { - color: colors.subtext, - fontSize: 11, - lineHeight: 14, - fontWeight: '700', - textTransform: 'capitalize', - }, - mutedValue: { - color: colors.subtext, - }, - rawText: { - color: colors.subtext, - fontSize: 12, - lineHeight: 17, - fontWeight: '600', - }, - previewRow: { - minHeight: 76, - borderRadius: 8, - backgroundColor: colors.cardAlt, - borderColor: colors.border, - borderWidth: 1, - padding: spacing.sm, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - }, - slicePreview: { - width: '100%', - height: 180, - borderRadius: 10, - backgroundColor: '#0d0f12', - marginBottom: spacing.sm, - }, - plateSection: { - marginTop: spacing.sm, - gap: spacing.xs, - }, - plateHeading: { - color: colors.subtext, - fontSize: 13, - fontWeight: '700', - }, - plateRow: { - gap: spacing.sm, - paddingVertical: spacing.xs, - paddingRight: spacing.sm, - }, - plateCard: { - width: 104, - borderRadius: 12, - padding: spacing.xs, - backgroundColor: colors.bg, - borderWidth: 2, - borderColor: 'transparent', - }, - plateCardActive: { - borderColor: colors.primary, - }, - plateThumb: { - width: '100%', - height: 88, - borderRadius: 8, - backgroundColor: '#0d0f12', - }, - platePlaceholder: { - alignItems: 'center', - justifyContent: 'center', - }, - plateName: { - color: colors.text, - fontSize: 12, - fontWeight: '700', - marginTop: 4, - }, - plateMeta: { - color: colors.subtext, - fontSize: 11, - marginTop: 1, - }, - plateCheck: { - position: 'absolute', - top: spacing.xs + 2, - right: spacing.xs + 2, - backgroundColor: colors.bg, - borderRadius: 10, - }, - previewImage: { - width: 58, - height: 58, - borderRadius: 6, - backgroundColor: colors.bg, - }, - previewPlaceholder: { - alignItems: 'center', - justifyContent: 'center', - borderColor: colors.border, - borderWidth: 1, - }, - previewText: { - flex: 1, - gap: 3, - }, - previewTitle: { - color: colors.text, - fontSize: 14, - lineHeight: 18, - fontWeight: '800', - }, - previewPath: { - color: colors.subtext, - fontSize: 11, - lineHeight: 15, - fontWeight: '600', - }, - row: { - gap: 4, - paddingVertical: spacing.sm, - borderBottomColor: colors.border, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - label: { - color: colors.subtext, - fontSize: 11, - lineHeight: 14, - fontWeight: '800', - textTransform: 'uppercase', - }, - value: { - fontSize: 13, - lineHeight: 18, - fontWeight: '700', - }, - good: { - color: colors.success, - }, - bad: { - color: colors.warning, - }, - button: { - minHeight: 46, - borderRadius: 8, - backgroundColor: colors.primary, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'row', - gap: spacing.sm, - }, - buttonOff: { - opacity: 0.4, - }, - secondaryButton: { - minHeight: 42, - borderRadius: 8, - backgroundColor: colors.cardAlt, - borderColor: colors.border, - borderWidth: 1, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'row', - gap: spacing.sm, - }, - buttonText: { - color: colors.text, - fontSize: 13, - lineHeight: 18, - fontWeight: '800', - }, - fileName: { - color: colors.text, - fontSize: 14, - fontWeight: '700', - marginTop: spacing.xs, - }, - modelFileRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - }, - modelFileName: { - flex: 1, - marginTop: 0, - }, - hintText: { - color: colors.subtext, - fontSize: 12, - lineHeight: 16, - marginTop: spacing.sm, - }, - hintLink: { - color: colors.primary, - fontWeight: '700', - textDecorationLine: 'underline', - }, - statsLine: { - color: colors.subtext, - fontSize: 13, - marginBottom: spacing.sm, - }, }); diff --git a/app/(tabs)/spoolman.tsx b/app/(tabs)/spoolman.tsx index b1acf7b..3338454 100644 --- a/app/(tabs)/spoolman.tsx +++ b/app/(tabs)/spoolman.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { - Alert, FlatList, Keyboard, KeyboardAvoidingView, @@ -23,6 +22,7 @@ import SpoolLabel from '../../components/SpoolLabel'; import SpoolScanner from '../../components/SpoolScanner'; import { t } from '../../services/i18n'; import { colors, spacing } from '../../constants/theme'; +import { useThemedAlert } from '../../hooks/useThemedAlert'; interface Vendor { id: number; @@ -109,6 +109,7 @@ function filamentTitle(f: Filament): string { export default function SpoolmanScreen() { const { connection, activeUrl } = useMoonraker(); + const { showAlert, alertDialog } = useThemedAlert(); useSettings(); // re-render on language/theme change const [spools, setSpools] = useState([]); const [filaments, setFilaments] = useState([]); @@ -183,7 +184,7 @@ export default function SpoolmanScreen() { if (changed) await new Promise((r) => setTimeout(r, 8000)); await refresh(); } catch (e: any) { - Alert.alert(t('Error'), String(e?.message ?? e)); + showAlert({ title: t('Error'), message: String(e?.message ?? e), icon: 'alert-circle-outline' }); } finally { setConfiguring(false); } @@ -193,7 +194,7 @@ export default function SpoolmanScreen() { setScanning(false); const spool = spools.find((s) => s.id === id); if (!spool) { - Alert.alert(t('Error'), `${t('No spool with ID')} ${id}`); + showAlert({ title: t('Error'), message: `${t('No spool with ID')} ${id}`, icon: 'alert-circle-outline' }); return; } setActive(spool); @@ -201,20 +202,25 @@ export default function SpoolmanScreen() { const setActive = (spool: Spool | null) => { const label = spool ? spoolTitle(spool) : t('No spool active'); - Alert.alert(t('Set active spool?'), label, [ - { text: t('Cancel'), style: 'cancel' }, - { - text: t('Set'), - onPress: async () => { - try { - const res = await api.spoolmanSetSpoolId(activeUrl, spool?.id ?? null); - setActiveId(res?.spool_id ?? null); - } catch (e: any) { - Alert.alert(t('Error'), String(e?.message ?? e)); - } + showAlert({ + title: t('Set active spool?'), + message: label, + actions: [ + { text: t('Cancel'), onPress: () => {} }, + { + text: t('Set'), + variant: 'primary', + onPress: async () => { + try { + const res = await api.spoolmanSetSpoolId(activeUrl, spool?.id ?? null); + setActiveId(res?.spool_id ?? null); + } catch (e: any) { + showAlert({ title: t('Error'), message: String(e?.message ?? e), icon: 'alert-circle-outline' }); + } + }, }, - }, - ]); + ], + }); }; if (connection === 'connected' && unavailable !== 'none' && !loading) { @@ -259,6 +265,7 @@ export default function SpoolmanScreen() { {t('Retry')} + {alertDialog} ); } @@ -372,6 +379,7 @@ export default function SpoolmanScreen() { /> )} {scanning && setScanning(false)} />} + {alertDialog} ); } @@ -460,12 +468,15 @@ function FormModal({ title, children, onClose, + onPrimary, + primaryDisabled, }: { title: string; children: React.ReactNode; onClose: () => void; + onPrimary: () => void; + primaryDisabled?: boolean; }) { - // Issue #5: bottom sheets draw edge-to-edge, so pad past the Android nav bar. const insets = useSafeAreaInsets(); const scrollRef = useRef(null); const scrollY = useRef(0); @@ -489,19 +500,15 @@ function FormModal({ return () => sub.remove(); }, []); return ( - - {/* Modal hosts its own window, so the activity's keyboard resize doesn't - apply — "padding" is needed on Android too (issue #5). */} + - + {title} - - - { scrollY.current = e.nativeEvent.contentOffset.y; @@ -510,6 +517,21 @@ function FormModal({ > {children} + + + + + {primaryDisabled ? t('Saving...') : t('Save')} + + + + {t('Cancel')} + + @@ -556,6 +578,7 @@ function SpoolFormModal({ proxy: (method: string, path: string, body?: any) => Promise; onClose: (changed: boolean) => void; }) { + const { showAlert, alertDialog } = useThemedAlert(); const editing = !!spool; const [filamentId, setFilamentId] = useState(spool?.filament?.id ?? null); const [remaining, setRemaining] = useState( @@ -574,19 +597,16 @@ function SpoolFormModal({ if (!isFinite(g) || g <= 0) return; try { const res = await proxy('PUT', `/v1/spool/${spool!.id}/measure`, { weight: g }); - Alert.alert( - t('Saved'), - `${t('Remaining')}: ${Math.round(res?.remaining_weight ?? 0)} g` - ); + showAlert({ title: t('Saved'), message: `${t('Remaining')}: ${Math.round(res?.remaining_weight ?? 0)} g`, icon: 'check-circle-outline' }); onClose(true); } catch (e: any) { - Alert.alert(t('Error'), String(e?.message ?? e)); + showAlert({ title: t('Error'), message: String(e?.message ?? e), icon: 'alert-circle-outline' }); } }; const save = async () => { if (!filamentId) { - Alert.alert(t('Error'), t('Pick a filament first')); + showAlert({ title: t('Error'), message: t('Pick a filament first'), icon: 'alert-circle-outline' }); return; } setSaving(true); @@ -600,7 +620,7 @@ function SpoolFormModal({ else await proxy('POST', '/v1/spool', body); onClose(true); } catch (e: any) { - Alert.alert(t('Error'), String(e?.message ?? e)); + showAlert({ title: t('Error'), message: String(e?.message ?? e), icon: 'alert-circle-outline' }); setSaving(false); } }; @@ -610,30 +630,40 @@ function SpoolFormModal({ await proxy('PATCH', `/v1/spool/${spool!.id}`, { archived: !spool!.archived }); onClose(true); } catch (e: any) { - Alert.alert(t('Error'), String(e?.message ?? e)); + showAlert({ title: t('Error'), message: String(e?.message ?? e), icon: 'alert-circle-outline' }); } }; const del = () => { - Alert.alert(t('Delete spool?'), spoolTitle(spool!), [ - { text: t('Cancel'), style: 'cancel' }, - { - text: t('Delete'), - style: 'destructive', - onPress: async () => { - try { - await proxy('DELETE', `/v1/spool/${spool!.id}`); - onClose(true); - } catch (e: any) { - Alert.alert(t('Error'), String(e?.message ?? e)); - } + showAlert({ + title: t('Delete spool?'), + message: spoolTitle(spool!), + actions: [ + { text: t('Cancel'), onPress: () => {} }, + { + text: t('Delete'), + variant: 'danger', + onPress: async () => { + try { + await proxy('DELETE', `/v1/spool/${spool!.id}`); + onClose(true); + } catch (e: any) { + showAlert({ title: t('Error'), message: String(e?.message ?? e), icon: 'alert-circle-outline' }); + } + }, }, - }, - ]); + ], + }); }; return ( - onClose(false)}> + <> + onClose(false)} + onPrimary={save} + primaryDisabled={saving} + > {filaments.length ? ( - - {t('Save')} - {editing && ( <> {t('Weigh the spool')} @@ -708,7 +731,9 @@ function SpoolFormModal({ )} - + + {alertDialog} + ); } @@ -723,6 +748,7 @@ function FilamentFormModal({ proxy: (method: string, path: string, body?: any) => Promise; onClose: (changed: boolean) => void; }) { + const { showAlert, alertDialog } = useThemedAlert(); const editing = !!filament; const [name, setName] = useState(filament?.name ?? ''); const [material, setMaterial] = useState(filament?.material ?? 'PLA'); @@ -787,7 +813,7 @@ function FilamentFormModal({ const save = async () => { if (!name.trim()) { - Alert.alert(t('Error'), t('Name is required')); + showAlert({ title: t('Error'), message: t('Name is required'), icon: 'alert-circle-outline' }); return; } setSaving(true); @@ -812,16 +838,19 @@ function FilamentFormModal({ else await proxy('POST', '/v1/filament', body); onClose(true); } catch (e: any) { - Alert.alert(t('Error'), String(e?.message ?? e)); + showAlert({ title: t('Error'), message: String(e?.message ?? e), icon: 'alert-circle-outline' }); setSaving(false); } }; return ( - onClose(false)} - > + <> + onClose(false)} + onPrimary={save} + primaryDisabled={saving} + > {!editing && catalog.length > 0 && ( - - {t('Save')} - - + + {alertDialog} + ); } @@ -1088,27 +1112,39 @@ const styles = StyleSheet.create({ }, modalWrap: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.6)', - justifyContent: 'flex-end', + backgroundColor: colors.bg, }, modalCard: { + flex: 1, backgroundColor: colors.bg, - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - padding: spacing.lg, - maxHeight: '88%', }, modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', alignItems: 'center', - marginBottom: spacing.md, + paddingHorizontal: 26, + paddingTop: 22, + paddingBottom: 12, }, modalTitle: { color: colors.text, - fontSize: 16, - fontWeight: '700', + fontSize: 30, + fontWeight: '800', + letterSpacing: -1, + textAlign: 'center', + }, + modalBody: { paddingHorizontal: 26, paddingBottom: 24 }, + modalActions: { paddingHorizontal: 22, gap: 9, paddingTop: 8 }, + modalAction: { + height: 62, + borderRadius: 999, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, }, + modalPrimary: { backgroundColor: colors.primary }, + modalCancel: { backgroundColor: colors.cardAlt }, + modalPrimaryText: { color: '#fff', fontSize: 16, fontWeight: '800' }, + modalCancelText: { color: colors.text, fontSize: 16, fontWeight: '800' }, field: { marginBottom: spacing.md, }, @@ -1143,18 +1179,6 @@ const styles = StyleSheet.create({ color: colors.subtext, fontSize: 12, }, - saveBtn: { - borderRadius: 8, - paddingVertical: spacing.md, - alignItems: 'center', - marginTop: spacing.sm, - marginBottom: spacing.sm, - }, - saveText: { - color: '#fff', - fontWeight: '700', - fontSize: 14, - }, measureRow: { flexDirection: 'row', gap: spacing.sm, diff --git a/app/(tabs)/tools.tsx b/app/(tabs)/tools.tsx index 6a634ab..5a8cac0 100644 --- a/app/(tabs)/tools.tsx +++ b/app/(tabs)/tools.tsx @@ -1,119 +1,200 @@ -import React from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +// Tools — Rail's categories, Nova's connected rows, Cockpit's colour. +// +// Rail's contribution is the grouping: tools sit under Printer / Materials / +// Diagnostics headings instead of one undifferentiated list, so you look in a +// category rather than reading five titles. Nova's contribution is the rows +// themselves — each group is one card with hairline-separated rows, nothing +// boxed inside anything else. +// +// One accent throughout. Colour-coding the groups was doing the same job as the +// headings twice, and a rainbow of edges says "these differ" without saying how. +// +// Each row spends its second line on live state rather than a description you +// read once. bed_mesh and print_task_config are both subscribed, so those are +// real. Spoolman and Console aren't probed anywhere — a green "Connected" for +// them would be invented, so they keep a static descriptor and no dot. +import React, { useMemo } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { colors, spacing } from '../../constants/theme'; +import { alpha, COCKPIT as P, Dot, SectionLabel, type IconName } from '../../components/dashboard/shared'; +import { useMoonraker } from '../../hooks/useMoonraker'; +import { t } from '../../services/i18n'; -type IconName = React.ComponentProps['name']; - -const TOOLS: { +interface Tool { + key: string; title: string; - subtitle: string; + /** Shown when there's no live status for this tool. */ + short: string; icon: IconName; route: string; -}[] = [ - { - title: 'Bed Mesh', - subtitle: 'View the current mesh and surface shape.', - icon: 'grid', - route: '/mesh', - }, +} + +interface Category { + title: string; + tools: Tool[]; +} + +const CATEGORIES: Category[] = [ { - title: 'Spoolman', - subtitle: 'Manage spools, filament usage, and labels.', - icon: 'paper-roll-outline', - route: '/spoolman', + title: 'Printer', + tools: [ + { key: 'mesh', title: 'Bed Mesh', short: 'Surface shape', icon: 'grid', route: '/mesh' }, + { + key: 'ace', + title: 'multiACE', + short: 'Filament lanes', + icon: 'palette-swatch', + route: '/ace', + }, + ], }, { - title: 'Console', - subtitle: 'Send commands and read printer responses.', - icon: 'console', - route: '/console', + title: 'Materials', + tools: [ + { + key: 'spoolman', + title: 'Spoolman', + short: 'Spools, usage and labels', + icon: 'paper-roll-outline', + route: '/spoolman', + }, + ], }, { - title: 'multiACE', - subtitle: 'Load, unload, dry, and switch filament lanes.', - icon: 'palette-swatch', - route: '/ace', + title: 'Diagnostics', + tools: [ + { + key: 'console', + title: 'Console', + short: 'Send commands, read replies', + icon: 'console', + route: '/console', + }, + ], }, ]; +type Tone = 'ok' | 'warn' | 'off'; + +interface Status { + label: string; + tone: Tone; +} + +function toneColor(tone: Tone): string { + if (tone === 'ok') return P.success; + if (tone === 'warn') return P.warn; + return P.dim; +} + export default function ToolsScreen() { const router = useRouter(); + const { connection, status } = useMoonraker(); + const connected = connection === 'connected'; + + const statuses = useMemo>(() => { + if (!connected) { + const off: Status = { label: t('Offline'), tone: 'off' }; + return { mesh: off, ace: off, spoolman: off, console: off }; + } + + const profile: unknown = status.bed_mesh?.profile_name; + const exists: unknown[] = Array.isArray(status.print_task_config?.filament_exist) + ? status.print_task_config.filament_exist + : []; + const loaded = exists.filter(Boolean).length; + + return { + mesh: + typeof profile === 'string' && profile + ? { label: profile, tone: 'ok' } + : { label: t('No mesh loaded'), tone: 'warn' }, + ace: + loaded > 0 + ? { label: `${loaded} ${t('of')} ${exists.length || 4} ${t('lanes')}`, tone: 'ok' } + : { label: t('No filament'), tone: 'warn' }, + spoolman: null, + console: null, + }; + }, [connected, status]); return ( - - - {TOOLS.map((tool) => ( - router.push(tool.route as never)} - > - - - - - {tool.title} - {tool.subtitle} + + + + {t('Tools')} + + {CATEGORIES.map((category) => ( + + {t(category.title)} + + + {category.tools.map((tool, index) => { + const state = statuses[tool.key]; + return ( + router.push(tool.route as never)} + style={({ pressed }) => [ + styles.row, + index > 0 && styles.rowDivided, + pressed && { backgroundColor: alpha(P.accent, 0.07) }, + ]} + > + + + + {t(tool.title)} + + {state ? : null} + + {state ? state.label : t(tool.short)} + + + + + + + ); + })} + - - - ))} - - + ))} + + + ); } const styles = StyleSheet.create({ - screen: { - flex: 1, - backgroundColor: colors.bg, - }, - content: { - padding: spacing.lg, - paddingBottom: spacing.xl + 80, - gap: spacing.md, - }, - grid: { - gap: spacing.sm, - }, - toolCard: { - minHeight: 78, - borderRadius: 8, + root: { flex: 1, backgroundColor: P.bg }, + flex: { flex: 1 }, + content: { padding: 16, paddingBottom: 40, gap: 18 }, + + title: { color: P.text, fontSize: 26, fontWeight: '800', letterSpacing: -0.7, marginBottom: -4 }, + + category: { gap: 9 }, + card: { + borderRadius: P.radius, borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.card, - padding: spacing.md, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, + borderColor: P.border, + backgroundColor: P.surface, + overflow: 'hidden', }, - iconBox: { - width: 46, - height: 46, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.cardAlt, + row: { + flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - }, - toolText: { - flex: 1, - gap: 3, - }, - toolTitle: { - color: colors.text, - fontSize: 15, - lineHeight: 19, - fontWeight: '800', - }, - toolSubtitle: { - color: colors.subtext, - fontSize: 12, - lineHeight: 16, - fontWeight: '600', + gap: 15, + paddingHorizontal: 16, + height: 78, }, + // Hairline rather than a full border: the rows are one card, and a heavy + // rule between them would break that. + rowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: P.border }, + rowText: { flex: 1, gap: 4 }, + rowTitle: { color: P.text, fontSize: 16, fontWeight: '800', letterSpacing: -0.2 }, + statusLine: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + rowSub: { flex: 1, color: P.dim, fontSize: 12, fontWeight: '600' }, }); diff --git a/assets/icons/printer.svg b/assets/icons/printer.svg new file mode 100644 index 0000000..5b5064c --- /dev/null +++ b/assets/icons/printer.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/CameraFeed.tsx b/components/CameraFeed.tsx index 1801ac8..30b93d5 100644 --- a/components/CameraFeed.tsx +++ b/components/CameraFeed.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Image, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library'; import * as ScreenOrientation from 'expo-screen-orientation'; @@ -222,6 +223,12 @@ var SRC = ${JSON.stringify(snapshotUrl)}; var loading = false; var requestTimer = null; var paused = window.__helixScreenPaused === true; +// Polling the printer's screen service too fast overloads its touchscreen +// (the physical panel can freeze). Idle gently; burst briefly after a touch +// so the mirror reflects taps/drags, then back off. +var IDLE_MS = 1500, FAST_MS = 160, FAST_WINDOW_MS = 2500; +var fastUntil = 0, lastPoll = 0; +function armFastBurst() { fastUntil = Date.now() + FAST_WINDOW_MS; } function bust(u) { return u + '?n=' + Date.now(); } function setStatus(t) { statusEl.textContent = t; statusEl.style.display = t ? 'block' : 'none'; } function finish(ok) { @@ -244,12 +251,18 @@ window.helixSetScreenPaused = function (nextPaused) { setStatus(''); return; } + armFastBurst(); + lastPoll = Date.now(); poll(); }; -setInterval(function () { - if (!paused) poll(); -}, 100); -if (!paused) poll(); +function adaptiveTick() { + if (paused) return; + var now = Date.now(); + var gap = now < fastUntil ? FAST_MS : IDLE_MS; + if (now - lastPoll >= gap) { lastPoll = now; poll(); } +} +setInterval(adaptiveTick, 100); +if (!paused) { lastPoll = Date.now(); poll(); } function getImageCoords(clientX, clientY) { var rect = img.getBoundingClientRect(); if (!rect.width || !rect.height || !img.naturalWidth || !img.naturalHeight) return null; @@ -280,6 +293,7 @@ async function flushTouch() { function sendTouch(action, x, y) { // Keep down/up ordering, but replace queued move events with the newest // coordinates so slow printer responses cannot build a stale touch backlog. + armFastBurst(); pendingTouch = { action: action, x: x, y: y }; flushTouch(); } @@ -354,6 +368,7 @@ export default function CameraFeed({ chromeless, radius, }: Props) { + const insets = useSafeAreaInsets(); const frame = [ chromeless && styles.chromeless, radius != null && { borderRadius: radius }, @@ -575,16 +590,11 @@ export default function CameraFeed({ setNonce((n) => n + 1)}> - - - + {!fullscreen ? ( + + + + ) : null} ); @@ -619,6 +629,13 @@ export default function CameraFeed({ {fullscreen && feed} {statsPanel} {controls} + + + {alertDialog} @@ -699,6 +716,18 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#000', }, + fullscreenClose: { + position: 'absolute', + right: 14, + width: 40, + height: 40, + borderRadius: 20, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: 'rgba(0,0,0,0.66)', + alignItems: 'center', + justifyContent: 'center', + }, statsPanel: { position: 'absolute', left: spacing.sm, diff --git a/components/ControlsPanel.tsx b/components/ControlsPanel.tsx deleted file mode 100644 index f3e1685..0000000 --- a/components/ControlsPanel.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; -import { t } from '../services/i18n'; -import { - displayTemperature, - formatTemperature, - inputTemperatureToCelsius, -} from '../services/temperature'; -import type { TemperatureUnit } from '../services/temperature'; -import { colors, spacing } from '../constants/theme'; -import { useThemedAlert } from '../hooks/useThemedAlert'; - -interface Props { - status: Record; - sendGcode: (script: string) => Promise; - disabled?: boolean; - showPandaBreath?: boolean; - gcodeHelp?: Record; - temperatureUnit?: TemperatureUnit; -} - -const SPEED_STEPS = [0, 25, 50, 75, 100]; -const GENERIC_HEATER_PREFIX = 'heater_generic '; -const PANDA_BREATH_MAX_TEMP = 60; -const PANDA_BREATH_NAME_RE = /(panda|breath|chamber)/i; -const PANDA_AUTO_FILTER_TEMP = 30; -const PANDA_AUTO_HOTBED_TEMP = 80; -const PANDA_DRY_PRESETS = [ - { material: 'PLA', temp: 55, hours: 12 }, - { material: 'PETG', temp: 60, hours: 12 }, -]; - -function genericHeaterName(objectKey: string): string { - return objectKey.startsWith(GENERIC_HEATER_PREFIX) - ? objectKey.slice(GENERIC_HEATER_PREFIX.length) - : objectKey; -} - -function findPandaBreathHeater(status: Record): Record | null { - const heaterKeys = Object.keys(status).filter( - (key) => - key.startsWith(GENERIC_HEATER_PREFIX) && - typeof status[key]?.temperature === 'number' - ); - const namedKey = heaterKeys.find((key) => PANDA_BREATH_NAME_RE.test(genericHeaterName(key))); - const objectKey = namedKey ?? (heaterKeys.length === 1 ? heaterKeys[0] : ''); - if (!objectKey) return null; - - return status[objectKey] ?? {}; -} - -function hasGcode(gcodeHelp: Record | undefined, command: string): boolean { - return Object.prototype.hasOwnProperty.call(gcodeHelp ?? {}, command); -} - -function dryCommand(gcodeHelp: Record | undefined): 'start' | 'run' | '' { - if (hasGcode(gcodeHelp, 'PANDA_BREATH_DRY_START')) return 'start'; - if (hasGcode(gcodeHelp, 'PANDA_BREATH_DRY_RUN')) return 'run'; - return ''; -} - -function defaultTargetInput(celsius: number, unit: TemperatureUnit): string { - return Math.round(displayTemperature(celsius, unit)).toString(); -} - -function clampInputToCelsius(value: string, unit: TemperatureUnit, maxCelsius: number): number { - const celsius = inputTemperatureToCelsius(value, unit); - return Math.max(0, Math.min(maxCelsius, Math.round(celsius))); -} - -function dryTimeLabel(seconds: number): string { - if (!Number.isFinite(seconds) || seconds <= 0) return ''; - const hours = Math.floor(seconds / 3600); - const minutes = Math.ceil((seconds % 3600) / 60); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${Math.max(1, minutes)}m`; -} - -function pandaModeLabel( - pandaState: Record, - heater: Record | null, - target: number -): string { - if (pandaState.connected === false) return t('offline'); - - const mode = Number(pandaState.work_mode); - const remaining = Number(pandaState.remaining_seconds); - const drying = - pandaState.filament_drying_active === true || - (Number.isFinite(remaining) && remaining > 0) || - mode === 3; - if (drying) { - const remainingText = dryTimeLabel(remaining); - return remainingText ? `${t('Dry')} ${remainingText}` : t('Dry'); - } - - if (pandaState.auto_enabled === true || mode === 1) return t('Auto'); - if (target > 0 || pandaState.work_on === true) return t('Manual'); - return heater ? t('Idle') : t('not detected'); -} - -function FanRow({ - label, - speed, - onSet, - disabled, -}: { - label: string; - speed: number; // 0..1 - onSet: (pct: number) => void; - disabled?: boolean; -}) { - const currentPct = Math.round(speed * 100); - return ( - - - {label} {currentPct}% - - - {SPEED_STEPS.map((pct) => ( - onSet(pct)} - > - - {pct === 0 ? t('Off') : `${pct}`} - - - ))} - - - ); -} - -export default function ControlsPanel({ - status, - sendGcode, - disabled, - showPandaBreath, - gcodeHelp, - temperatureUnit = 'c', -}: Props) { - const [bedTarget, setBedTarget] = useState(() => defaultTargetInput(60, temperatureUnit)); - const [pandaTarget, setPandaTarget] = useState(() => defaultTargetInput(45, temperatureUnit)); - const { showAlert, alertDialog } = useThemedAlert(); - - useEffect(() => { - setBedTarget(defaultTargetInput(60, temperatureUnit)); - setPandaTarget(defaultTargetInput(45, temperatureUnit)); - }, [temperatureUnit]); - - const bed = status.heater_bed ?? {}; - const pandaBreath = showPandaBreath ? findPandaBreathHeater(status) : null; - const pandaState = showPandaBreath ? status.panda_breath ?? {} : {}; - const pandaTemp = - typeof pandaBreath?.temperature === 'number' ? pandaBreath.temperature : 0; - const pandaActiveTarget = - typeof pandaBreath?.target === 'number' ? pandaBreath.target : 0; - const supportsPandaAuto = - hasGcode(gcodeHelp, 'PANDA_BREATH_AUTO') || typeof pandaState.auto_target === 'number'; - const pandaDryCommand = dryCommand(gcodeHelp); - const pandaDryActive = - pandaState.filament_drying_active === true || - Number(pandaState.remaining_seconds) > 0 || - Number(pandaState.work_mode) === 3; - const pandaMode = pandaModeLabel(pandaState, pandaBreath, pandaActiveTarget); - const partFan = status.fan ?? {}; - const cavityFan = status['fan_generic cavity_fan'] ?? null; - - const startPandaDry = (preset: (typeof PANDA_DRY_PRESETS)[number]) => { - if (pandaDryCommand === 'start') { - sendGcode(`PANDA_BREATH_DRY_START TEMP=${preset.temp} HOURS=${preset.hours}`); - } else if (pandaDryCommand === 'run') { - sendGcode(`PANDA_BREATH_DRY_RUN TARGET=${preset.temp} DURATION=${preset.hours * 60}`); - } - }; - - const choosePandaDryPreset = () => { - showAlert({ - title: t('Dry filament'), - message: t('Choose a drying preset.'), - icon: 'radiator', - actions: [ - ...PANDA_DRY_PRESETS.map((preset) => ({ - text: `${preset.material} ${formatTemperature(preset.temp, temperatureUnit, 0)} ${preset.hours}h`, - variant: 'primary' as const, - onPress: () => startPandaDry(preset), - })), - { text: t('Cancel') }, - ], - }); - }; - - const stopPandaBreath = () => { - const lines: string[] = []; - if (pandaDryActive && hasGcode(gcodeHelp, 'PANDA_BREATH_DRY_STOP')) { - lines.push('PANDA_BREATH_DRY_STOP'); - } - if (supportsPandaAuto) { - lines.push('PANDA_BREATH_AUTO ENABLE=0'); - } - lines.push('M141 S0'); - sendGcode(lines.join('\n')); - }; - - return ( - <> - - {/* bed heater */} - - - {t('Bed')}{' '} - - {formatTemperature(bed.temperature, temperatureUnit, 0)} - {bed.target > 0 ? ` \u2192 ${formatTemperature(bed.target, temperatureUnit, 0)}` : ''} - - - - - { - const tgt = clampInputToCelsius(bedTarget, temperatureUnit, 110); - sendGcode(`SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=${tgt}`); - }} - > - {t('Set')} - - sendGcode('SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=0')} - > - {t('Off')} - - - - - {showPandaBreath && ( - - - {t('Panda Breath')}{' '} - - {pandaBreath - ? `${formatTemperature(pandaTemp, temperatureUnit, 0)}${ - pandaActiveTarget > 0 - ? ` \u2192 ${formatTemperature(pandaActiveTarget, temperatureUnit, 0)}` - : '' - } \u00B7 ${pandaMode}` - : t('not detected')} - - - {pandaBreath && ( - - - { - const tgt = clampInputToCelsius( - pandaTarget, - temperatureUnit, - PANDA_BREATH_MAX_TEMP - ); - sendGcode(`M141 S${tgt}`); - }} - > - {t('Set')} - - {supportsPandaAuto && ( - { - const tgt = clampInputToCelsius( - pandaTarget, - temperatureUnit, - PANDA_BREATH_MAX_TEMP - ); - sendGcode( - `PANDA_BREATH_AUTO ENABLE=1 TARGET=${tgt} FILTERTEMP=${PANDA_AUTO_FILTER_TEMP} HOTBEDTEMP=${PANDA_AUTO_HOTBED_TEMP}` - ); - }} - > - - {t('Auto')} - - - )} - {pandaDryCommand && ( - - - {t('Dry')} - - - )} - - {pandaDryActive ? t('Stop') : t('Off')} - - - )} - - )} - - {/* part cooling fan on the active head */} - sendGcode(pct === 0 ? 'M107' : `M106 S${Math.round((pct / 100) * 255)}`)} - /> - - {/* chamber circulation fan */} - {cavityFan && ( - sendGcode(`SET_FAN_SPEED FAN=cavity_fan SPEED=${(pct / 100).toFixed(2)}`)} - /> - )} - - - {alertDialog} - - ); -} - -const styles = StyleSheet.create({ - card: { - backgroundColor: colors.card, - borderRadius: 10, - borderWidth: 1, - borderColor: colors.border, - padding: spacing.md, - gap: spacing.md, - }, - row: { - gap: spacing.sm, - }, - rowLabel: { - color: colors.text, - fontSize: 13, - fontWeight: '600', - }, - rowValue: { - color: colors.subtext, - fontWeight: '400', - fontSize: 12, - }, - chips: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - alignItems: 'center', - }, - chip: { - backgroundColor: colors.cardAlt, - borderRadius: 6, - paddingVertical: 7, - paddingHorizontal: 12, - minWidth: 42, - alignItems: 'center', - }, - chipText: { - color: colors.text, - fontSize: 12, - fontWeight: '600', - }, - tempInput: { - backgroundColor: colors.bg, - borderRadius: 6, - borderWidth: 1, - borderColor: colors.border, - color: colors.text, - paddingHorizontal: spacing.sm, - paddingVertical: 6, - width: 56, - fontSize: 13, - textAlign: 'center', - }, - disabled: { - opacity: 0.4, - }, -}); diff --git a/components/Dropdown.tsx b/components/Dropdown.tsx index 2f9c0be..31541a2 100644 --- a/components/Dropdown.tsx +++ b/components/Dropdown.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { FlatList, KeyboardAvoidingView, @@ -8,8 +8,8 @@ import { TextInput, TouchableOpacity, View, + useWindowDimensions, } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { t } from '../services/i18n'; import { colors, spacing } from '../constants/theme'; @@ -31,8 +31,8 @@ interface Props { clearable?: boolean; } -// bottom-sheet picker. RN has no styled native dropdown and the chip walls -// were getting out of hand once the lists grew past ~8 entries. +// Layer picker. RN has no styled native dropdown and the chip walls were +// getting out of hand once the lists grew past ~8 entries. // crabcore export default function Dropdown({ label, @@ -44,9 +44,9 @@ export default function Dropdown({ }: Props) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(''); - // Issue #5: bottom sheets draw edge-to-edge, so pad past the Android nav bar. - const insets = useSafeAreaInsets(); - + const [anchor, setAnchor] = useState({ x: spacing.lg, y: 100, width: 320, height: 48 }); + const triggerRef = useRef>(null); + const window = useWindowDimensions(); const selected = options.find((o) => o.key === value) ?? null; const searchable = options.length > 8; @@ -60,10 +60,24 @@ export default function Dropdown({ setFilter(''); }; + const openPicker = () => { + triggerRef.current?.measureInWindow((x, y, width, height) => { + setAnchor({ x, y, width, height }); + setOpen(true); + }); + }; + + const belowSpace = window.height - anchor.y - anchor.height - 12; + const opensAbove = belowSpace < 220; + const popoverMaxHeight = Math.max( + 160, + Math.min(window.height * 0.55, opensAbove ? anchor.y - 12 : belowSpace) + ); + return ( {label} - setOpen(true)}> + {selected?.color ? ( ) : null} @@ -78,69 +92,96 @@ export default function Dropdown({ - - {/* Modal hosts its own window, so the activity's keyboard resize doesn't - apply — lift the sheet above the keyboard ourselves (issue #5). */} + true} > - - {label} - - - + {selected?.color ? ( + + ) : null} + + {selected?.label ?? placeholder ?? t('Select…')} + + - {searchable && ( - - )} - o.key} - style={styles.list} - keyboardShouldPersistTaps="handled" - renderItem={({ item }) => { - const isSel = item.key === value; - return ( - { - onSelect(item.key); - close(); - }} - > - {item.color ? ( - - ) : null} - true} + > + {searchable && ( + + )} + o.key} + style={styles.list} + keyboardShouldPersistTaps="handled" + renderItem={({ item }) => { + const isSel = item.key === value; + return ( + { + onSelect(item.key); + close(); + }} > - {item.label} - - {item.hint ? {item.hint} : null} - {isSel && ( - - )} - - ); - }} - ListEmptyComponent={{t('No matches')}} - /> + {item.color ? ( + + ) : null} + + {item.label} + + {item.hint ? {item.hint} : null} + {isSel && ( + + )} + + ); + }} + ListEmptyComponent={{t('No matches')}} + /> @@ -184,31 +225,31 @@ const styles = StyleSheet.create({ }, overlay: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.6)', - justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.35)', }, - sheet: { - backgroundColor: colors.bg, - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - paddingTop: spacing.md, - paddingHorizontal: spacing.lg, - paddingBottom: spacing.xl, - maxHeight: '70%', - }, - sheetHeader: { + anchorField: { + position: 'absolute', + zIndex: 2, flexDirection: 'row', - justifyContent: 'space-between', alignItems: 'center', - marginBottom: spacing.sm, + gap: spacing.sm, + backgroundColor: colors.card, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.primary, + paddingHorizontal: spacing.md, }, - sheetTitle: { - color: colors.text, - fontSize: 15, - fontWeight: '700', + popover: { + position: 'absolute', + zIndex: 1, + backgroundColor: colors.card, + borderRadius: 16, + borderWidth: 1, + borderColor: colors.border, + overflow: 'hidden', }, search: { - backgroundColor: colors.card, + backgroundColor: colors.cardAlt, borderRadius: 8, borderWidth: 1, borderColor: colors.border, @@ -216,7 +257,7 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.md, paddingVertical: spacing.sm, fontSize: 14, - marginBottom: spacing.sm, + margin: spacing.sm, }, list: { flexGrow: 0, @@ -225,9 +266,10 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - paddingVertical: spacing.md, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.border, + height: 52, + paddingHorizontal: 16, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, }, rowSelected: {}, rowText: { diff --git a/components/FilamentDashboardCard.tsx b/components/FilamentDashboardCard.tsx deleted file mode 100644 index 3518c2e..0000000 --- a/components/FilamentDashboardCard.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { useMemo } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { colors, spacing } from '../constants/theme'; -import { normalizeFilamentSlotColors } from '../constants/filamentColors'; -import FilamentSlotsEditor, { - type FilamentSlotDisplay, -} from './FilamentSlotsEditor'; - -type Props = { - status: Record; - slotColors: string[]; - slotBrands: string[]; - slotMaterials: string[]; - slotSubtypes: string[]; - onChange: (colors: string[], changedIndex?: number) => void; - onBrandsChange: (brands: string[], changedIndex?: number) => void; - onMaterialsChange: (materials: string[], changedIndex?: number) => void; - onSubtypesChange: (subtypes: string[], changedIndex?: number) => void; -}; - -function printerText(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const clean = value.trim(); - return clean && clean !== 'NONE' ? clean.toUpperCase() : undefined; -} - -function printerColor(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const clean = value.trim().replace(/^#/, ''); - if (!/^[0-9a-f]{6,8}$/i.test(clean)) return undefined; - return `#${clean.slice(0, 6)}`; -} - -function resolveSlots( - status: Record, - slotColors: string[], - slotBrands: string[], - slotMaterials: string[], - slotSubtypes: string[], -): FilamentSlotDisplay[] { - const task = status.print_task_config && typeof status.print_task_config === 'object' - ? status.print_task_config - : {}; - const exists = Array.isArray(task.filament_exist) ? task.filament_exist : []; - const colorsFromPrinter = Array.isArray(task.filament_color_rgba) ? task.filament_color_rgba : []; - const vendorsFromPrinter = Array.isArray(task.filament_vendor) ? task.filament_vendor : []; - const materialsFromPrinter = Array.isArray(task.filament_type) ? task.filament_type : []; - const subtypesFromPrinter = Array.isArray(task.filament_sub_type) ? task.filament_sub_type : []; - const fallbackColors = normalizeFilamentSlotColors(slotColors); - - return Array.from({ length: 4 }, (_, index) => { - const loaded = typeof exists[index] === 'boolean' ? exists[index] : undefined; - const color = printerColor(colorsFromPrinter[index]); - const vendor = printerText(vendorsFromPrinter[index]); - const materialType = printerText(materialsFromPrinter[index]); - const subtype = printerText(subtypesFromPrinter[index]); - const material = [materialType, subtype].filter(Boolean).join(' ') || undefined; - const manualMaterial = [slotMaterials[index], slotSubtypes[index]].filter(Boolean).join(' '); - return { - index, - color: color ?? fallbackColors[index], - brand: vendor && vendor !== 'GENERIC' ? vendor : slotBrands[index] ?? 'Generic', - material: material ?? (manualMaterial || 'PLA'), - status: loaded === true ? 'loaded' : loaded === false ? 'empty' : 'unknown', - source: color || material ? 'printer' : 'manual', - }; - }); -} - -export default function FilamentDashboardCard({ - status, - slotColors, - slotBrands, - slotMaterials, - slotSubtypes, - onChange, - onBrandsChange, - onMaterialsChange, - onSubtypesChange, -}: Props) { - const slots = useMemo( - () => resolveSlots(status, slotColors, slotBrands, slotMaterials, slotSubtypes), - [status, slotColors, slotBrands, slotMaterials, slotSubtypes], - ); - - return ( - - Filaments - - - ); -} - -const styles = StyleSheet.create({ - card: { - backgroundColor: colors.card, - borderWidth: 1, - borderColor: colors.border, - borderRadius: 12, - padding: spacing.md, - gap: spacing.xs, - }, - sectionTitle: { - color: colors.text, - fontSize: 17, - fontWeight: '800', - }, -}); diff --git a/components/FilamentSlotsEditor.tsx b/components/FilamentSlotsEditor.tsx index be02da5..283dc6a 100644 --- a/components/FilamentSlotsEditor.tsx +++ b/components/FilamentSlotsEditor.tsx @@ -1,6 +1,5 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { - Dimensions, Keyboard, Modal, ScrollView, @@ -8,6 +7,7 @@ import { Text, TextInput, TouchableOpacity, + useWindowDimensions, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -17,10 +17,12 @@ import { normalizeFilamentHex, } from '../constants/filamentColors'; import { + BRAND_PRESETS, DEFAULT_FILAMENT_SUBTYPE, FILAMENT_MAIN_TYPES, subtypesForMainType, } from '../services/filamentMaterials'; +import { t } from '../services/i18n'; export type FilamentSlotStatus = 'loaded' | 'empty' | 'busy' | 'unknown'; @@ -29,6 +31,8 @@ export type FilamentSlotDisplay = { color: string; brand?: string; material: string; + mainType?: string; + subType?: string; status: FilamentSlotStatus; source?: 'printer' | 'manual'; }; @@ -45,9 +49,8 @@ type Props = { onSubtypesChange: (subtypes: string[], changedIndex?: number) => void; }; -const BRAND_PRESETS = ['Generic', 'Bambu Lab', 'Hatchbox', 'eSun', 'Overture', 'SUNLU', 'Polymaker', 'Prusament', 'Snapmaker', 'Jayo', 'Other']; - type PickerKind = 'material' | 'subtype' | 'brand'; +type PickerAnchor = { x: number; y: number; width: number; height: number }; type OptionPickerProps = { visible: boolean; @@ -56,8 +59,8 @@ type OptionPickerProps = { selected: string; onSelect: (value: string) => void; onClose: () => void; - bottomInset: number; maxHeight: number; + anchor: PickerAnchor; }; function OptionPicker({ @@ -67,28 +70,47 @@ function OptionPicker({ selected, onSelect, onClose, - bottomInset, maxHeight, + anchor, }: OptionPickerProps) { + const window = useWindowDimensions(); + const belowSpace = window.height - anchor.y - anchor.height - 12; + const opensAbove = belowSpace < 220; + return ( - - - {}} + + + + - - {title} - - Done - - - + {selected} + ▲ + + + {title} - - + + ); } @@ -129,13 +151,24 @@ export default function FilamentSlotsEditor({ onSubtypesChange, }: Props) { const insets = useSafeAreaInsets(); - const window = Dimensions.get('window'); + const window = useWindowDimensions(); const [editingSlot, setEditingSlot] = useState(null); const [hexDraft, setHexDraft] = useState(''); const [brandChoice, setBrandChoice] = useState('Generic'); const [customBrandDraft, setCustomBrandDraft] = useState(''); + const [materialDraft, setMaterialDraft] = useState('PLA'); + const [subtypeDraft, setSubtypeDraft] = useState(DEFAULT_FILAMENT_SUBTYPE); const [picker, setPicker] = useState(null); + const [pickerAnchor, setPickerAnchor] = useState({ + x: spacing.lg, + y: 120, + width: 320, + height: 48, + }); const [keyboardHeight, setKeyboardHeight] = useState(0); + const materialRef = useRef>(null); + const subtypeRef = useRef>(null); + const brandRef = useRef>(null); React.useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', (event) => setKeyboardHeight(event.endCoordinates.height)); @@ -152,57 +185,61 @@ export default function FilamentSlotsEditor({ const brand = slotBrands[index] ?? 'Generic'; setBrandChoice(BRAND_PRESETS.includes(brand) ? brand : 'Other'); setCustomBrandDraft(BRAND_PRESETS.includes(brand) ? '' : brand); + setMaterialDraft(slotMaterials[index] || 'PLA'); + setSubtypeDraft(slotSubtypes[index] || DEFAULT_FILAMENT_SUBTYPE); setPicker(null); }; const openPicker = (kind: PickerKind) => { Keyboard.dismiss(); - setPicker(kind); - }; - - const applyBrand = (brand: string) => { - if (editingSlot == null) return; - const clean = brand.trim(); - if (!clean) return; - const next = [...slotBrands]; - next[editingSlot] = clean; - onBrandsChange(next, editingSlot); - setCustomBrandDraft(clean); + const ref = kind === 'material' ? materialRef : kind === 'subtype' ? subtypeRef : brandRef; + ref.current?.measureInWindow((x, y, width, height) => { + setPickerAnchor({ x, y, width, height }); + setPicker(kind); + }); }; const applyColor = (hex: string) => { - if (editingSlot == null) return; const normalized = normalizeFilamentHex(hex); if (!normalized) return; - const next = [...slotColors]; - next[editingSlot] = normalized; - onChange(next, editingSlot); - setEditingSlot(null); + setHexDraft(normalized.replace('#', '')); }; const applyMaterial = (material: string) => { - if (editingSlot == null) return; const clean = material.trim().toUpperCase(); if (!clean) return; - const nextMaterials = [...slotMaterials]; - nextMaterials[editingSlot] = clean; - onMaterialsChange(nextMaterials, editingSlot); - - const currentSubtype = slotSubtypes[editingSlot] || DEFAULT_FILAMENT_SUBTYPE; - if (!subtypesForMainType(clean).includes(currentSubtype)) { - const nextSubtypes = [...slotSubtypes]; - nextSubtypes[editingSlot] = DEFAULT_FILAMENT_SUBTYPE; - onSubtypesChange(nextSubtypes, editingSlot); + setMaterialDraft(clean); + if (!subtypesForMainType(clean).includes(subtypeDraft)) { + setSubtypeDraft(DEFAULT_FILAMENT_SUBTYPE); } }; const applySubtype = (subtype: string) => { - if (editingSlot == null) return; const clean = subtype.trim(); if (!clean) return; - const next = [...slotSubtypes]; - next[editingSlot] = clean; - onSubtypesChange(next, editingSlot); + setSubtypeDraft(clean); + }; + + const saveEditor = () => { + if (editingSlot == null) return; + const normalized = normalizeFilamentHex(hexDraft); + if (!normalized) return; + const brand = brandChoice === 'Other' ? customBrandDraft.trim() : brandChoice; + if (!brand) return; + + const nextColors = [...slotColors]; + const nextBrands = [...slotBrands]; + const nextMaterials = [...slotMaterials]; + const nextSubtypes = [...slotSubtypes]; + nextColors[editingSlot] = normalized; + nextBrands[editingSlot] = brand; + nextMaterials[editingSlot] = materialDraft; + nextSubtypes[editingSlot] = subtypeDraft; + onChange(nextColors, editingSlot); + onBrandsChange(nextBrands, editingSlot); + onMaterialsChange(nextMaterials, editingSlot); + onSubtypesChange(nextSubtypes, editingSlot); + setEditingSlot(null); }; const editorSlot = editingSlot == null ? null : slots?.[editingSlot]; @@ -232,37 +269,40 @@ export default function FilamentSlotsEditor({ {[brand, material].filter(Boolean).join(' ')} - {status} + {t(status)} ); })} - - setEditingSlot(null)} + > + 0 && styles.backdropKeyboard]} - activeOpacity={1} - onPress={() => setEditingSlot(null)} > - 0 ? insets.top + spacing.sm : 0, - paddingBottom: insets.bottom + spacing.md, + flex: 1, + maxHeight: Math.max(180, window.height - keyboardHeight), + paddingTop: insets.top, }]} - onPress={() => {}} > + + {t('Filament')} T{editingSlot ?? 0} + - Filament T{editingSlot ?? 0} {editorSlot?.source === 'printer' - ? 'Printer values are shown when loaded; manual values stay as fallback.' - : 'Choose the filament details for this slot.'} + ? t('Printer values are shown when loaded; manual values stay as fallback.') + : t('Choose the filament details for this slot.')} {FILAMENT_COLOR_PRESETS.map((preset) => ( @@ -277,18 +317,18 @@ export default function FilamentSlotsEditor({ /> ))} - Material - openPicker('material')}> - {slotMaterials[editingSlot ?? 0] || 'PLA'} + {t('Material')} + openPicker('material')}> + {materialDraft} ▼ - Subtype - openPicker('subtype')}> - {slotSubtypes[editingSlot ?? 0] || DEFAULT_FILAMENT_SUBTYPE} + {t('Subtype')} + openPicker('subtype')}> + {subtypeDraft} ▼ - Brand - openPicker('brand')}> + {t('Brand')} + openPicker('brand')}> {brandChoice} ▼ @@ -296,12 +336,12 @@ export default function FilamentSlotsEditor({ { setCustomBrandDraft(brand); applyBrand(brand); }} - placeholder="Custom brand" + onChangeText={setCustomBrandDraft} + placeholder={t('Custom brand')} placeholderTextColor={colors.subtext} /> )} - Custom hex + {t('Custom hex')} applyColor(hexDraft)} > - Set + {t('Set')} - - + + + {t('Save filament')} + + setEditingSlot(null)} + > + {t('Cancel')} + + + + { applyMaterial(value); setPicker(null); }} onClose={() => setPicker(null)} - bottomInset={insets.bottom} maxHeight={pickerMaxHeight} + anchor={pickerAnchor} /> { applySubtype(value); setPicker(null); }} onClose={() => setPicker(null)} - bottomInset={insets.bottom} maxHeight={pickerMaxHeight} + anchor={pickerAnchor} /> { setBrandChoice(brand); setPicker(null); - if (brand !== 'Other') applyBrand(brand); }} onClose={() => setPicker(null)} - bottomInset={insets.bottom} maxHeight={pickerMaxHeight} + anchor={pickerAnchor} /> ); @@ -422,27 +472,31 @@ const styles = StyleSheet.create({ }, backdrop: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.55)', - justifyContent: 'center', - padding: spacing.lg, + backgroundColor: colors.bg, + justifyContent: 'flex-start', }, backdropKeyboard: { justifyContent: 'flex-start', }, sheet: { - backgroundColor: colors.card, - borderRadius: 12, - borderWidth: 1, - borderColor: colors.border, - padding: spacing.md, + backgroundColor: colors.bg, + }, + sheetHeader: { + alignItems: 'center', + paddingHorizontal: 26, + paddingTop: 22, + paddingBottom: 12, }, sheetContent: { - paddingBottom: spacing.sm, + paddingHorizontal: 26, + paddingBottom: 24, }, sheetTitle: { color: colors.text, - fontSize: 16, - fontWeight: '700', + fontSize: 30, + fontWeight: '800', + letterSpacing: -1, + textAlign: 'center', }, sheetHint: { color: colors.subtext, @@ -519,57 +573,65 @@ const styles = StyleSheet.create({ fontWeight: '600', fontSize: 13, }, + focusActions: { + paddingHorizontal: 22, + paddingTop: 8, + gap: 9, + }, + focusAction: { + height: 62, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + }, + focusPrimary: { backgroundColor: colors.primary }, + focusCancel: { backgroundColor: colors.cardAlt }, + focusPrimaryText: { color: '#fff', fontSize: 16, fontWeight: '800' }, + focusCancelText: { color: colors.text, fontSize: 16, fontWeight: '800' }, pickerBackdrop: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.55)', - justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.35)', + }, + pickerAnchor: { + position: 'absolute', + zIndex: 2, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: colors.card, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.primary, + paddingHorizontal: spacing.sm, }, pickerSheet: { + position: 'absolute', + zIndex: 1, backgroundColor: colors.card, - borderTopLeftRadius: 16, - borderTopRightRadius: 16, + borderRadius: 16, borderWidth: 1, - borderBottomWidth: 0, borderColor: colors.border, - paddingTop: spacing.sm, - }, - pickerHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, + padding: 12, + overflow: 'hidden', }, pickerTitle: { - color: colors.text, - fontSize: 15, - fontWeight: '700', - }, - pickerDone: { - color: colors.primary, - fontSize: 14, - fontWeight: '700', - }, - pickerGrabber: { - width: 36, - height: 4, - borderRadius: 2, - backgroundColor: colors.border, - alignSelf: 'center', - marginBottom: spacing.xs, - }, - pickerScroll: { - paddingHorizontal: spacing.sm, + color: colors.subtext, + fontSize: 11, + fontWeight: '800', + letterSpacing: 1.1, + textTransform: 'uppercase', + paddingHorizontal: 8, + paddingVertical: 6, }, + pickerScroll: { flexGrow: 0 }, pickerOption: { - minHeight: 46, + height: 52, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: spacing.sm, - paddingVertical: 10, - borderBottomWidth: 1, - borderBottomColor: colors.border, + paddingHorizontal: 12, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, }, pickerOptionActive: { backgroundColor: colors.cardAlt, diff --git a/components/FirstRunSetup.tsx b/components/FirstRunSetup.tsx index 823586c..0cc71ac 100644 --- a/components/FirstRunSetup.tsx +++ b/components/FirstRunSetup.tsx @@ -12,11 +12,14 @@ import { View, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors, spacing } from '../constants/theme'; import { ConnectionMode, PrinterEntry, useSettings } from '../hooks/useSettings'; import { normalizeMoonrakerUrl, validatePrinterConnectionTarget } from '../services/moonraker'; import type { PrinterConnectionValidationError } from '../services/moonraker'; import { t } from '../services/i18n'; +import TailscaleIcon from './TailscaleIcon'; +import PrinterIcon from './PrinterIcon'; const CONNECTION_MODES: { value: ConnectionMode; @@ -35,6 +38,7 @@ function connectionErrorMessage(error: PrinterConnectionValidationError | null): } export default function FirstRunSetup({ visible }: { visible: boolean }) { + const insets = useSafeAreaInsets(); const { update } = useSettings(); const [name, setName] = useState('Snapmaker U1'); const [lanUrl, setLanUrl] = useState(''); @@ -82,21 +86,28 @@ export default function FirstRunSetup({ visible }: { visible: boolean }) { }; return ( - - - - - {t('Welcome to Helix')} - - {t( - 'A mobile Fluidd-style controller for PAXX/Moonraker so you do not have to open the printer web UI every time.' - )} - + + + + + + + + LAYER 1 + {t('Welcome to Helix')} + + + + + {t( + 'A mobile Fluidd-style controller for PAXX/Moonraker so you do not have to open the printer web UI every time.' + )} + - + setConnectionMode(mode.value)} > - + {mode.value === 'tailscale' ? ( + + ) : ( + + )} {t(mode.label)} @@ -159,23 +174,22 @@ export default function FirstRunSetup({ visible }: { visible: boolean }) { {t('Tailscale only never falls back to Wi-Fi. Auto tries LAN first.')} - {error ? {error} : null} - - - - {saving ? t('Saving...') : t('Start using Helix')} - + {error ? {error} : null} + + + + + + {saving ? t('Saving...') : t('Start using Helix')} + + - - + + ); } @@ -213,33 +227,64 @@ function Field({ const styles = StyleSheet.create({ screen: { flex: 1, + }, + scrim: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.55)', + }, + keyboard: { + flex: 1, + justifyContent: 'flex-end', + }, + deck: { + maxHeight: '82%', backgroundColor: colors.bg, + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + borderWidth: 1, + borderColor: colors.border, + overflow: 'hidden', + }, + grabber: { + alignSelf: 'center', + width: 40, + height: 4, + borderRadius: 2, + marginTop: 10, + backgroundColor: colors.border, + }, + deckHead: { + paddingHorizontal: 18, + paddingTop: 12, + gap: 3, + }, + depth: { + color: colors.subtext, + fontSize: 10, + fontWeight: '800', + letterSpacing: 1.3, }, content: { - flexGrow: 1, - justifyContent: 'center', - padding: spacing.xl, + padding: 18, gap: spacing.md, }, logo: { - width: 104, - height: 104, - borderRadius: 24, + width: 80, + height: 80, + borderRadius: 20, alignSelf: 'center', - marginBottom: spacing.sm, }, title: { color: colors.text, - fontSize: 26, + fontSize: 19, fontWeight: '800', - textAlign: 'center', + letterSpacing: -0.4, }, copy: { color: colors.subtext, fontSize: 14, lineHeight: 20, textAlign: 'center', - marginBottom: spacing.md, }, card: { backgroundColor: colors.card, @@ -301,8 +346,9 @@ const styles = StyleSheet.create({ fontWeight: '700', }, saveBtn: { - minHeight: 48, - borderRadius: 10, + height: 52, + borderRadius: 999, + backgroundColor: colors.primary, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', @@ -313,4 +359,8 @@ const styles = StyleSheet.create({ fontSize: 15, fontWeight: '800', }, + actions: { + paddingHorizontal: 18, + paddingTop: 6, + }, }); diff --git a/components/HistoryView.tsx b/components/HistoryView.tsx deleted file mode 100644 index 4fa9ac5..0000000 --- a/components/HistoryView.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { - FlatList, - Image, - RefreshControl, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { api, HistoryJob, HistoryTotals, thumbnailUrl } from '../services/moonraker'; -import UsageChart from './UsageChart'; -import { t } from '../services/i18n'; -import { colors, spacing } from '../constants/theme'; - -const PAGE = 30; - -function fmtDur(totalSeconds: number): string { - if (!isFinite(totalSeconds) || totalSeconds <= 0) return '0s'; - const s = Math.round(totalSeconds); - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - if (h > 0) return `${h}h ${m}m ${sec}s`; - if (m > 0) return `${m}m ${sec}s`; - return `${sec}s`; -} - -function fmtDate(epoch: number): string { - const d = new Date(epoch * 1000); - return ( - d.toLocaleDateString() + - ' ' + - d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - ); -} - -function fmtSize(bytes?: number): string { - if (!bytes) return ''; - if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / 1024).toFixed(0)} KB`; -} - -function fmtFilament(mm: number): string { - return `${(mm / 1000).toFixed(2)} m`; -} - -function statusIcon(status: string): { name: any; color: string } { - switch (status) { - case 'completed': - return { name: 'check-circle', color: colors.success }; - case 'cancelled': - return { name: 'close-circle', color: colors.danger }; - case 'error': - case 'klippy_shutdown': - case 'klippy_disconnect': - return { name: 'alert-circle', color: colors.danger }; - case 'in_progress': - return { name: 'progress-clock', color: colors.primary }; - case 'interrupted': - return { name: 'alert', color: colors.warning }; - default: - return { name: 'help-circle-outline', color: colors.subtext }; - } -} - -function jobThumb(base: string, job: HistoryJob): string | null { - const thumbs = job.metadata?.thumbnails; - if (!Array.isArray(thumbs) || !thumbs.length) return null; - const best = thumbs.reduce((a, b) => (b.width > a.width ? b : a), thumbs[0]); - return thumbnailUrl(base, job.filename, best.relative_path); -} - -export default function HistoryView({ base, connected }: { base: string; connected: boolean }) { - const [jobs, setJobs] = useState([]); - const [count, setCount] = useState(0); - const [totals, setTotals] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - const load = useCallback( - async (start: number, replace: boolean) => { - if (!base) return; - setLoading(true); - setError(''); - try { - const [list, tot] = await Promise.all([ - api.historyList(base, PAGE, start), - start === 0 ? api.historyTotals(base) : Promise.resolve(null), - ]); - setCount(list.count); - setJobs((prev) => (replace ? list.jobs : [...prev, ...list.jobs])); - if (tot) setTotals(tot.job_totals); - } catch (e: any) { - setError(String(e?.message ?? e)); - } finally { - setLoading(false); - } - }, - [base] - ); - - useEffect(() => { - if (connected) load(0, true); - }, [connected, load]); - - const header = ( - <> - - - {t('Printer Stats')} - {totals ? ( - - - - - - - - - ) : ( - {connected ? '…' : t('Not connected')} - )} - - - ); - - const footer = - jobs.length < count ? ( - load(jobs.length, false)} - disabled={loading} - > - - {loading ? '…' : `${t('Load more')} (${jobs.length}/${count})`} - - - ) : null; - - return ( - j.job_id} - contentContainerStyle={styles.listContent} - ListHeaderComponent={header} - ListFooterComponent={footer} - ListEmptyComponent={ - !loading ? ( - - {error ? `${t('Error')}: ${error}` : connected ? t('No print history') : t('Not connected')} - - ) : null - } - refreshControl={ - load(0, true)} tintColor={colors.subtext} /> - } - renderItem={({ item }) => { - const icon = statusIcon(item.status); - const thumb = jobThumb(base, item); - return ( - - {thumb ? ( - - ) : ( - - - - )} - - - {item.filename} - - - {fmtDate(item.start_time)} · {fmtDur(item.print_duration || item.total_duration)} - - - {[fmtSize(item.metadata?.size), item.filament_used > 0 ? fmtFilament(item.filament_used) : ''] - .filter(Boolean) - .join(' · ')} - - - - - ); - }} - /> - ); -} - -function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { - return ( - - {label} - {value} - {sub ? {sub} : null} - - ); -} - -const styles = StyleSheet.create({ - listContent: { - padding: spacing.lg, - gap: spacing.sm, - paddingBottom: spacing.xl * 2, - }, - statsCard: { - backgroundColor: colors.card, - borderRadius: 10, - borderWidth: 1, - borderColor: colors.border, - padding: spacing.md, - marginBottom: spacing.sm, - }, - statsTitle: { - color: colors.text, - fontSize: 14, - fontWeight: '700', - marginBottom: spacing.sm, - }, - statsGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - }, - stat: { - width: '50%', - marginBottom: spacing.sm, - }, - statLabel: { - color: colors.subtext, - fontSize: 10, - textTransform: 'uppercase', - }, - statValue: { - color: colors.text, - fontSize: 15, - fontWeight: '700', - marginTop: 1, - }, - statSub: { - color: colors.subtext, - fontSize: 11, - }, - jobCard: { - backgroundColor: colors.card, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.border, - padding: spacing.sm, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - }, - thumb: { - width: 44, - height: 44, - borderRadius: 6, - backgroundColor: colors.cardAlt, - }, - thumbPlaceholder: { - alignItems: 'center', - justifyContent: 'center', - }, - jobInfo: { - flex: 1, - }, - jobName: { - color: colors.text, - fontSize: 13, - fontWeight: '600', - }, - jobMeta: { - color: colors.subtext, - fontSize: 11, - marginTop: 1, - }, - moreBtn: { - backgroundColor: colors.cardAlt, - borderRadius: 8, - paddingVertical: spacing.md, - alignItems: 'center', - marginTop: spacing.sm, - }, - moreText: { - color: colors.text, - fontSize: 13, - fontWeight: '600', - }, - empty: { - color: colors.subtext, - textAlign: 'center', - marginTop: spacing.lg, - }, -}); diff --git a/components/MacrosPanel.tsx b/components/MacrosPanel.tsx deleted file mode 100644 index 7da1335..0000000 --- a/components/MacrosPanel.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { StyleSheet, Text, TextInput, View } from 'react-native'; -import { useMoonraker } from '../hooks/useMoonraker'; -import { useSettings } from '../hooks/useSettings'; -import { filterMacrosForDisplay, getMacroDisplay } from '../services/macroDisplay'; -import MacroButton from './MacroButton'; -import { t } from '../services/i18n'; -import { colors, spacing } from '../constants/theme'; -import { useThemedAlert } from '../hooks/useThemedAlert'; - -// PAXX exposes many macros. First matching category wins, with virtual tool -// macros grouped separately from manual filament actions. -const CATEGORIES: { name: string; match: (m: string) => boolean }[] = [ - { name: 'Print Control', match: (m) => /^(PRINT_|PAUSE|RESUME|CANCEL|M600$|SET_PAUSE)/i.test(m) }, - { name: 'Tool Change', match: (m) => /^T\d+$/.test(m) }, - { - name: 'ACE & Filament', - match: (m) => /ACE|FEED|FILAMENT|LOAD|UNLOAD|RUNOUT|FLUSH|DISCARD/i.test(m), - }, - { - name: 'Calibration', - match: (m) => /CALIBRATE|SHAPER|PID|PROBE|OFFSET|MESH|TILT|RESONANCE|FLOW|MEASURE/i.test(m), - }, - { name: 'Cleaning', match: (m) => /CLEAN|CUTOFF|NOZZLE/i.test(m) }, - { name: 'Homing & Motion', match: (m) => /HOME|HOMING|PARK|SHAKE|MOVE/i.test(m) }, -]; - -// lives on the Home tab (toggleable section) since Spoolman took the tab slot -export default function MacrosPanel() { - const { macros, sendGcode, connection } = useMoonraker(); - const { settings } = useSettings(); - const [filter, setFilter] = useState(''); - const { showAlert, alertDialog } = useThemedAlert(); - const macroDisplay = useMemo(() => getMacroDisplay(settings), [settings]); - - const configuredMacros = useMemo(() => { - return filterMacrosForDisplay(macros, macroDisplay); - }, [macros, macroDisplay]); - - const sections = useMemo(() => { - const f = filter.trim().toLowerCase(); - const visible = f - ? configuredMacros.filter((m) => m.toLowerCase().includes(f)) - : configuredMacros; - const buckets = new Map(); - for (const name of visible) { - const cat = CATEGORIES.find((c) => c.match(name))?.name ?? 'Other'; - if (!buckets.has(cat)) buckets.set(cat, []); - buckets.get(cat)!.push(name); - } - const order = [...CATEGORIES.map((c) => c.name), 'Other']; - return order - .filter((name) => buckets.has(name)) - .map((name) => ({ name, macros: buckets.get(name)! })); - }, [configuredMacros, filter]); - - const run = (name: string) => { - // multiACE macros get a confirmation for safety - if (/ace/i.test(name)) { - showAlert({ - title: t('Run ACE macro?'), - message: name, - icon: 'play-circle-outline', - actions: [ - { text: t('Cancel') }, - { text: t('Run'), variant: 'primary', onPress: () => sendGcode(name) }, - ], - }); - } else { - sendGcode(name); - } - }; - - return ( - <> - - - {sections.map((section) => ( - - - {t(section.name)} - {section.macros.length} - - - {section.macros.map((name) => ( - run(name)} - disabled={connection !== 'connected'} - /> - ))} - - - ))} - {!sections.length && ( - - {connection === 'connected' - ? macroDisplay.mode === 'selected' && configuredMacros.length === 0 - ? t('No macros selected in Settings') - : t('No macros found') - : t('Not connected')} - - )} - - {alertDialog} - - ); -} - -const styles = StyleSheet.create({ - wrap: { - gap: spacing.sm, - }, - search: { - backgroundColor: colors.card, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.border, - color: colors.text, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - fontSize: 14, - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginTop: spacing.sm, - marginBottom: spacing.sm, - }, - sectionTitle: { - fontSize: 13, - fontWeight: '700', - textTransform: 'uppercase', - }, - sectionCount: { - color: colors.subtext, - fontSize: 11, - }, - grid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: spacing.sm, - }, - empty: { - color: colors.subtext, - textAlign: 'center', - width: '100%', - marginTop: spacing.md, - }, -}); diff --git a/components/NotificationBell.tsx b/components/NotificationBell.tsx deleted file mode 100644 index 449b135..0000000 --- a/components/NotificationBell.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { - Modal, - Pressable, - ScrollView, - StyleSheet, - Text, - View, - useWindowDimensions, -} from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { useSettings } from '../hooks/useSettings'; -import { - BAKED_NOTIFICATIONS, - FCM_ANNOUNCEMENTS_KEY, - NOTIFICATIONS_URL, - idsOf, - mergeNotifications, - parseNotifications, - unreadCount, -} from '../constants/changelog'; -import type { AppNotification, AppNotificationType } from '../constants/changelog'; -import { t } from '../services/i18n'; -import { colors, radius, shadow, spacing, withAlpha } from '../constants/theme'; -import { PopIn, PressableScale } from './ui'; - -type IconName = React.ComponentProps['name']; - -function typeIcon(type: AppNotificationType): IconName { - if (type === 'alert') return 'alert-outline'; - if (type === 'changelog') return 'rocket-launch-outline'; - return 'information-outline'; -} - -function typeColor(type: AppNotificationType): string { - if (type === 'alert') return colors.warning; - return colors.primary; -} - -const CACHE_KEY = 'u1control.notifications.cache.v1'; -const FETCH_TIMEOUT_MS = 6000; - -/** Baked feed, newest first — the instant fallback before cache/remote load. */ -const INITIAL_LIST = mergeNotifications(BAKED_NOTIFICATIONS); - -async function fetchRemoteNotifications(): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - try { - const res = await fetch(NOTIFICATIONS_URL, { signal: controller.signal }); - if (!res.ok) return null; - const items = parseNotifications(await res.json()); - return items; - } catch { - return null; - } finally { - clearTimeout(timer); - } -} - -export default function NotificationBell() { - const { settings, update } = useSettings(); - const window = useWindowDimensions(); - const [open, setOpen] = useState(false); - const [tab, setTab] = useState<'alerts' | 'changelog'>('alerts'); - const [list, setList] = useState(INITIAL_LIST); - - // On mount: show cached feed instantly, then refresh from the remote JSON. - useEffect(() => { - let live = true; - (async () => { - try { - const raw = await AsyncStorage.getItem(CACHE_KEY); - const cached = raw ? parseNotifications(JSON.parse(raw)) : []; - const fcmRaw = await AsyncStorage.getItem(FCM_ANNOUNCEMENTS_KEY); - const fcm = fcmRaw ? parseNotifications(JSON.parse(fcmRaw)) : []; - if (live && cached.length) { - setList(mergeNotifications(BAKED_NOTIFICATIONS, cached, fcm)); - } else if (live && fcm.length) { - setList(mergeNotifications(BAKED_NOTIFICATIONS, fcm)); - } - } catch { - // ignore corrupt cache - } - const remote = await fetchRemoteNotifications(); - if (remote && live) { - const fcmRaw = await AsyncStorage.getItem(FCM_ANNOUNCEMENTS_KEY); - const fcm = fcmRaw ? parseNotifications(JSON.parse(fcmRaw)) : []; - setList(mergeNotifications(BAKED_NOTIFICATIONS, remote, fcm)); - AsyncStorage.setItem(CACHE_KEY, JSON.stringify(remote)).catch(() => {}); - } - })(); - return () => { - live = false; - }; - }, []); - - const seen = settings.seenNotificationIds; - const unread = useMemo(() => unreadCount(list, seen), [list, seen]); - const seenSet = useMemo(() => new Set(seen), [seen]); - const visibleList = useMemo( - () => list.filter((item) => (tab === 'changelog' ? item.type !== 'alert' : item.type === 'alert')), - [list, tab] - ); - - const openCenter = () => { - setOpen(true); - if (unread > 0) update({ seenNotificationIds: idsOf(list) }); - }; - - const sheetWidth = Math.min(window.width - spacing.lg * 2, 380); - const sheetMaxHeight = Math.min(window.height * 0.7, 520); - - return ( - <> - - 0 ? 'bell-badge-outline' : 'bell-outline'} - size={20} - color={colors.text} - /> - {unread > 0 && ( - - {unread > 9 ? '9+' : unread} - - )} - - - setOpen(false)}> - - setOpen(false)} /> - - setOpen(false)}> - - - - setTab('alerts')} - > - Alerts - - setTab('changelog')} - > - Changelog - - - {visibleList.length === 0 ? ( - - - {t('Nothing new yet')} - - ) : ( - - {visibleList.map((n) => ( - - ))} - - )} - - - - - ); -} - -function NotificationRow({ item, fresh }: { item: AppNotification; fresh: boolean }) { - const accent = typeColor(item.type); - return ( - - - - - - - - {item.title} - - {fresh && } - - {item.date} - {item.body} - - - ); -} - -const styles = StyleSheet.create({ - bellButton: { - width: 40, - height: 40, - borderRadius: radius.md, - backgroundColor: colors.card, - borderWidth: 1, - borderColor: colors.border, - alignItems: 'center', - justifyContent: 'center', - position: 'absolute', - left: 0, - top: 0, - }, - badge: { - position: 'absolute', - top: -4, - right: -4, - minWidth: 16, - height: 16, - borderRadius: 8, - paddingHorizontal: 3, - backgroundColor: colors.danger, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1.5, - borderColor: colors.bg, - }, - badgeText: { color: '#fff', fontSize: 9, fontWeight: '900' }, - modalLayer: { flex: 1 }, - scrim: { backgroundColor: 'rgba(0,0,0,0.35)' }, - sheet: { - position: 'absolute', - top: 70, - left: spacing.lg, - backgroundColor: colors.card, - borderRadius: radius.lg, - borderWidth: 1, - borderColor: colors.border, - overflow: 'hidden', - ...shadow.hero, - }, - closeBtn: { - width: 30, - height: 30, - borderRadius: radius.md, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.cardAlt, - position: 'absolute', - right: spacing.lg, - top: spacing.md, - zIndex: 1, - }, - list: { paddingBottom: spacing.sm }, - tabs: { - flexDirection: 'row', - marginHorizontal: spacing.lg, - paddingTop: spacing.lg, - marginBottom: spacing.xs, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - tab: { flex: 1, alignItems: 'center', paddingVertical: spacing.sm }, - activeTab: { borderBottomWidth: 2, borderBottomColor: colors.primary }, - tabText: { color: colors.subtext, fontSize: 13, fontWeight: '800' }, - activeTabText: { color: colors.primary }, - row: { - flexDirection: 'row', - gap: spacing.md, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.border, - }, - rowIcon: { - width: 36, - height: 36, - borderRadius: radius.md, - alignItems: 'center', - justifyContent: 'center', - }, - rowBody: { flex: 1, minWidth: 0 }, - rowTitleLine: { flexDirection: 'row', alignItems: 'center', gap: 6 }, - rowTitle: { color: colors.text, fontSize: 15, fontWeight: '800', flexShrink: 1 }, - freshDot: { width: 7, height: 7, borderRadius: 4 }, - rowDate: { color: colors.subtext, fontSize: 11, fontWeight: '700', marginTop: 1 }, - rowText: { color: colors.subtext, fontSize: 13, lineHeight: 18, marginTop: 4 }, - empty: { alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.xl }, - emptyText: { color: colors.subtext, fontSize: 13, fontWeight: '700' }, -}); diff --git a/components/PrintPreprocessDialog.tsx b/components/PrintPreprocessDialog.tsx index a5c9e49..f3aee4d 100644 --- a/components/PrintPreprocessDialog.tsx +++ b/components/PrintPreprocessDialog.tsx @@ -1,22 +1,56 @@ -import React, { useState } from 'react'; +// Print preprocess — Ticket sheet. The last screen before a print starts. +// +// Graduated from the preprocess lab. By the time this opens you already decided +// in the slicer (or tapped a file to reprint). The sheet answers four things at +// a glance, folds routing and options away, and makes the commit itself +// deliberate with a press-and-hold. +// +// Auto-routing (services/printPreprocess.ts) fills gaps so an empty lane next to +// loaded ones does not block a printable job. Blocking is reserved for genuinely +// unsatisfiable cases — fewer usable lanes than the file needs, printer busy, or +// offline. +import React, { useMemo, useRef, useState } from 'react'; import { ActivityIndicator, + Animated, Image, Modal, + PanResponder, Pressable, ScrollView, StyleSheet, Text, + Vibration, View, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { formatDuration } from './PrintProgress'; import type { FilamentSlotDisplay } from './FilamentSlotsEditor'; -import { colors, radius, spacing, withAlpha } from '../constants/theme'; +import { COCKPIT as P, alpha, ThumbMock, type IconName } from './dashboard/shared'; +import { PrinterIcon } from './PrinterIcon'; +import { Chevron, Collapsible } from './ui'; +import { + PREF_COPY, + buildPreprocessChecks, + buildPreprocessTools, + finishClock, + laneDetail, + laneLabel, + type PrintPref, + type PreprocessLane, + type PreprocessTool, +} from '../services/printPreprocess'; -export type PrintPref = 'flowCal' | 'timelapse' | 'autoLevel'; +export type { PrintPref }; -type PrinterOption = { id: string; name: string; status?: string; busy?: boolean; selectable?: boolean }; +type PrinterOption = { + id: string; + name: string; + status?: string; + busy?: boolean; + selectable?: boolean; +}; type Props = { visible: boolean; @@ -28,8 +62,11 @@ type Props = { printers: PrinterOption[]; activePrinterId: string; onSelectPrinter: (id: string) => void; + /** File tools the gcode asks for (subset of lanes). */ slots: FilamentSlotDisplay[]; + /** Physical lanes available to remap onto. */ availableSlots?: FilamentSlotDisplay[]; + /** Manual overrides only — auto-routing fills the rest. */ assignments?: Record; onAssignSlot?: (fileTool: number, loadedSlot: number) => void; requiredColors?: Record; @@ -42,13 +79,26 @@ type Props = { errorMessage?: string | null; onSend: (prefs: Readonly>) => void; sendLabel?: string; + /** Moonraker connected for the active printer. */ + connected?: boolean; + /** Active bed_mesh profile name, or null. */ + meshProfile?: string | null; + layers?: number; }; -const PREF_LABELS: { key: PrintPref; label: string }[] = [ - { key: 'flowCal', label: 'Extrusion Flow Calibration' }, - { key: 'timelapse', label: 'Time-lapse Camera' }, - { key: 'autoLevel', label: 'Auto Leveling' }, -]; +const HOLD_MS = 700; + +function asLane(slot: FilamentSlotDisplay): PreprocessLane { + return { + index: slot.index, + color: slot.color, + brand: slot.brand, + material: slot.material, + mainType: slot.mainType, + subType: slot.subType, + status: slot.status, + }; +} export default function PrintPreprocessDialog({ visible, @@ -73,427 +123,821 @@ export default function PrintPreprocessDialog({ statusMessage, errorMessage, onSend, - sendLabel = 'Print', + sendLabel = 'Hold to start', + connected = true, + meshProfile = null, + layers = 0, }: Props) { - const [pickerTool, setPickerTool] = useState(null); - const [printerPickerOpen, setPrinterPickerOpen] = useState(false); + const insets = useSafeAreaInsets(); + const [openFold, setOpenFold] = useState<'routing' | 'options' | null>(null); + const [picking, setPicking] = useState(null); + const [printerOpen, setPrinterOpen] = useState(false); + const hold = useRef(new Animated.Value(0)).current; + const [holding, setHolding] = useState(false); + + const lanes = useMemo(() => { + const pool = availableSlots.length > 0 ? availableSlots : slots; + // Pad to four lanes so routeTools can address T0–T3 by index. + const byIndex = new Map(pool.map((slot) => [slot.index, asLane(slot)])); + return Array.from({ length: 4 }, (_, index) => { + const existing = byIndex.get(index); + if (existing) return existing; + return { + index, + color: requiredColors?.[index] ?? '#888888', + material: 'PLA', + status: 'empty' as const, + }; + }); + }, [availableSlots, slots, requiredColors]); + + const required = useMemo( + () => slots.map((slot) => slot.index).sort((a, b) => a - b), + [slots], + ); + + const tools = useMemo( + () => buildPreprocessTools(required, lanes, assignments, perToolGrams), + [required, lanes, assignments, perToolGrams], + ); + const selectedPrinter = printers.find((printer) => printer.id === activePrinterId) ?? printers[0]; + const printerBusy = Boolean(selectedPrinter?.busy); + + const checks = useMemo( + () => + buildPreprocessChecks({ + connected, + printerBusy, + printerName: selectedPrinter?.name ?? 'Printer', + tools, + lanes, + }), + [connected, printerBusy, selectedPrinter?.name, tools, lanes], + ); + + const failing = checks.filter((check) => check.tone === 'fail' && check.blocking); + const blocked = failing.length > 0; + const blockReason = failing[0]?.detail ?? null; + const notes = checks.filter((check) => check.tone === 'warn' || check.tone === 'fail'); + const rerouted = tools.filter((tool) => tool.source === 'auto'); + const canRemap = Boolean(onAssignSlot) && lanes.length > 0; + const routingOpen = openFold === 'routing' || blocked; const pct = Math.round(Math.max(0, Math.min(1, progress)) * 100); + const displayGrams = + estGramsTotal > 0 + ? estGramsTotal + : tools.reduce((sum, tool) => sum + tool.grams, 0); + + const layerTwoOpen = picking != null || printerOpen; + const closeTopLayer = () => { + setPicking(null); + setPrinterOpen(false); + }; + const requestClose = () => { + if (sending) return; + if (layerTwoOpen) closeTopLayer(); + else onClose(); + }; + + // Drag-down-to-dismiss for the sheet and its layer-2 picker. Latest close + // fn is held in a ref so the (once-created) PanResponder never goes stale. + const dismissMain = useRef(requestClose); + dismissMain.current = requestClose; + const dismissTop = useRef(closeTopLayer); + dismissTop.current = closeTopLayer; + const dismissOnDrag = (onDismiss: () => void) => + PanResponder.create({ + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_e, g) => g.dy > 12 && g.dy > Math.abs(g.dx), + onPanResponderRelease: (_e, g) => { + if (g.dy > 70 || g.vy > 0.6) onDismiss(); + }, + }).panHandlers; + const mainPan = useRef(dismissOnDrag(() => dismissMain.current())).current; + const topPan = useRef(dismissOnDrag(() => dismissTop.current())).current; + + const beginHold = () => { + if (blocked || sending) return; + setHolding(true); + Animated.timing(hold, { toValue: 1, duration: HOLD_MS, useNativeDriver: false }).start( + ({ finished }) => { + setHolding(false); + hold.setValue(0); + if (finished) { + try { + Vibration.vibrate(18); + } catch { + // Vibration is best-effort — some devices refuse it. + } + onSend(prefs); + } + }, + ); + }; + + const cancelHold = () => { + hold.stopAnimation(() => { + Animated.timing(hold, { toValue: 0, duration: 150, useNativeDriver: false }).start(); + }); + setHolding(false); + }; + + const holdWidth = hold.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }); + return ( - - - - - - Print Preprocessing - - - - + + + + + + - {/* Model Information */} -
- - - {thumbnail ? ( - - ) : ( - - )} - - - - - - - -
- - {/* Select Printer */} -
- {selectedPrinter ? ( - setPrinterPickerOpen(true)}> - - - {selectedPrinter.name} - - {selectedPrinter.status ?? 'Checking…'} - - - - + + {thumbnail ? ( + ) : ( - No saved printers + )} -
- - {/* Edit Filament — from the live loaded-filament wiring + per-tool grams */} -
- - {slots.map((slot) => { - const grams = perToolGrams[slot.index]; - const assignedIndex = assignments[slot.index] ?? slot.index; - const assignedSlot = availableSlots.find((candidate) => candidate.index === assignedIndex); - const effectiveSlot = assignedSlot ?? slot; - const empty = effectiveSlot.status === 'empty'; - const displayColor = effectiveSlot.color ?? requiredColors?.[slot.index] ?? slot.color; - return ( - onAssignSlot && availableSlots.length > 0 && setPickerTool(slot.index)} - disabled={!onAssignSlot || availableSlots.length === 0} - > - - - {assignedSlot?.material || slot.material || 'PLA'} - - - {typeof grams === 'number' && grams > 0 ? `${grams.toFixed(2)}g` : '—'} - - - - - T{assignedIndex} - - {assignedSlot ? ( - - {assignedSlot.status === 'loaded' ? 'Loaded' : assignedSlot.status === 'empty' ? 'Empty' : 'Unknown'} + + + {fileName.replace(/\.gcode$/i, '').split(/[\\/]/).pop() ?? fileName} + + setPrinterOpen(true)} + style={styles.heroPrinter} + disabled={printers.length === 0} + > + + + {selectedPrinter?.name ?? 'No printer'} + + + + + + + + + + + + 0 ? `${layers} layers` : 'Filament'} + /> + + + {notes.length > 0 ? ( + + {notes.map((note) => ( + + + {note.detail} + + ))} + + ) : ( + + + + {rerouted.length > 0 + ? `Routed around empty lanes — ${rerouted + .map((tool) => `T${tool.fileTool} on lane ${tool.assigned + 1}`) + .join(', ')}` + : 'Lanes loaded, printer idle'} + + + )} + + {errorMessage ? {errorMessage} : null} + + setOpenFold(routingOpen && !blocked ? null : 'routing')} + disabled={blocked} + > + + {tools.map((tool) => ( + + ))} + + + {tools.length} {tools.length === 1 ? 'lane' : 'lanes'} + + + + + + + + {tools.map((tool) => ( + canRemap && setPicking(tool.fileTool)} + disabled={!canRemap} + style={styles.laneRow} + > + + + + {laneLabel(tool.lane)} + {tool.source !== 'identity' ? ( + + {' '} + {tool.source === 'manual' ? '→' : 'auto →'} lane {tool.assigned + 1} ) : null} + + + lane {tool.assigned + 1} · {laneDetail(tool.lane)} + + + {tool.grams > 0 ? ( + {tool.grams.toFixed(1)} g + ) : null} + {canRemap ? ( + + ) : null} + + ))} + + + + setOpenFold(openFold === 'options' ? null : 'options')} + > + + + {PREF_COPY.filter(({ key }) => prefs[key]) + .map(({ label }) => label) + .join(', ') || 'Print preferences'} + + + + + + + {PREF_COPY.map(({ key, label, hint, icon }) => { + const on = prefs[key]; + return ( + onTogglePref(key)} style={styles.prefRow}> + + + {label} + {hint} + + + ); })} - -
- - {/* Print Preferences */} -
- {PREF_LABELS.map(({ key, label }) => { - const on = prefs[key]; - return ( - onTogglePref(key)}> - {label} - - {on ? : null} - - - ); - })} -
+
+ - - {errorMessage ? {errorMessage} : null} - {sending && statusMessage ? ( - {statusMessage} - ) : null} - - - - - {pct}% - + {blocked ? ( + { + const starved = tools.find((tool) => tool.lane.status === 'empty'); + if (starved && canRemap) setPicking(starved.fileTool); + else setPrinterOpen(true); + }} + > + + + {blockReason ?? 'Fix to continue'} + + + ) : ( onSend(prefs)} + style={[styles.holdBtn, sending && styles.holdOff]} > - {sending ? ( - - ) : ( - - )} - {sending ? 'Sending…' : sendLabel} + + + + {holding ? 'Keep holding…' : sendLabel} + - + )} - setPickerTool(null)}> - - setPickerTool(null)} /> - - - Select Tool Head - setPickerTool(null)} hitSlop={8}> - - - - {availableSlots.map((candidate) => { - const active = pickerTool != null && assignments[pickerTool] === candidate.index; - const status = candidate.status === 'loaded' ? 'Loaded' : candidate.status === 'empty' ? 'Empty' : 'Unknown'; - return ( - { - if (pickerTool != null) onAssignSlot?.(pickerTool, candidate.index); - setPickerTool(null); - }} - > - - - T{candidate.index} - - {[candidate.brand || 'Generic', candidate.material || 'PLA'].filter(Boolean).join(' ')} - - {status} - - {active ? : null} - - ); - })} + + {layerTwoOpen ? ( + <> + + + + + + + Back + + + {picking != null ? ( + <> + Lane for T{picking} + + Choose the physical spool that feeds this tool. + + {lanes.map((lane) => { + const active = + (tools.find((tool) => tool.fileTool === picking)?.assigned ?? picking) === + lane.index; + const empty = lane.status === 'empty'; + return ( + { + onAssignSlot?.(picking, lane.index); + closeTopLayer(); + }} + > + + {lane.index + 1} + + + {laneLabel(lane)} + {laneDetail(lane)} + + {active ? ( + + ) : null} + + ); + })} + + ) : ( + <> + Select Printer + {printers.map((printer) => { + const active = printer.id === activePrinterId; + return ( + { + onSelectPrinter(printer.id); + closeTopLayer(); + }} + > + + + + + {printer.name} + + {printer.status ?? 'Checking…'} + + + {active ? ( + + ) : null} + + ); + })} + + )} + - - setPrinterPickerOpen(false)}> - - setPrinterPickerOpen(false)} /> - - - Select Printer - setPrinterPickerOpen(false)} hitSlop={8}> - - + + ) : null} + + {sending ? ( + + + + + + Starting print + + {statusMessage || 'Preparing your print…'} + + + + + + {pct}% - {printers.map((printer) => { - const active = printer.id === activePrinterId; - return ( - { - onSelectPrinter(printer.id); - setPrinterPickerOpen(false); - }} - > - - - {printer.name} - - {printer.status ?? 'Checking…'} - - - {active ? : null} - - ); - })} - + ) : null}
); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function toneColor(tone: string): string { + if (tone === 'pass') return P.success; + if (tone === 'warn') return P.warn; + if (tone === 'fail') return P.danger; + return P.dim; +} + +function toneIcon(tone: string): IconName { + if (tone === 'pass') return 'check-circle'; + if (tone === 'warn') return 'alert-circle-outline'; + if (tone === 'fail') return 'close-circle'; + return 'help-circle-outline'; +} + +function Stat({ + icon, + value, + label, + tone, +}: { + icon: IconName; + value: string; + label: string; + tone?: string; +}) { return ( - - {title} - {children} + + + + {value} + + + {label} + ); } -function InfoLine({ label, value }: { label: string; value: string }) { +function WeightBar({ tools }: { tools: PreprocessTool[] }) { + const total = tools.reduce((sum, tool) => sum + Math.max(tool.grams, 0.01), 0) || 1; return ( - - {label}: - {value} - + + {tools.map((tool) => ( + + ))} + ); } -/** Pick black/white text for legibility over a filament colour. */ -function readableOn(hex: string): string { - const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); - if (!m) return '#fff'; - const n = parseInt(m[1], 16); - const r = (n >> 16) & 255; - const g = (n >> 8) & 255; - const b = n & 255; - const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return lum > 0.6 ? '#111' : '#fff'; +function LaneChip({ tool, size = 38 }: { tool: PreprocessTool; size?: number }) { + const empty = tool.lane.status === 'empty'; + const ring = empty + ? alpha(P.danger, 0.8) + : tool.source === 'manual' + ? P.accent + : tool.lane.color; + return ( + + T{tool.fileTool} + {empty ? ( + + + + ) : null} + + ); } const styles = StyleSheet.create({ - layer: { flex: 1, justifyContent: 'center', padding: spacing.lg }, - scrim: { backgroundColor: 'rgba(0,0,0,0.55)' }, + root: { flex: 1 }, + scrim: { backgroundColor: 'rgba(0,0,0,0.62)' }, sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, maxHeight: '88%', - backgroundColor: colors.bg, - borderRadius: radius.lg, + backgroundColor: P.bg, + borderTopLeftRadius: P.radius + 10, + borderTopRightRadius: P.radius + 10, borderWidth: 1, - borderColor: colors.border, + borderColor: P.border, + paddingHorizontal: 16, overflow: 'hidden', }, - head: { + sheetBack: { transform: [{ scale: 0.94 }, { translateY: -14 }], opacity: 0.5 }, + grabber: { + alignSelf: 'center', + width: 40, + height: 4, + borderRadius: 2, + marginTop: 10, + backgroundColor: P.border, + }, + body: { paddingTop: 16, paddingBottom: 14, gap: 11 }, + + hero: { flexDirection: 'row', alignItems: 'center', gap: 14 }, + heroThumb: { width: 72, height: 72, borderRadius: 16 }, + heroText: { flex: 1, minWidth: 0, gap: 7 }, + heroTitle: { + color: P.text, + fontSize: 21, + fontWeight: '800', + letterSpacing: -0.55, + lineHeight: 25, + }, + heroPrinter: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.border, + gap: 5, + alignSelf: 'flex-start', + paddingHorizontal: 9, + height: 28, + borderRadius: 999, + borderWidth: 1, + borderColor: P.border, + backgroundColor: P.surface, }, - title: { color: colors.text, fontSize: 16, fontWeight: '800' }, - close: { padding: 2 }, - body: { padding: spacing.md, gap: spacing.md }, - section: { - backgroundColor: colors.card, - borderRadius: radius.md, + heroPrinterText: { color: P.text, fontSize: 12, fontWeight: '700', maxWidth: 150 }, + + statBand: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderRadius: P.radius - 4, borderWidth: 1, - borderColor: colors.border, - padding: spacing.md, - gap: spacing.sm, + borderColor: P.border, + backgroundColor: P.surface, + padding: 14, + }, + statDivider: { width: StyleSheet.hairlineWidth, height: 34, backgroundColor: P.border }, + stat: { flex: 1, gap: 3 }, + statValue: { color: P.text, fontSize: 17, fontWeight: '800', letterSpacing: -0.4 }, + statLabel: { + color: P.dim, + fontSize: 10, + fontWeight: '700', + letterSpacing: 0.6, + textTransform: 'uppercase', }, - sectionTitle: { color: colors.text, fontSize: 13, fontWeight: '800' }, - modelRow: { flexDirection: 'row', gap: spacing.md }, - modelThumb: { - width: 84, - height: 84, - borderRadius: radius.md, - backgroundColor: '#0d0f12', + + notice: { borderWidth: 1, borderRadius: P.radius - 6, padding: 12, gap: 8 }, + noticeRow: { flexDirection: 'row', alignItems: 'center', gap: 9 }, + noticeText: { flex: 1, color: P.text, fontSize: 12.5, fontWeight: '700', lineHeight: 17 }, + clean: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 2 }, + cleanText: { flex: 1, color: P.dim, fontSize: 12, fontWeight: '600' }, + errText: { color: P.danger, fontSize: 12, fontWeight: '700' }, + + foldHead: { + flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', + gap: 10, + height: 52, + paddingHorizontal: 13, + borderRadius: P.radius - 6, + borderWidth: 1, + borderColor: P.border, + backgroundColor: P.surface, + }, + foldLanes: { flexDirection: 'row', gap: 4 }, + foldLabel: { color: P.text, fontSize: 12.5, fontWeight: '700' }, + foldGrow: { flex: 1 }, + foldBody: { paddingTop: 4, gap: 4 }, + weightBar: { + flex: 1, + height: 6, + borderRadius: 3, overflow: 'hidden', + flexDirection: 'row', + backgroundColor: P.surfaceAlt, }, - modelThumbImg: { width: '100%', height: '100%' }, - modelInfo: { flex: 1, minWidth: 0, justifyContent: 'center', gap: 4 }, - infoLine: { color: colors.text, fontSize: 12 }, - infoLabel: { color: colors.subtext, fontWeight: '700' }, - printerRow: { + + laneRow: { flexDirection: 'row', alignItems: 'center', - gap: spacing.sm, - paddingVertical: spacing.sm, - paddingHorizontal: spacing.sm, - borderRadius: radius.md, - borderWidth: 1, - borderColor: 'transparent', + gap: 12, + paddingVertical: 9, + paddingHorizontal: 4, }, - printerRowActive: { - backgroundColor: withAlpha(colors.primary, 0.12), - borderColor: withAlpha(colors.primary, 0.4), + laneText: { flex: 1, minWidth: 0, gap: 2 }, + laneTitle: { color: P.text, fontSize: 14, fontWeight: '700' }, + laneSub: { color: P.dim, fontSize: 11.5, fontWeight: '600' }, + laneGram: { color: P.dim, fontSize: 12, fontWeight: '800' }, + remapNote: { color: P.accent, fontSize: 12, fontWeight: '800' }, + + prefRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 9, + paddingHorizontal: 4, }, - printerName: { flex: 1, minWidth: 0, color: colors.text, fontSize: 14, fontWeight: '700' }, - printerBody: { flex: 1, minWidth: 0 }, - printerStatus: { color: colors.subtext, fontSize: 11, marginTop: 2 }, - printerBusy: { color: '#F5B45A' }, - filRow: { gap: spacing.sm, paddingVertical: 2 }, - filCard: { - width: 92, - borderRadius: radius.md, + track: { + width: 42, + height: 24, + borderRadius: 12, + backgroundColor: P.surfaceAlt, borderWidth: 1, - borderColor: colors.border, + borderColor: P.border, + padding: 2, + justifyContent: 'center', + }, + knob: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: P.dim, + alignSelf: 'flex-start', + }, + knobOn: { alignSelf: 'flex-end', backgroundColor: P.onAccent }, + + holdBtn: { + height: 56, + borderRadius: 999, + backgroundColor: P.accentFill, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 9, overflow: 'hidden', - backgroundColor: colors.cardAlt, }, - filCardEmpty: { opacity: 0.4 }, - filTop: { paddingVertical: spacing.sm, paddingHorizontal: 8, alignItems: 'center', gap: 2 }, - filMat: { fontSize: 12, fontWeight: '800' }, - filGrams: { fontSize: 12, fontWeight: '700' }, - filBadgeWrap: { alignItems: 'center', paddingVertical: spacing.sm }, - filBadge: { - width: 30, - height: 30, - borderRadius: 15, - borderWidth: 2, + holdOff: { opacity: 0.5 }, + holdFill: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + backgroundColor: 'rgba(255,255,255,0.26)', + }, + holdText: { color: P.onAccent, fontSize: 15, fontWeight: '800' }, + + fixBar: { + height: 56, + borderRadius: 999, + backgroundColor: alpha(P.danger, 0.16), + borderWidth: 1, + borderColor: alpha(P.danger, 0.5), + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + gap: 9, + paddingHorizontal: 16, }, - filBadgeText: { color: colors.text, fontSize: 13, fontWeight: '900' }, - filStatus: { color: colors.subtext, fontSize: 10, marginTop: 2 }, - pickerLayer: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.6)', + fixBarText: { flexShrink: 1, color: P.danger, fontSize: 14, fontWeight: '800' }, + + laneChip: { + borderWidth: 2.5, + alignItems: 'center', justifyContent: 'center', - paddingHorizontal: spacing.lg, + backgroundColor: P.bg, }, + laneChipText: { color: P.text, fontWeight: '900' }, + laneChipWarn: { + position: 'absolute', + right: -3, + top: -3, + width: 15, + height: 15, + borderRadius: 8, + backgroundColor: P.danger, + alignItems: 'center', + justifyContent: 'center', + }, + pickerSheet: { - alignSelf: 'center', - width: '100%', - maxWidth: 280, - backgroundColor: colors.card, - borderRadius: radius.md, + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '74%', + backgroundColor: P.bg, + borderTopLeftRadius: P.radius + 8, + borderTopRightRadius: P.radius + 8, borderWidth: 1, - borderColor: colors.border, - padding: spacing.sm, + borderColor: P.border, + overflow: 'hidden', }, pickerHeader: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - marginBottom: spacing.xs, + paddingHorizontal: P.gap, + paddingTop: 10, }, - pickerTitle: { color: colors.text, fontSize: 15, fontWeight: '700' }, + backButton: { flexDirection: 'row', alignItems: 'center', minHeight: 40, gap: 2 }, + backText: { color: P.text, fontSize: 13, fontWeight: '700' }, + pickerContent: { paddingHorizontal: P.gap, paddingBottom: P.gap, paddingTop: 4 }, + pickerTitle: { color: P.text, fontSize: 21, fontWeight: '800', letterSpacing: -0.4 }, + pickerHint: { color: P.dim, fontSize: 12, marginTop: 4, marginBottom: 12 }, pickerRow: { flexDirection: 'row', alignItems: 'center', - borderRadius: radius.sm, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.sm, + gap: 12, + minHeight: 56, + borderRadius: P.radius - 4, + paddingHorizontal: 10, + marginVertical: 3, }, - pickerRowActive: { backgroundColor: withAlpha(colors.primary, 0.14) }, - pickerDot: { - width: 20, - height: 20, - borderRadius: 10, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.2)', + pickerBadge: { + width: 34, + height: 34, + borderRadius: 17, + borderWidth: 2, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: P.bg, }, - pickerBody: { flex: 1, marginLeft: spacing.sm }, - pickerTool: { color: colors.text, fontSize: 13, fontWeight: '700' }, - pickerFilament: { color: colors.text, fontSize: 12, marginTop: 1 }, - pickerStatus: { color: colors.subtext, fontSize: 11, marginTop: 2 }, - prefRow: { - flexDirection: 'row', + pickerBadgeText: { color: P.text, fontSize: 12, fontWeight: '900' }, + printerIcon: { + width: 36, + height: 36, + borderRadius: 12, alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: spacing.sm, + justifyContent: 'center', }, - prefLabel: { color: colors.text, fontSize: 14 }, - radio: { - width: 22, - height: 22, - borderRadius: 11, - borderWidth: 2, - borderColor: colors.border, + + blockingLayer: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.76)', alignItems: 'center', justifyContent: 'center', + padding: 26, }, - radioDot: { width: 11, height: 11, borderRadius: 6, backgroundColor: colors.primary }, - footer: { - padding: spacing.md, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.border, - gap: spacing.sm, + blockingCard: { + width: '100%', + maxWidth: 380, + borderRadius: 24, + borderWidth: 1, + borderColor: P.border, + backgroundColor: P.surface, + padding: 22, + gap: 13, + alignItems: 'center', }, - errText: { color: '#ff7676', fontSize: 12, fontWeight: '600' }, - progressRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + blockingIcon: { + width: 52, + height: 52, + borderRadius: 26, + alignItems: 'center', + justifyContent: 'center', + }, + blockingTitle: { color: P.text, fontSize: 19, fontWeight: '800', letterSpacing: -0.4 }, + statusText: { color: P.text, fontSize: 13, fontWeight: '700', marginBottom: 4, textAlign: 'center' }, + progressRow: { flexDirection: 'row', alignItems: 'center', gap: 8, width: '100%' }, progressTrack: { flex: 1, height: 6, borderRadius: 3, - backgroundColor: colors.cardAlt, + backgroundColor: P.surfaceAlt, overflow: 'hidden', }, - progressFill: { height: '100%', borderRadius: 3, backgroundColor: colors.primary }, - progressPct: { color: colors.subtext, fontSize: 12, fontWeight: '700', minWidth: 34, textAlign: 'right' }, - statusText: { color: colors.text, fontSize: 13, fontWeight: '700', marginBottom: spacing.xs }, - send: { - minHeight: 48, - borderRadius: radius.md, - backgroundColor: colors.primary, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - }, - sendOff: { opacity: 0.5 }, - sendText: { color: '#fff', fontSize: 15, fontWeight: '800' }, + progressFill: { height: '100%', borderRadius: 3, backgroundColor: P.accent }, + progressPct: { color: P.dim, fontSize: 12, fontWeight: '800', minWidth: 34, textAlign: 'right' }, }); diff --git a/components/PrinterIcon.tsx b/components/PrinterIcon.tsx new file mode 100644 index 0000000..545cf77 --- /dev/null +++ b/components/PrinterIcon.tsx @@ -0,0 +1,128 @@ +// Custom 4-toolhead whole-printer icon (Snapmaker U1). +// +// Translated from assets/icons/printer.svg. Callers may tint the enclosure for +// contrast while the darker window, gantry, toolheads, and plate retain the +// illustration's depth. +import React from 'react'; +import { type ViewStyle } from 'react-native'; +import Svg, { Circle, Defs, G, LinearGradient, Path, Rect, Stop } from 'react-native-svg'; + +export function PrinterIcon({ + size, + color, + style, +}: { + size: number; + color?: string; + style?: ViewStyle; +}) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Bowden tube / filament guide stubs */} + + + + + + + + {/* Main printer enclosure */} + + + {/* Control screen */} + + + + + + {/* Door / viewing window */} + + + {/* X gantry rail */} + + + {/* Four toolheads */} + + + + + + + + {/* Build plate */} + + + ); +} + +export default PrinterIcon; diff --git a/components/PrinterStrip.tsx b/components/PrinterStrip.tsx deleted file mode 100644 index d5bbaed..0000000 --- a/components/PrinterStrip.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { api, normalizeMoonrakerUrl, printerConnectionUrl } from '../services/moonraker'; -import type { PrinterEntry } from '../hooks/useSettings'; -import { colors, spacing } from '../constants/theme'; - -interface Props { - printers: PrinterEntry[]; - activeId: string; - // live values for the active printer (from the websocket, no need to poll it) - activeState: string; - activeProgress: number; - onSwitch: (p: PrinterEntry) => void; -} - -interface PolledStatus { - state: string; - progress: number; -} - -type PrinterStatusQuery = { - print_stats?: { state?: string }; - display_status?: { progress?: number }; -}; - -function dotColor(state: string): string { - switch (state) { - case 'printing': return colors.primary; - case 'paused': return colors.warning; - case 'error': return colors.danger; - case 'complete': return colors.success; - case 'offline': return colors.border; - default: return colors.subtext; - } -} - -function pollUrl(printer: PrinterEntry): string { - return printerConnectionUrl(printer); -} - -// Non-active printers use a light REST poll instead of one websocket per chip. -export default function PrinterStrip({ - printers, - activeId, - activeState, - activeProgress, - onSwitch, -}: Props) { - const [polled, setPolled] = useState>({}); - const timerRef = useRef | null>(null); - - useEffect(() => { - let live = true; - const poll = async () => { - const others = printers.filter((p) => p.id !== activeId); - for (const p of others) { - const url = pollUrl(p); - if (!url) { - setPolled((prev) => ({ ...prev, [p.id]: { state: 'offline', progress: 0 } })); - continue; - } - try { - const res = await api.queryObjects(normalizeMoonrakerUrl(url), [ - 'print_stats', - 'display_status', - ]); - if (!live) return; - setPolled((prev) => ({ - ...prev, - [p.id]: { - state: res?.status?.print_stats?.state ?? 'unknown', - progress: res?.status?.display_status?.progress ?? 0, - }, - })); - } catch { - if (!live) return; - setPolled((prev) => ({ ...prev, [p.id]: { state: 'offline', progress: 0 } })); - } - } - }; - poll(); - timerRef.current = setInterval(poll, 15000); - return () => { - live = false; - if (timerRef.current) clearInterval(timerRef.current); - }; - }, [printers, activeId]); - - if (printers.length < 2) return null; - - return ( - - {printers.map((p) => { - const isActive = p.id === activeId; - const st = isActive - ? { state: activeState, progress: activeProgress } - : polled[p.id] ?? { state: 'unknown', progress: 0 }; - return ( - !isActive && onSwitch(p)} - > - - - {p.name} - - {st.state === 'printing' && ( - {Math.round(st.progress * 100)}% - )} - - ); - })} - - ); -} - -const styles = StyleSheet.create({ - strip: { - flexGrow: 0, - }, - chip: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - backgroundColor: colors.card, - borderRadius: 16, - borderWidth: 1, - borderColor: colors.border, - paddingVertical: 6, - paddingHorizontal: 12, - marginRight: spacing.sm, - }, - dot: { - width: 8, - height: 8, - borderRadius: 4, - }, - name: { - color: colors.subtext, - fontSize: 12, - fontWeight: '600', - maxWidth: 120, - }, - pct: { - color: colors.primary, - fontSize: 11, - fontWeight: '700', - }, -}); diff --git a/components/SpoolLabel.tsx b/components/SpoolLabel.tsx index 90aba17..6ca6e0f 100644 --- a/components/SpoolLabel.tsx +++ b/components/SpoolLabel.tsx @@ -1,13 +1,13 @@ import React, { useRef, useState } from 'react'; -import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Modal, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; import ViewShot from 'react-native-view-shot'; import * as MediaLibrary from 'expo-media-library'; import * as Sharing from 'expo-sharing'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { t } from '../services/i18n'; -import { colors, spacing } from '../constants/theme'; -import { useThemedAlert } from '../hooks/useThemedAlert'; +import { colors, spacing, withAlpha } from '../constants/theme'; interface Props { spoolId: number; @@ -24,9 +24,15 @@ export function spoolQrValue(id: number): string { } export default function SpoolLabel({ spoolId, title, material, colorHex, onClose }: Props) { + const insets = useSafeAreaInsets(); const shotRef = useRef(null); const [busy, setBusy] = useState(false); - const { showAlert, alertDialog } = useThemedAlert(); + const [alert, setAlert] = useState<{ + title: string; + message: string; + icon: React.ComponentProps['name']; + } | null>(null); + const showAlert = setAlert; const capture = async (): Promise => { try { @@ -94,34 +100,41 @@ export default function SpoolLabel({ spoolId, title, material, colorHex, onClose }; return ( - <> - - - - - {t('Spool label')} - - - + setAlert(null) : onClose}> + + + + + + LAYER 1 + {t('Spool label')} - {/* White label background preserves print contrast. */} - - - - - {title} - - - {colorHex ? ( - - ) : null} - - {[material, `#${spoolId}`].filter(Boolean).join(' · ')} + + {/* White label background preserves print contrast. */} + + + + + {title} + + {colorHex ? ( + + ) : null} + + {[material, `#${spoolId}`].filter(Boolean).join(' · ')} + + - - + + - - - {alertDialog} - + + {alert ? ( + + setAlert(null)} /> + + + + + {alert.title} + {alert.message} + setAlert(null)}> + {t('OK')} + + + + ) : null} + + ); } const styles = StyleSheet.create({ wrap: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.7)', - alignItems: 'center', - justifyContent: 'center', - padding: spacing.xl, + backgroundColor: 'rgba(0,0,0,0.55)', }, - card: { + deckLayer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '82%', backgroundColor: colors.bg, - borderRadius: 14, - padding: spacing.lg, - alignSelf: 'stretch', + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + borderWidth: 1, + borderColor: colors.border, + overflow: 'hidden', }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.md, + deckLayerBack: { + transform: [{ scale: 0.94 }, { translateY: -14 }], + opacity: 0.5, }, - headerTitle: { - color: colors.text, - fontSize: 15, - fontWeight: '700', + grabber: { + alignSelf: 'center', + width: 40, + height: 4, + borderRadius: 2, + marginTop: 10, + backgroundColor: colors.border, }, + deckHead: { paddingHorizontal: 18, paddingTop: 12, gap: 3 }, + deckDepth: { color: colors.subtext, fontSize: 9, fontWeight: '800', letterSpacing: 1.4 }, + deckTitle: { color: colors.text, fontSize: 21, fontWeight: '800', letterSpacing: -0.5 }, + deckBody: { padding: 18 }, label: { backgroundColor: '#FFFFFF', borderRadius: 8, @@ -209,18 +245,19 @@ const styles = StyleSheet.create({ }, btnRow: { flexDirection: 'row', - gap: spacing.sm, - marginTop: spacing.md, + gap: 8, + paddingHorizontal: 18, + paddingTop: 14, }, btn: { flex: 1, + height: 52, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, backgroundColor: colors.cardAlt, - borderRadius: 8, - paddingVertical: spacing.md, + borderRadius: 999, }, btnText: { color: colors.text, @@ -232,4 +269,54 @@ const styles = StyleSheet.create({ fontWeight: '600', fontSize: 13, }, + alertLayer: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.74)', + alignItems: 'center', + justifyContent: 'center', + padding: 26, + }, + alertCard: { + width: '100%', + maxWidth: 380, + borderRadius: 24, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.card, + padding: 22, + gap: 13, + alignItems: 'center', + }, + alertIcon: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: withAlpha(colors.primary, 0.14), + alignItems: 'center', + justifyContent: 'center', + }, + alertTitle: { + color: colors.text, + fontSize: 19, + fontWeight: '800', + letterSpacing: -0.4, + textAlign: 'center', + }, + alertMessage: { + color: colors.subtext, + fontSize: 13, + fontWeight: '600', + lineHeight: 19, + textAlign: 'center', + }, + alertAction: { + alignSelf: 'stretch', + height: 52, + borderRadius: 999, + backgroundColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + marginTop: 4, + }, + alertActionText: { color: '#fff', fontSize: 14, fontWeight: '800' }, }); diff --git a/components/SpoolScanner.tsx b/components/SpoolScanner.tsx index 6e640fa..f53ba30 100644 --- a/components/SpoolScanner.tsx +++ b/components/SpoolScanner.tsx @@ -2,6 +2,7 @@ import React, { useRef, useState } from 'react'; import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { t } from '../services/i18n'; import { colors, spacing } from '../constants/theme'; @@ -25,6 +26,7 @@ export function parseSpoolQr(data: string): number | null { } export default function SpoolScanner({ onScanned, onClose }: Props) { + const insets = useSafeAreaInsets(); const [permission, requestPermission] = useCameraPermissions(); const [badScan, setBadScan] = useState(false); // barcode events fire repeatedly per frame — latch after the first hit @@ -72,7 +74,10 @@ export default function SpoolScanner({ onScanned, onClose }: Props) { - + @@ -106,12 +111,13 @@ const styles = StyleSheet.create({ }, closeBtn: { position: 'absolute', - top: 50, - right: 20, + right: 14, backgroundColor: 'rgba(30,30,30,0.8)', - borderRadius: 18, - width: 36, - height: 36, + borderRadius: 20, + borderWidth: 1, + borderColor: colors.border, + width: 40, + height: 40, alignItems: 'center', justifyContent: 'center', }, diff --git a/components/TailscaleIcon.tsx b/components/TailscaleIcon.tsx new file mode 100644 index 0000000..6f4f38f --- /dev/null +++ b/components/TailscaleIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Svg, { Circle } from 'react-native-svg'; + +type Props = { size?: number; color?: string }; + +/** Official Tailscale dot-grid mark, kept inline so it stays crisp and works offline. */ +export default function TailscaleIcon({ size = 20, color = '#FFFFFF' }: Props) { + return ( + + + + + + + + + + + + ); +} diff --git a/components/TempGauge.tsx b/components/TempGauge.tsx deleted file mode 100644 index fd023a2..0000000 --- a/components/TempGauge.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { colors, spacing } from '../constants/theme'; -import { displayTemperature, temperatureUnitSymbol } from '../services/temperature'; -import type { TemperatureUnit } from '../services/temperature'; - -interface Props { - name: string; - temperature?: number; - target?: number; - power?: number; // 0..1 - active?: boolean; - temperatureUnit?: TemperatureUnit; -} - -function tempColor(temp: number, target: number): string { - if (target > 0) { - return temp < target - 5 ? colors.warning : colors.hot; - } - return temp >= 50 ? colors.warning : colors.cold; -} - -export default function TempGauge({ - name, - temperature, - target, - power, - active, - temperatureUnit = 'c', -}: Props) { - const temp = temperature ?? 0; - const tgt = target ?? 0; - const color = tempColor(temp, tgt); - const unitSymbol = temperatureUnitSymbol(temperatureUnit); - - return ( - - - {name} - {active ? ACTIVE : null} - - - {displayTemperature(temp, temperatureUnit).toFixed(1)} - {unitSymbol} - - - {tgt > 0 ? `\u2192 ${displayTemperature(tgt, temperatureUnit).toFixed(0)}${unitSymbol}` : 'off'} - - {typeof power === 'number' ? ( - - - - ) : null} - - ); -} - -const styles = StyleSheet.create({ - card: { - backgroundColor: colors.card, - borderRadius: 10, - borderWidth: 1, - borderColor: colors.border, - padding: spacing.md, - flex: 1, - minWidth: '45%', - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - name: { - color: colors.subtext, - fontSize: 12, - fontWeight: '600', - textTransform: 'uppercase', - }, - activeTag: { - fontSize: 9, - fontWeight: '700', - }, - temp: { - fontSize: 26, - fontWeight: '700', - marginTop: spacing.xs, - }, - unit: { - fontSize: 14, - fontWeight: '400', - }, - target: { - color: colors.subtext, - fontSize: 12, - marginTop: 2, - }, - powerTrack: { - height: 3, - backgroundColor: colors.cardAlt, - borderRadius: 2, - marginTop: spacing.sm, - overflow: 'hidden', - }, - powerFill: { - height: 3, - borderRadius: 2, - }, -}); diff --git a/components/ThemedDialog.tsx b/components/ThemedDialog.tsx index c94123d..51f9a15 100644 --- a/components/ThemedDialog.tsx +++ b/components/ThemedDialog.tsx @@ -1,9 +1,24 @@ +// Confirms and alerts, in the chosen modal system. +// +// Shape is derived from consequence and content: destructive confirmations use +// red Focus, forms use neutral Focus, and acknowledgement-only dialogs use +// Layer. Deriving it here keeps the system consistent at every call site. +// +// The `placement` prop is kept for source compatibility but no longer decides +// anything; an irreversible action shouldn't be able to opt into looking casual. import React from 'react'; -import { KeyboardAvoidingView, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { + KeyboardAvoidingView, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing } from '../constants/theme'; -import { useSettings } from '../hooks/useSettings'; +import { alpha, COCKPIT as P } from './dashboard/shared'; type IconName = React.ComponentProps['name']; @@ -20,20 +35,44 @@ interface Props { title: string; message?: string; icon?: IconName; + /** @deprecated Shape now follows the content and actions. */ placement?: 'bottom' | 'center'; + /** + * Override the derived shape. 'auto' (default) sends anything carrying a + * danger action to Focus. Pass 'layer' for a destructive action that is + * recoverable — cancelling a print loses the print, not the machine — so it + * keeps the red button without taking over the whole screen. + */ + shape?: 'auto' | 'layer' | 'focus'; onClose: () => void; actions: DialogAction[]; children?: React.ReactNode; } -function actionStyle(variant: DialogAction['variant'], accentColor: string) { - if (variant === 'primary') return { backgroundColor: accentColor }; - if (variant === 'danger') return styles.dangerBtn; - return styles.secondaryBtn; -} +function ActionButton({ action, big }: { action: DialogAction; big?: boolean }) { + const variant = action.variant ?? 'secondary'; + const fg = variant === 'primary' ? P.onAccent : variant === 'danger' ? '#FFFFFF' : P.text; + const bg = variant === 'primary' ? P.accentFill : variant === 'danger' ? P.danger : P.surfaceAlt; -function actionTextStyle(variant: DialogAction['variant']) { - return variant === 'primary' || variant === 'danger' ? styles.lightActionText : styles.actionText; + return ( + [ + styles.action, + { backgroundColor: bg, height: big ? 62 : 52 }, + action.disabled && { opacity: 0.5 }, + pressed && { opacity: 0.78 }, + ]} + > + {action.icon ? ( + + ) : null} + + {action.text} + + + ); } export default function ThemedDialog({ @@ -41,69 +80,87 @@ export default function ThemedDialog({ title, message, icon, - placement = 'bottom', + shape = 'auto', onClose, actions, children, }: Props) { - const centered = placement === 'center'; - const { settings } = useSettings(); - const accentColor = settings.accentColor || colors.primary; - // Issue #5: bottom sheets draw edge-to-edge, so pad past the Android nav bar. const insets = useSafeAreaInsets(); - return ( - - - - - - - {icon ? ( - - - - ) : null} - {title} + const danger = actions.some((action) => action.variant === 'danger'); + const confirmation = actions.length > 1; + const form = children != null; + // An explicit shape wins over the derivation, so a destructive-but- + // recoverable action can stay a Layer card instead of taking the screen. + const focus = + shape === 'focus' || (shape === 'auto' && (danger || confirmation || form)); + const orderedActions = focus + ? [...actions].sort((a, b) => { + const priority = (action: DialogAction) => + action.variant === 'danger' || action.variant === 'primary' ? 0 : 1; + return priority(a) - priority(b); + }) + : actions; + + if (focus) { + return ( + + + {danger ? : null} + + {!form ? ( + + - - - + ) : null} + {title} + {message ? {message} : null} + {children} + + + {/* Stacked and full-width: a row of small buttons puts the + destructive one under your thumb by accident. */} + + {orderedActions.map((action) => ( + + ))} + + + + ); + } + + return ( + + + + + + + + {title} {message ? {message} : null} {children} 2 && styles.actionsStacked]}> - {actions.map((action) => { - const variant = action.variant ?? 'secondary'; - return ( - - {action.icon ? ( - - ) : null} - {action.text} - - ); - })} + {actions.map((action) => ( + + ))} @@ -113,108 +170,87 @@ export default function ThemedDialog({ } const styles = StyleSheet.create({ - wrap: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.6)', - justifyContent: 'flex-end', + flex: { flex: 1 }, + scrim: { ...StyleSheet.absoluteFillObject, backgroundColor: alpha('#000000', 0.74) }, + + // Focus — irreversible actions get the whole screen. + focus: { flex: 1, backgroundColor: P.bg }, + focusForm: { flex: 1, backgroundColor: P.bg }, + focusTint: { + ...StyleSheet.absoluteFillObject, + backgroundColor: alpha(P.danger, 0.1), }, - keyboardWrap: { - flex: 1, + focusBody: { + flexGrow: 1, + padding: 26, + gap: 16, + alignItems: 'center', + justifyContent: 'center', }, - wrapCenter: { + focusIcon: { + width: 92, + height: 92, + borderRadius: 46, + alignItems: 'center', justifyContent: 'center', - padding: spacing.lg, }, - card: { - backgroundColor: colors.bg, - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - padding: spacing.lg, + focusTitle: { + color: P.text, + fontSize: 30, + fontWeight: '800', + letterSpacing: -1, + textAlign: 'center', }, - cardCenter: { + focusMessage: { + color: P.dim, + fontSize: 15, + fontWeight: '600', + lineHeight: 22, + textAlign: 'center', + }, + focusActions: { paddingHorizontal: 22, gap: 9 }, + + // Layer — everything else. + centreWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 26 }, + card: { width: '100%', maxWidth: 380, - alignSelf: 'center', - borderRadius: 16, + borderRadius: 24, borderWidth: 1, - borderColor: colors.border, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - gap: spacing.md, - marginBottom: spacing.md, - }, - titleRow: { - flex: 1, - flexDirection: 'row', + borderColor: P.border, + backgroundColor: P.surface, + padding: 22, + gap: 13, alignItems: 'center', - gap: spacing.sm, }, iconBadge: { - width: 34, - height: 34, - borderRadius: 17, - backgroundColor: colors.card, - borderWidth: 1, - borderColor: colors.border, + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: alpha(P.accent, 0.14), alignItems: 'center', justifyContent: 'center', }, title: { - flex: 1, - color: colors.text, - fontSize: 16, - fontWeight: '700', - }, - message: { - color: colors.subtext, - fontSize: 13, - lineHeight: 19, - marginBottom: spacing.lg, - }, - actions: { - flexDirection: 'row', - gap: spacing.sm, - }, - actionsStacked: { - flexDirection: 'column', + color: P.text, + fontSize: 19, + fontWeight: '800', + letterSpacing: -0.4, + textAlign: 'center', }, - actionBtn: { - flex: 1, - minHeight: 44, - borderRadius: 8, + message: { color: P.dim, fontSize: 13, fontWeight: '600', lineHeight: 19, textAlign: 'center' }, + + actions: { flexDirection: 'row', gap: 8, alignSelf: 'stretch', paddingTop: 4 }, + actionsStacked: { flexDirection: 'column' }, + action: { + flexGrow: 1, + flexShrink: 1, + borderRadius: 999, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 6, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - }, - secondaryBtn: { - backgroundColor: colors.cardAlt, - borderWidth: 1, - borderColor: colors.border, - }, - dangerBtn: { - backgroundColor: colors.danger, - }, - disabledBtn: { - opacity: 0.5, - }, - actionText: { - flexShrink: 1, - color: colors.text, - fontSize: 13, - fontWeight: '700', - textAlign: 'center', - }, - lightActionText: { - flexShrink: 1, - color: '#fff', - fontSize: 13, - fontWeight: '700', - textAlign: 'center', + gap: 7, + paddingHorizontal: 16, }, + actionText: { fontWeight: '800', flexShrink: 1 }, }); diff --git a/components/TimelapseView.tsx b/components/TimelapseView.tsx deleted file mode 100644 index 05788a5..0000000 --- a/components/TimelapseView.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - FlatList, - Image, - Linking, - Modal, - RefreshControl, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; -import { WebView } from 'react-native-webview'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { api, FileEntry, fileUrl } from '../services/moonraker'; -import { t } from '../services/i18n'; -import { colors, spacing } from '../constants/theme'; - -interface Clip { - video: FileEntry; - thumbUrl: string | null; -} - -function fmtSize(bytes: number): string { - if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / 1024).toFixed(0)} KB`; -} - -function fmtDate(epoch: number): string { - const d = new Date(epoch * 1000); - return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} - -// player page: plain