Build shared Compose Multiplatform UI in Meshtastic-Android - adaptive layouts on Material 3 Adaptive, plus the string and resource rules. Use this whenever you add or change a composable, add a user-facing string, or work on tablet, desktop or landscape layout. Consult the bundled `strings-index.txt` rather than opening the raw `strings.xml`, which is guarded for size.
71
87%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive.
currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true) to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktopApp/tablets.AlertHost(alertManager) from core:ui/commonMain. Do NOT trigger alerts inline or duplicate alert logic. Use SharedDialogs(uiViewModel) for general popups.PlaceholderScreen(name) from core:ui/commonMain for unimplemented desktopApp/JVM features.ThemePickerDialog from feature:settings/commonMain.CompositionLocal from the androidApp or desktopApp shells. Do not tightly couple Google Maps dependencies to commonMain; the MapLibre surfaces live in :feature:map-maplibre, not in a core module.core:resources (e.g., stringResource(Res.string.your_key)). Never use hardcoded strings.getStringSuspend(Res.string.your_key). NEVER use blocking getString() in a coroutine context.stringResource only supports %N$s (string) and %N$d (integer).
%N$.1f pass through unsubstituted. Pre-format in Kotlin using NumberFormatter.format(value, decimalPlaces) from core:common and pass as a string argument (%N$s):
val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5"
stringResource(Res.string.battery_percent, formatted) // uses %1$s% (not %%) for literal percent signs in CMP-consumed strings.Choose the right tool for the job:
| Scenario | Tool | Example |
|---|---|---|
| Metric display (temp, voltage, %, signal) | MetricFormatter.* | MetricFormatter.temperature(25.0f, isFahrenheit) → "77.0°F" |
| Simple number + unit | NumberFormatter + interpolation | "${NumberFormatter.format(val, 1)} dB" |
| Localized template from strings.xml | stringResource(Res.string.key, preFormattedArgs) | stringResource(Res.string.battery, formatted) |
| Non-composable template (notifications, plain functions) | formatString(template, args) | formatString(template, label, value) |
| Hex formatting | formatString | formatString("!%08x", nodeNum) |
| Date/time | DateFormatter | DateFormatter.format(instant) |
Rules:
%.Nf in strings.xml — CMP cannot substitute them. Use %N$s and pre-format floats.MetricFormatter over scattered formatString("%.1f°C", temp) calls.formatString (pure Kotlin) is a pure-Kotlin commonMain implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in core:common Formatter.kt.NumberFormatter always uses . as decimal separator — intentional for mesh networking precision.core/resources/src/commonMain/composeResources/values/strings.xml.python3 scripts/sort-strings.py — keeps the file sorted and regenerates strings-index.txt.org.meshtastic.core.resources.<key> symbol.values/schema_strings.xml, keyed by schema path: Config.LoRaConfig.hop_limit is
Res.string.schema_lora_hop_limit, its summary schema_lora_hop_limit_description, the enum value
PositionFlags.DOP schema_position_positionflags_dop (all indexed under ### SCHEMA in strings-index.txt).
A control that edits one whole schema field uses that key; a control that edits a bit, a threshold, a negation or
drops a unit keeps a hand-written string. Never edit the generated file or write a schema_ key by hand;
:schema-strings:test fails on both. Wrong wording is a protobufs change. After a protobufs pin bump the
hourly scheduled-updates run regenerates the file (it records the pin it was built from); run
./gradlew :schema-strings:sync yourself only when you need a new key before that PR lands.libs.coil (Coil Compose) in feature modules. Configuration/Networking for Coil (coil-network-ktor3) happens strictly in the androidApp and desktopApp host modules.rememberQrCodePainter from core:ui/commonMain powered by qrcode-kotlin. No ZXing or Android Bitmap APIs in shared code.@Preview in commonMain via compose-multiplatform-ui-tooling-preview. Place preview functions alongside their composables.androidx.compose.ui.tooling.preview.Preview. The JetBrains-prefixed import (org.jetbrains.compose.ui.tooling.preview.Preview) is deprecated.rememberSaveable for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom Saver needed.CMP 1.12+ ships an MCP server inside Compose Hot Reload; .mcp.json registers it as compose-hot-reload (:desktopApp:hotMcpServer). With ./gradlew :desktopApp:hotRun running, it drives the live app — inspect, input and reload without a rebuild.
status, reload, await_reload, get_semantic_tree, click, type_text, scroll, get_logs, get_ui_error, take_screenshot.status until connected: true before anything else — the server accepts requests before the app has connected to it.get_semantic_tree is the assertion surface: roles, text, selected/focused, available actions and bounds. click addresses nodes by nodeId taken from that tree. Prefer it over take_screenshot, whose output depends on the host renderer.reload after editing sources applies the change into the running app; use await_reload instead when the app was started with --auto.core/resources/src/commonMain/composeResources/values/strings.xmlcore/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.ktandroidApp/src/main/kotlin/org/meshtastic/app/MainActivity.ktd003a21
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.