@@ -42,6 +42,7 @@ import androidx.compose.animation.AnimatedVisibility
4242import androidx.compose.material.icons.Icons
4343import androidx.compose.material.icons.automirrored.filled.Redo
4444import androidx.compose.material.icons.automirrored.filled.Undo
45+ import androidx.compose.material.icons.filled.AttachFile
4546import androidx.compose.material.icons.filled.Backspace
4647import androidx.compose.material.icons.filled.Close
4748import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -53,6 +54,7 @@ import androidx.compose.material3.HorizontalDivider
5354import androidx.compose.material3.AssistChip
5455import androidx.compose.material3.AssistChipDefaults
5556import androidx.compose.material3.Button
57+ import androidx.compose.material3.Checkbox
5658import androidx.compose.material3.SegmentedButton
5759import androidx.compose.material3.SegmentedButtonDefaults
5860import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@@ -72,6 +74,7 @@ import androidx.compose.runtime.Composable
7274import androidx.compose.runtime.LaunchedEffect
7375import androidx.compose.runtime.collectAsState
7476import androidx.compose.runtime.getValue
77+ import androidx.compose.runtime.mutableStateMapOf
7578import androidx.compose.runtime.mutableStateOf
7679import androidx.compose.runtime.remember
7780import androidx.compose.runtime.setValue
@@ -162,13 +165,16 @@ fun WheelDiagnosticsDialog(
162165 Tab (selected = tab == 0 , onClick = { tab = 0 },
163166 text = { Text (" Commands" ) })
164167 Tab (selected = tab == 1 , onClick = { tab = 1 },
168+ text = { Text (" Inspect" ) })
169+ Tab (selected = tab == 2 , onClick = { tab = 2 },
165170 text = { Text (" Raw" ) })
166171 }
167172
168173 Box (modifier = Modifier .weight(2f , fill = true )) {
169174 when (tab) {
170175 0 -> CommandsTab (vm)
171- 1 -> RawTab (vm)
176+ 1 -> InspectTab (vm)
177+ 2 -> RawTab (vm)
172178 }
173179 }
174180 }
@@ -264,6 +270,7 @@ private fun LogPanel(modifier: Modifier = Modifier) {
264270@Composable
265271private fun CommentRow (vm : WheelDiagnosticsViewModel ) {
266272 var comment by remember { mutableStateOf(" " ) }
273+ var showAttachDialog by remember { mutableStateOf(false ) }
267274 Row (
268275 modifier = Modifier .fillMaxWidth(),
269276 verticalAlignment = Alignment .CenterVertically
@@ -290,8 +297,78 @@ private fun CommentRow(vm: WheelDiagnosticsViewModel) {
290297 keyboardActions = KeyboardActions (onSend = { send() }),
291298 singleLine = true
292299 )
300+ IconButton (onClick = { showAttachDialog = true }) {
301+ Icon (Icons .Default .AttachFile , contentDescription = " Attach data" )
302+ }
293303 IconButton (onClick = { send() }) { Icon (Icons .Default .Send , contentDescription = " Add" ) }
294304 }
305+ if (showAttachDialog) {
306+ AttachDataDialog (
307+ onDismiss = { showAttachDialog = false },
308+ onSubmit = { selected ->
309+ vm.attach(selected)
310+ showAttachDialog = false
311+ }
312+ )
313+ }
314+ }
315+
316+ /* *
317+ * Opt-in attach picker. Each box maps to a [WheelDiagnosticsViewModel.AttachCategory];
318+ * confirming dumps the selected categories into the live log as NOTE entries.
319+ * Nothing leaves the device until the user shares the log themselves.
320+ */
321+ @Composable
322+ private fun AttachDataDialog (
323+ onDismiss : () -> Unit ,
324+ onSubmit : (Set <WheelDiagnosticsViewModel .AttachCategory >) -> Unit
325+ ) {
326+ val selected = remember {
327+ mutableStateMapOf<WheelDiagnosticsViewModel .AttachCategory , Boolean >().apply {
328+ WheelDiagnosticsViewModel .AttachCategory .entries.forEach { put(it, false ) }
329+ }
330+ }
331+ AlertDialog (
332+ onDismissRequest = onDismiss,
333+ title = { Text (" Attach data to log" ) },
334+ text = {
335+ Column {
336+ Text (
337+ " Picks below are dumped into THIS log only. Nothing is sent until you share the log yourself." ,
338+ style = MaterialTheme .typography.bodySmall,
339+ color = MaterialTheme .colorScheme.onSurfaceVariant
340+ )
341+ Spacer (Modifier .height(8 .dp))
342+ WheelDiagnosticsViewModel .AttachCategory .entries.forEach { cat ->
343+ Row (
344+ modifier = Modifier
345+ .fillMaxWidth()
346+ .clickable { selected[cat] = ! (selected[cat] ? : false ) }
347+ .padding(vertical = 4 .dp),
348+ verticalAlignment = Alignment .CenterVertically
349+ ) {
350+ Checkbox (
351+ checked = selected[cat] ? : false ,
352+ onCheckedChange = { selected[cat] = it }
353+ )
354+ Text (cat.label, style = MaterialTheme .typography.bodyMedium)
355+ }
356+ }
357+ }
358+ },
359+ confirmButton = {
360+ val any = selected.values.any { it }
361+ TextButton (
362+ enabled = any,
363+ onClick = {
364+ onSubmit(selected.filterValues { it }.keys)
365+ }
366+ ) { Text (" Attach" ) }
367+ },
368+ dismissButton = {
369+ TextButton (onClick = onDismiss) { Text (" Cancel" ) }
370+ }
371+ )
295372}
296373
297374@Composable
@@ -423,6 +500,129 @@ private fun CommandsTab(vm: WheelDiagnosticsViewModel) {
423500 }
424501}
425502
503+ /* *
504+ * Live byte interpreter. Picks the most recent NOTE entry whose text starts
505+ * with the selected message type prefix and renders every byte as a small
506+ * tappable cell showing offset, hex, and decimal. Tapping a cell drops a
507+ * structured COMMENT into the log so the user can correlate the byte they
508+ * just clicked with whatever value the wheel's own UI is showing — useful
509+ * for finding unknown offsets like motor temp.
510+ */
511+ @Composable
512+ private fun InspectTab (vm : WheelDiagnosticsViewModel ) {
513+ val entries by vm.entries.collectAsState()
514+ val types = vm.inspectMessageTypes
515+ var selected by remember { mutableStateOf(types.first()) }
516+ var menuExpanded by remember { mutableStateOf(false ) }
517+
518+ val latestBytes: List <Int > = remember(entries, selected) {
519+ val match = entries.lastOrNull {
520+ it.kind == DiagnosticsLogger .Kind .NOTE && it.text.startsWith(" $selected len=" )
521+ } ? : return @remember emptyList()
522+ match.text.substringAfter(" body=" , " " ).trim()
523+ .split(' ' )
524+ .mapNotNull { runCatching { it.toInt(16 ) }.getOrNull() }
525+ }
526+
527+ Column (modifier = Modifier .fillMaxSize().padding(8 .dp)) {
528+ // Type picker. Just one or two options today, so a tiny dropdown is
529+ // sufficient — no need for the heavier SimpleDropdown helper.
530+ Box {
531+ OutlinedButton (onClick = { menuExpanded = true }) {
532+ Text (selected)
533+ Spacer (Modifier .width(6 .dp))
534+ Icon (
535+ imageVector = Icons .Default .KeyboardArrowDown ,
536+ contentDescription = null
537+ )
538+ }
539+ androidx.compose.material3.DropdownMenu (
540+ expanded = menuExpanded,
541+ onDismissRequest = { menuExpanded = false }
542+ ) {
543+ types.forEach { t ->
544+ androidx.compose.material3.DropdownMenuItem (
545+ text = { Text (t) },
546+ onClick = { selected = t; menuExpanded = false }
547+ )
548+ }
549+ }
550+ }
551+
552+ Spacer (Modifier .height(6 .dp))
553+ Text (
554+ " Tap a byte to log a comment with its offset and value." ,
555+ style = MaterialTheme .typography.bodySmall,
556+ color = MaterialTheme .colorScheme.onSurfaceVariant
557+ )
558+ Spacer (Modifier .height(8 .dp))
559+
560+ if (latestBytes.isEmpty()) {
561+ Text (
562+ " No \" $selected \" frames in the log yet. Connect the wheel and wait for one to arrive." ,
563+ style = MaterialTheme .typography.bodySmall
564+ )
565+ return @Column
566+ }
567+
568+ // 6-column grid of byte cells. Lazy so 96-byte bodies don't slow
569+ // down recomposition when the underlying frame stream is at 5 Hz.
570+ LazyVerticalGrid (
571+ columns = GridCells .Fixed (6 ),
572+ modifier = Modifier .fillMaxSize()
573+ ) {
574+ items(latestBytes.size) { off ->
575+ val v = latestBytes[off]
576+ ByteInspectCell (off, v) {
577+ vm.logByteInspection(selected, off, v)
578+ }
579+ }
580+ }
581+ }
582+ }
583+
584+ @Composable
585+ private fun ByteInspectCell (offset : Int , value : Int , onTap : () -> Unit ) {
586+ val hex = remember(value) { " %02x" .format(value) }
587+ Column (
588+ modifier = Modifier
589+ .padding(2 .dp)
590+ .border(
591+ 1 .dp,
592+ MaterialTheme .colorScheme.outline.copy(alpha = 0.3f ),
593+ RoundedCornerShape (4 .dp)
594+ )
595+ .clickable(onClick = onTap)
596+ .padding(4 .dp),
597+ horizontalAlignment = Alignment .CenterHorizontally
598+ ) {
599+ Text (
600+ " [$offset ]" ,
601+ style = MaterialTheme .typography.labelSmall.copy(
602+ fontFamily = FontFamily .Monospace ,
603+ fontSize = 9 .sp
604+ ),
605+ color = MaterialTheme .colorScheme.onSurfaceVariant
606+ )
607+ Text (
608+ hex,
609+ style = MaterialTheme .typography.labelMedium.copy(
610+ fontFamily = FontFamily .Monospace ,
611+ fontSize = 12 .sp
612+ ),
613+ color = MaterialTheme .colorScheme.primary
614+ )
615+ Text (
616+ " $value " ,
617+ style = MaterialTheme .typography.labelSmall.copy(
618+ fontFamily = FontFamily .Monospace ,
619+ fontSize = 9 .sp
620+ ),
621+ color = MaterialTheme .colorScheme.onSurfaceVariant
622+ )
623+ }
624+ }
625+
426626@Composable
427627private fun RawTab (vm : WheelDiagnosticsViewModel ) {
428628 var raw by remember { mutableStateOf(" " ) }
0 commit comments