Dashboard on the design system (redesign phase 3)

Library screen now uses the shared primitives: filled SonarField search
with leading magnifier, PillChip sort/show filters (selected pill gets
the teal glow), SonarCard rows with a "Your recordings — N of M"
section header per the reference, ghost icon row-actions, and the
quiet-red bulk Delete. Rename dialog rides the same field/buttons.
All handlers, selection, filtering, and batch logic untouched.
This commit is contained in:
avi 2026-09-18 18:17:03 -05:00
commit a3d16a6c18

View file

@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -222,23 +223,23 @@ fun LibraryScreen(state: DesktopState) {
val deleteWarning by state.deleteWarning.collectAsState() val deleteWarning by state.deleteWarning.collectAsState()
val selected by state.selected.collectAsState() val selected by state.selected.collectAsState()
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(Shonar.Space3)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
ShonarWordmark(modifier = Modifier.weight(1f)) ShonarWordmark(modifier = Modifier.weight(1f))
TextButton({ state.go(DesktopState.Screen.SETTINGS) }) { ShonarGhostButton({ state.go(DesktopState.Screen.SETTINGS) }) {
Icon(Icons.Filled.Settings, contentDescription = null, Icon(Icons.Filled.Settings, contentDescription = "Settings",
modifier = Modifier.size(18.dp)) modifier = Modifier.size(Shonar.IconSm))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(Shonar.Space2))
Text("Settings") Text("Settings")
} }
} }
if (!connected) { if (!connected) {
Card(Modifier.fillMaxWidth()) { SonarCard(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text("Transcription engine isn't running.", Text("Transcription engine isn't running.",
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
TextButton({ state.go(DesktopState.Screen.ENGINE) }) { ShonarGhostButton({ state.go(DesktopState.Screen.ENGINE) }) {
Text("Start it") Text("Start it")
} }
} }
@ -258,7 +259,7 @@ fun LibraryScreen(state: DesktopState) {
it.status != FileStatus.RUNNING it.status != FileStatus.RUNNING
} }
if (batch == null && untranscribed > 0) { if (batch == null && untranscribed > 0) {
OutlinedButton({ state.transcribeAll() }) { ShonarOutlinedButton({ state.transcribeAll() }) {
Text("Transcribe all ($untranscribed)") Text("Transcribe all ($untranscribed)")
} }
} }
@ -269,9 +270,9 @@ fun LibraryScreen(state: DesktopState) {
val running = entries.firstOrNull { val running = entries.firstOrNull {
it.status == FileStatus.RUNNING && it.file.name in batch.names it.status == FileStatus.RUNNING && it.file.name in batch.names
} }
Card(Modifier.fillMaxWidth()) { SonarCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), Column(Modifier.padding(Shonar.CardPadding),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
val fileFrac = running?.progress val fileFrac = running?.progress
// Overall % = settled files + current file's fraction. // Overall % = settled files + current file's fraction.
val overall = if (fileFrac != null) val overall = if (fileFrac != null)
@ -310,58 +311,59 @@ fun LibraryScreen(state: DesktopState) {
} }
} }
} }
OutlinedTextField(query, { state.setQuery(it) }, SonarField(query, { state.setQuery(it) },
label = { Text("Search saved transcripts") }, placeholder = "Search saved transcripts…",
singleLine = true, modifier = Modifier.fillMaxWidth()) singleLine = true, modifier = Modifier.fillMaxWidth(),
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null,
tint = Shonar.TextSecondary,
modifier = Modifier.size(Shonar.IconSm))
})
// Sort order (persisted): name A–Z, newest first, oldest first. // Sort order (persisted): name A–Z, newest first, oldest first.
// Date modes also surface the file date on each row below. // Date modes also surface the file date on each row below.
if (folder != null && query.isBlank()) { if (folder != null && query.isBlank()) {
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Text("Sort", style = MaterialTheme.typography.labelMedium, Text("Sort", style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
val order = state.sortOrder.collectAsState().value val order = state.sortOrder.collectAsState().value
listOf("name" to "Name", "new" to "Newest", listOf("name" to "Name", "new" to "Newest",
"old" to "Oldest").forEach { (value, label) -> "old" to "Oldest").forEach { (value, label) ->
FilterChip( PillChip(
selected = order == value, selected = order == value,
onClick = { state.setSortOrder(value) }, onClick = { state.setSortOrder(value) },
label = { Text(label) }, label = label,
) )
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(Shonar.Space3))
Text("Show", style = MaterialTheme.typography.labelMedium, Text("Show", style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
listOf("all" to "All", "new" to "Untranscribed") listOf("all" to "All", "new" to "Untranscribed")
.forEach { (value, label) -> .forEach { (value, label) ->
FilterChip( PillChip(
selected = statusFilter == value, selected = statusFilter == value,
onClick = { state.setLibraryFilter(value) }, onClick = { state.setLibraryFilter(value) },
label = { Text(label) }, label = label,
) )
} }
} }
} }
if (selected.isNotEmpty() && query.isBlank()) { if (selected.isNotEmpty() && query.isBlank()) {
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Text("${selected.size} selected", Text("${selected.size} selected",
style = MaterialTheme.typography.titleSmall) style = MaterialTheme.typography.titleSmall)
TextButton({ ShonarGhostButton({
state.selectAllOrClear(entries.map { it.file.absolutePath }) state.selectAllOrClear(entries.map { it.file.absolutePath })
}) { }) {
val allIn = entries.isNotEmpty() && val allIn = entries.isNotEmpty() &&
selected.containsAll(entries.map { it.file.absolutePath }) selected.containsAll(entries.map { it.file.absolutePath })
Text(if (allIn) "Clear all" else "Select all") Text(if (allIn) "Clear all" else "Select all")
} }
TextButton({ state.clearSelection() }) { Text("Clear") } ShonarGhostButton({ state.clearSelection() }) { Text("Clear") }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Button( ShonarDangerButton(
onClick = { deletingMany = true }, onClick = { deletingMany = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) { ) {
Text("Delete ${selected.size}") Text("Delete ${selected.size}")
} }
@ -372,17 +374,14 @@ fun LibraryScreen(state: DesktopState) {
Text("No matches.", style = MaterialTheme.typography.bodySmall) Text("No matches.", style = MaterialTheme.typography.bodySmall)
} else { } else {
LazyColumn(Modifier.fillMaxWidth().weight(1f), LazyColumn(Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
items(hits, key = { "${it.audioPath ?: "text"}:${it.report}:${it.line}" }) { h -> items(hits, key = { "${it.audioPath ?: "text"}:${it.report}:${it.line}" }) { h ->
val openable = h.audioPath?.let { File(it).exists() } == true val openable = h.audioPath?.let { File(it).exists() } == true
Card( SonarCard(
Modifier.fillMaxWidth().then( Modifier.fillMaxWidth(),
if (openable) Modifier.clickable { onClick = { if (openable) state.openDetail(File(h.audioPath)) },
state.openDetail(File(h.audioPath))
} else Modifier
),
) { ) {
Column(Modifier.padding(10.dp)) { Column(Modifier.padding(Shonar.CardPadding)) {
Text(h.report, style = MaterialTheme.typography.titleSmall) Text(h.report, style = MaterialTheme.typography.titleSmall)
Text( Text(
if (h.line == 0) h.snippet else "line ${h.line}: …${h.snippet}…", if (h.line == 0) h.snippet else "line ${h.line}: …${h.snippet}…",
@ -394,12 +393,12 @@ fun LibraryScreen(state: DesktopState) {
} }
} }
} else if (folder == null) { } else if (folder == null) {
Card(Modifier.fillMaxWidth()) { SonarCard(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text("No folder chosen — pick one in Settings.", Text("No folder chosen — pick one in Settings.",
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
Button({ state.go(DesktopState.Screen.SETTINGS) }) { ShonarButton({ state.go(DesktopState.Screen.SETTINGS) }) {
Text("Open Settings") Text("Open Settings")
} }
} }
@ -408,12 +407,16 @@ fun LibraryScreen(state: DesktopState) {
Text("No audio files in this folder.", Text("No audio files in this folder.",
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
} else { } else {
SectionHeader(
"Your recordings",
meta = "${entries.size} of ${allEntries.size}")
LazyColumn(Modifier.fillMaxWidth().weight(1f), LazyColumn(Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp)) { verticalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
items(entries, key = { it.file.absolutePath }) { e -> items(entries, key = { it.file.absolutePath }) { e ->
val isSelected = e.file.absolutePath in selected val isSelected = e.file.absolutePath in selected
Card(Modifier.fillMaxWidth().clickable { state.openDetail(e.file) }) { SonarCard(Modifier.fillMaxWidth(),
Row(Modifier.fillMaxWidth().padding(12.dp), onClick = { state.openDetail(e.file) }) {
Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Checkbox( Checkbox(
checked = isSelected, checked = isSelected,
@ -471,10 +474,10 @@ fun LibraryScreen(state: DesktopState) {
} }
} }
if (e.status == FileStatus.NEW || e.status == FileStatus.FAILED) { if (e.status == FileStatus.NEW || e.status == FileStatus.FAILED) {
TextButton({ state.pumpFile(e.file) }) { ShonarGhostButton({ state.pumpFile(e.file) }) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, Icon(Icons.Filled.PlayArrow, contentDescription = null,
modifier = Modifier.size(18.dp)) modifier = Modifier.size(Shonar.IconSm))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(Shonar.Space1))
Text("Transcribe") Text("Transcribe")
} }
} }
@ -482,10 +485,10 @@ fun LibraryScreen(state: DesktopState) {
// cancels them out of the pump. Only a file the // cancels them out of the pump. Only a file the
// engine is actually working is locked. // engine is actually working is locked.
if (e.status != FileStatus.RUNNING) { if (e.status != FileStatus.RUNNING) {
TextButton({ renaming = e.file }) { ShonarGhostButton({ renaming = e.file }) {
Icon(Icons.Filled.Edit, contentDescription = null, Icon(Icons.Filled.Edit, contentDescription = null,
modifier = Modifier.size(18.dp)) modifier = Modifier.size(Shonar.IconSm))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(Shonar.Space1))
Text("Rename") Text("Rename")
} }
} }
@ -506,26 +509,26 @@ fun LibraryScreen(state: DesktopState) {
title = { Text("Rename recording") }, title = { Text("Rename recording") },
text = { text = {
Column { Column {
OutlinedTextField( SonarField(
value = text, value = text,
onValueChange = { text = it; error = null }, onValueChange = { text = it; error = null },
label = { Text("New name") }, placeholder = "New name",
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
error?.let { error?.let {
Text(it, color = MaterialTheme.colorScheme.error, HelperText(it, error = true)
style = MaterialTheme.typography.bodySmall)
} }
} }
}, },
confirmButton = { confirmButton = {
TextButton({ ShonarGhostButton({
error = state.renameFile(target, text) error = state.renameFile(target, text)
if (error == null) renaming = null if (error == null) renaming = null
}) { Text("Rename") } }) { Text("Rename") }
}, },
dismissButton = { dismissButton = {
TextButton({ renaming = null }) { Text("Cancel") } ShonarGhostButton({ renaming = null }) { Text("Cancel") }
}, },
) )
} }