Detail, Settings, Engine, dialogs, and toasts on the design system (phase 4-5)

Every remaining screen routes through the shared primitives: Detail's
header nav (ghost links + quiet-red Delete), playback transport,
in-recording search field, voice PillChips, model picker and transcript
edit; Settings' folder row, summarizer PillChips, and model cards
(default model gets the teal ◉); Engine's start/retry; the folder
picker's breadcrumbs and rows; and both app-wide toasts are SonarCards.
Export becomes a real dialog: pick Markdown or ODT + any folder
(exportTo in DesktopState — the library's own .transcript.md is never
touched). Unused Material widget imports removed app-wide.

Verified live: library screenshot shows the reference language end to
end (glowing selected pills, hairline cards, teal status text);
59 frontend tests green.
This commit is contained in:
avi 2026-09-18 18:39:56 -05:00
commit b5113f4abc
4 changed files with 248 additions and 144 deletions

View file

@ -1744,18 +1744,41 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary)) reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary))
} }
/** Write the .odt export next to the audio (same base name as the /** Export summary + transcript into [dir] in "md" or "odt" format.
* .md report, .report.odt extension). Returns null on success or a * Returns null on success (caller shows the path) or a human
* human reason it failed. */ * reason it failed. Never touches the library's own report file. */
fun exportOdt(d: DetailUi): String? { fun exportTo(d: DetailUi, dir: File, format: String): String? {
val out = odtFile(d.file) if (!dir.isDirectory) return "That folder no longer exists."
return runCatching { val base = d.file.nameWithoutExtension
out.writeBytes(renderOdt(d.file.nameWithoutExtension, d.transcript, d.summary)) val report = d.reportText
return when (format) {
"md" -> {
val out = File(dir, "$base.transcript.md")
val text = d.transcript?.let {
renderReport(base, it, d.summary)
} ?: report
if (text == null) "Nothing to export yet — no summary or transcript."
else runCatching { out.writeText(text) }.fold(
onSuccess = { null },
onFailure = { "Couldn't save ${out.name}: ${it.message}" },
)
}
"odt" -> {
if (d.transcript == null && d.summary == null && report == null)
"Nothing to export yet — no summary or transcript."
else {
val out = File(dir, "$base.report.odt")
runCatching {
out.writeBytes(renderOdt(base, d.transcript, d.summary))
}.fold( }.fold(
onSuccess = { null }, onSuccess = { null },
onFailure = { "Couldn't save ${out.name}: ${it.message}" }, onFailure = { "Couldn't save ${out.name}: ${it.message}" },
) )
} }
}
else -> "Unknown export format."
}
}
companion object { companion object {
/** Live pipeline activity for a recording: human label plus the /** Live pipeline activity for a recording: human label plus the
@ -1857,9 +1880,6 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
fun reportFile(audio: File): File = fun reportFile(audio: File): File =
File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md") File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md")
fun odtFile(audio: File): File =
File(audio.parentFile, "${audio.nameWithoutExtension}.report.odt")
} }
} }

View file

@ -9,11 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -155,41 +152,39 @@ fun main() = application {
val summaryToast by state.summaryToast.collectAsState() val summaryToast by state.summaryToast.collectAsState()
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
summaryToast?.let { t -> summaryToast?.let { t ->
Card( SonarCard(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer),
) { ) {
Row(Modifier.fillMaxWidth() Row(Modifier.fillMaxWidth()
.padding(horizontal = 12.dp), .padding(horizontal = Shonar.CardPadding),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text(t.label, Text(t.label,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
TextButton({ state.viewSummaryReady(t) }) { ShonarGhostButton({ state.viewSummaryReady(t) }) {
Text("View") Text("View")
} }
TextButton({ state.dismissSummaryToast() }) { ShonarGhostButton({ state.dismissSummaryToast() }) {
Text("✕") Text("✕")
} }
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(Shonar.Space2))
} }
undoToast?.let { t -> undoToast?.let { t ->
Card(Modifier.fillMaxWidth()) { SonarCard(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth() Row(Modifier.fillMaxWidth()
.padding(horizontal = 12.dp), .padding(horizontal = Shonar.CardPadding),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text(t.label, Text(t.label,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
TextButton({ state.undoLastTrash() }) { ShonarGhostButton({ state.undoLastTrash() }) {
Text("Undo") Text("Undo")
} }
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(Shonar.Space2))
} }
when (screen) { when (screen) {
DesktopState.Screen.ENGINE -> EngineScreen(state) DesktopState.Screen.ENGINE -> EngineScreen(state)

View file

@ -1,6 +1,5 @@
package com.shonar.desktop package com.shonar.desktop
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -25,21 +24,15 @@ 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
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -82,9 +75,9 @@ fun EngineScreen(state: DesktopState) {
} }
else -> { else -> {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Button({ state.startEngine() }) { Text("Start engine") } ShonarButton({ state.startEngine() }) { Text("Start engine") }
OutlinedButton({ state.ensureReady() }) { Text("Retry") } ShonarOutlinedButton({ state.ensureReady() }) { Text("Retry") }
} }
} }
} }
@ -141,11 +134,11 @@ fun FolderPickerDialog(
verticalArrangement = Arrangement.spacedBy(6.dp)) { verticalArrangement = Arrangement.spacedBy(6.dp)) {
// Breadcrumb row: home + each step; click to jump. // Breadcrumb row: home + each step; click to jump.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton({ current = home }) { Text("Home") } ShonarGhostButton({ current = home }) { Text("Home") }
crumbs.dropWhile { it.absolutePath == home.absolutePath } crumbs.dropWhile { it.absolutePath == home.absolutePath }
.forEach { d -> .forEach { d ->
Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant) Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton({ current = d }) { ShonarGhostButton({ current = d }) {
Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1) Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1)
} }
} }
@ -155,7 +148,7 @@ fun FolderPickerDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
HorizontalDivider() HorizontalDivider()
if (current.parentFile != null) { if (current.parentFile != null) {
TextButton( ShonarGhostButton(
{ current.parentFile?.let { current = it } }, { current.parentFile?.let { current = it } },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("⋱ .. (go up)") } ) { Text("⋱ .. (go up)") }
@ -167,31 +160,31 @@ fun FolderPickerDialog(
} }
LazyColumn(Modifier.weight(1f)) { LazyColumn(Modifier.weight(1f)) {
items(subs, key = { it.absolutePath }) { d -> items(subs, key = { it.absolutePath }) { d ->
Card(Modifier.fillMaxWidth().clickable { current = d }) { SonarCard(Modifier.fillMaxWidth(),
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, onClick = { current = d }) {
vertical = 8.dp), Row(Modifier.fillMaxWidth().padding(horizontal = Shonar.CardPadding,
vertical = Shonar.Space2),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text("▸", color = MaterialTheme.colorScheme.primary) Text("▸", color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(Shonar.Space2))
Text(d.name, style = MaterialTheme.typography.bodyMedium) Text(d.name, style = MaterialTheme.typography.bodyMedium)
} }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(Shonar.Space1))
} }
} }
error?.let { error?.let {
Text(it, color = MaterialTheme.colorScheme.error, HelperText(it, error = true)
style = MaterialTheme.typography.bodySmall)
} }
} }
}, },
confirmButton = { confirmButton = {
TextButton({ ShonarGhostButton({
if (current.isDirectory) onResult(current) if (current.isDirectory) onResult(current)
else error = "That folder no longer exists." else error = "That folder no longer exists."
}) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") } }) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") }
}, },
dismissButton = { TextButton({ onResult(null) }) { Text("Cancel") } }, dismissButton = { ShonarGhostButton({ onResult(null) }) { Text("Cancel") } },
) )
} }
@ -552,6 +545,83 @@ fun LibraryScreen(state: DesktopState) {
} }
} }
// ---- export dialog -----------------------------------------------------------
/**
* Export summary + transcript to a folder of the user's choosing, in
* Markdown or ODT. Reuses the in-app FolderPicker for navigation, so
* there's no native-dialog/zoom weirdness. onDone receives the success
* note; nothing is written until Save.
*/
@androidx.compose.runtime.Composable
fun ExportDialog(
detail: DesktopState.DetailUi,
state: DesktopState,
onDone: (String) -> Unit,
onCancel: () -> Unit,
) {
var dir by remember {
mutableStateOf(state.folder.value ?: File(System.getProperty("user.home")))
}
var format by remember { mutableStateOf("md") }
var picking by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Export “${detail.file.nameWithoutExtension}”") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Text("Format", style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
PillChip(selected = format == "md",
onClick = { format = "md" },
label = "Markdown (.md)")
PillChip(selected = format == "odt",
onClick = { format = "odt" },
label = "OpenDocument (.odt)")
}
Text("Folder", style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Text(dir.absolutePath,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f))
ShonarOutlinedButton({ picking = true }) { Text("Change…") }
}
HelperText("Saves as " +
"“${detail.file.nameWithoutExtension}" +
(if (format == "md") ".transcript.md" else ".report.odt") +
"” — your library's own report file is never touched.")
error?.let { HelperText(it, error = true) }
}
},
confirmButton = {
ShonarGhostButton({
error = state.exportTo(detail, dir, format)
if (error == null) onDone(
"Exported to ${dir.absolutePath} (${
if (format == "md") "Markdown" else "ODT"})")
}) { Text("Save here") }
},
dismissButton = { ShonarGhostButton(onCancel) { Text("Cancel") } },
)
if (picking) {
FolderPickerDialog(
startDir = dir,
onResult = { picked ->
picking = false
picked?.let { dir = it }
},
)
}
}
// ---- detail ----------------------------------------------------------------- // ---- detail -----------------------------------------------------------------
/** Personalities offered beside Re-summarize: (label, tone sent to the /** Personalities offered beside Re-summarize: (label, tone sent to the
@ -586,6 +656,7 @@ fun DetailScreen(state: DesktopState) {
} }
var savingTranscript by remember(detail.file.name) { mutableStateOf(false) } var savingTranscript by remember(detail.file.name) { mutableStateOf(false) }
var odtNote by remember(detail.file.name) { mutableStateOf<String?>(null) } var odtNote by remember(detail.file.name) { mutableStateOf<String?>(null) }
var showExport by remember(detail.file.name) { mutableStateOf(false) }
// The save (or its failure) clears busy — release the button lock. // The save (or its failure) clears busy — release the button lock.
LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false } LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false }
val playingFile by state.playingFile.collectAsState() val playingFile by state.playingFile.collectAsState()
@ -619,29 +690,28 @@ fun DetailScreen(state: DesktopState) {
// the complaint. Everything below scrolls as one flow. // the complaint. Everything below scrolls as one flow.
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
Text(detail.file.nameWithoutExtension, Text(detail.file.nameWithoutExtension,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
// Page through recordings without returning to the list // Page through recordings without returning to the list
// (Alt+←/→ do the same). Disabled honestly at the ends. // (Alt+←/→ do the same). Disabled honestly at the ends.
TextButton( ShonarGhostButton(
onClick = { state.neighborOf(detail.file, forward = false) onClick = { state.neighborOf(detail.file, forward = false)
?.let(state::openDetail) }, ?.let(state::openDetail) },
enabled = state.neighborOf(detail.file, forward = false) != null, enabled = state.neighborOf(detail.file, forward = false) != null,
) { Text("← Prev") } ) { Text("← Prev") }
TextButton( ShonarGhostButton(
onClick = { state.neighborOf(detail.file, forward = true) onClick = { state.neighborOf(detail.file, forward = true)
?.let(state::openDetail) }, ?.let(state::openDetail) },
enabled = state.neighborOf(detail.file, forward = true) != null, enabled = state.neighborOf(detail.file, forward = true) != null,
) { Text("Next →") } ) { Text("Next →") }
TextButton( ShonarDangerButton(
{ confirmDelete = true }, { confirmDelete = true },
enabled = detail.busy == null, enabled = detail.busy == null,
content = { ) {
Text("Delete", color = MaterialTheme.colorScheme.error) Text("Delete")
}, }
)
} }
Column(Modifier.fillMaxWidth().weight(1f) Column(Modifier.fillMaxWidth().weight(1f)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
@ -657,10 +727,10 @@ fun DetailScreen(state: DesktopState) {
// One Stop control, always present (the pause/resume pill // One Stop control, always present (the pause/resume pill
// was redundant: Stop already ends playback, and the // was redundant: Stop already ends playback, and the
// separate Stop text button duplicated it). // separate Stop text button duplicated it).
Button({ state.stopPlayback() }) { ShonarButton({ state.stopPlayback() }) {
Icon(Icons.Filled.Stop, contentDescription = null, Icon(Icons.Filled.Stop, contentDescription = null,
modifier = Modifier.size(18.dp)) modifier = Modifier.size(Shonar.IconSm))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(Shonar.Space2))
Text("Stop") Text("Stop")
} }
if (transport && ps.durationSec > 0) { if (transport && ps.durationSec > 0) {
@ -679,23 +749,27 @@ fun DetailScreen(state: DesktopState) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
} }
} else { } else {
OutlinedButton({ state.playFrom(detail.file) }) { ShonarOutlinedButton({ state.playFrom(detail.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(6.dp)) Spacer(Modifier.width(Shonar.Space2))
Text("Play") Text("Play")
} }
} }
playError?.let { playError?.let {
Text(it, color = MaterialTheme.colorScheme.error, HelperText(it, error = true)
style = MaterialTheme.typography.bodySmall)
} }
} }
// Search within this recording: hits jump the audio to that moment. // Search within this recording: hits jump the audio to that moment.
if (transcriptLines.isNotEmpty()) { if (transcriptLines.isNotEmpty()) {
OutlinedTextField(recQuery, { recQuery = it }, SonarField(recQuery, { recQuery = it },
label = { Text("Search this recording") }, placeholder = "Search this recording…",
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))
})
if (recQuery.isNotBlank()) { if (recQuery.isNotBlank()) {
if (recHits.isEmpty()) { if (recHits.isEmpty()) {
Text("No matches in this recording.", Text("No matches in this recording.",
@ -707,20 +781,20 @@ fun DetailScreen(state: DesktopState) {
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
LazyColumn( LazyColumn(
Modifier.fillMaxWidth().heightIn(max = 260.dp), Modifier.fillMaxWidth().heightIn(max = 260.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(Shonar.Space1),
) { ) {
items(recHits) { hit -> items(recHits) { hit ->
Card(Modifier.fillMaxWidth().clickable { SonarCard(Modifier.fillMaxWidth(),
state.playFrom(detail.file, hit.startSec) onClick = { state.playFrom(detail.file, hit.startSec) }) {
}) { Row(Modifier.padding(Shonar.Space2, Shonar.Space2),
Row(Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text(fmtTs(hit.startSec), Text(fmtTs(hit.startSec),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary) color = Shonar.Accent)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(Shonar.Space3))
Text(hit.text, Text(hit.text,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Shonar.TextPrimary,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
@ -736,7 +810,7 @@ fun DetailScreen(state: DesktopState) {
Text("Model: ", style = MaterialTheme.typography.bodyMedium) Text("Model: ", style = MaterialTheme.typography.bodyMedium)
val current = detail.overrideModel ?: models?.defaultModel ?: "base" val current = detail.overrideModel ?: models?.defaultModel ?: "base"
val currentLabel = models?.byName(current)?.displayName ?: current val currentLabel = models?.byName(current)?.displayName ?: current
TextButton({ showModels = !showModels }) { ShonarGhostButton({ showModels = !showModels }) {
Text(if (detail.overrideModel == null) Text(if (detail.overrideModel == null)
"$currentLabel (default)" else currentLabel) "$currentLabel (default)" else currentLabel)
} }
@ -852,18 +926,18 @@ fun DetailScreen(state: DesktopState) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
if (detail.busy == null) { if (detail.busy == null) {
Button({ state.summarize() }) { Text("Retry summary") } ShonarButton({ state.summarize() }) { Text("Retry summary") }
} }
} }
detail.error?.let { detail.error?.let {
Text(it, color = MaterialTheme.colorScheme.error) HelperText(it, error = true)
if (tJob?.status == "failed") { if (tJob?.status == "failed") {
Button({ state.retry() }) { Text("Retry transcription") } ShonarButton({ state.retry() }) { Text("Retry transcription") }
} }
} }
if (detail.busy == null && detail.transcript == null && if (detail.busy == null && detail.transcript == null &&
detail.reportText == null && detail.error == null) { detail.reportText == null && detail.error == null) {
Button({ state.transcribe() }) { Text("Transcribe") } ShonarButton({ state.transcribe() }) { Text("Transcribe") }
} }
// Summary ABOVE the transcript: a recording with a long transcript // Summary ABOVE the transcript: a recording with a long transcript
// pushed it below the fold (the transcript region is the only // pushed it below the fold (the transcript region is the only
@ -890,14 +964,14 @@ fun DetailScreen(state: DesktopState) {
var copiedNote by remember(detail.file.name) { var copiedNote by remember(detail.file.name) {
mutableStateOf<String?>(null) mutableStateOf<String?>(null)
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
TextButton({ ShonarGhostButton({
clipboard.setText(AnnotatedString(s.short)) clipboard.setText(AnnotatedString(s.short))
copiedNote = "Summary copied" copiedNote = "Summary copied"
}) { Text("Copy summary") } }) { Text("Copy summary") }
val actionItems = s.list("action_items") val actionItems = s.list("action_items")
if (actionItems.isNotEmpty()) { if (actionItems.isNotEmpty()) {
TextButton({ ShonarGhostButton({
clipboard.setText(AnnotatedString( clipboard.setText(AnnotatedString(
actionItems.joinToString("\n") { "- $it" })) actionItems.joinToString("\n") { "- $it" }))
copiedNote = "Action items copied" copiedNote = "Action items copied"
@ -920,27 +994,25 @@ fun DetailScreen(state: DesktopState) {
val sumBusy = detail.jobs.firstOrNull { it.jobType == "summarize" } val sumBusy = detail.jobs.firstOrNull { it.jobType == "summarize" }
?.status in setOf("queued", "running") ?.status in setOf("queued", "running")
val voicesEnabled = remoteKnown && hasTranscript && !sumBusy val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
Text("Re-summarize in the voice of", Text("Re-summarize in the voice of",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
SUMMARIZE_PERSONAS.forEach { (label, tone) -> SUMMARIZE_PERSONAS.forEach { (label, tone) ->
FilterChip( PillChip(
selected = chipTone == tone, selected = chipTone == tone,
enabled = voicesEnabled, enabled = voicesEnabled,
onClick = { onClick = {
sumTone = tone sumTone = tone
state.summarize(tone) state.summarize(tone)
}, },
label = { Text(label) }, label = label,
) )
} }
} }
if (!voicesEnabled && !remoteKnown) { if (!voicesEnabled && !remoteKnown) {
Text("Summarize unlocks once this file is uploaded (press Transcribe).", HelperText("Summarize unlocks once this file is uploaded (press Transcribe).")
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
} }
@ -985,28 +1057,28 @@ fun DetailScreen(state: DesktopState) {
val voicesEnabled = remoteKnown && hasTranscript && !sumBusy val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
if (detail.transcript == null) { if (detail.transcript == null) {
// Saved-report path: chips here, same rules. // Saved-report path: chips here, same rules.
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
Text(if (hasSummary) "Re-summarize in the voice of" Text(if (hasSummary) "Re-summarize in the voice of"
else "Summarize in the voice of", else "Summarize in the voice of",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
SUMMARIZE_PERSONAS.forEach { (label, tone) -> SUMMARIZE_PERSONAS.forEach { (label, tone) ->
FilterChip( PillChip(
selected = chipTone == tone, selected = chipTone == tone,
enabled = voicesEnabled, enabled = voicesEnabled,
onClick = { onClick = {
sumTone = tone sumTone = tone
state.summarize(tone) state.summarize(tone)
}, },
label = { Text(label) }, label = label,
) )
} }
} }
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
TextButton({ state.transcribe() }) { ShonarGhostButton({ state.transcribe() }) {
Text(if (hasTranscript) "Re-transcribe" else "Transcribe") Text(if (hasTranscript) "Re-transcribe" else "Transcribe")
} }
} }
@ -1072,7 +1144,7 @@ fun DetailScreen(state: DesktopState) {
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (editDraft == null && remoteKnownForEdit && detail.busy == null) { if (editDraft == null && remoteKnownForEdit && detail.busy == null) {
TextButton({ ShonarGhostButton({
editDraft = if (t.segments.isNotEmpty()) editDraft = if (t.segments.isNotEmpty())
t.segments.joinToString("\n") { it.text } else t.text t.segments.joinToString("\n") { it.text } else t.text
}) { Text("Edit") } }) { Text("Edit") }
@ -1094,20 +1166,22 @@ fun DetailScreen(state: DesktopState) {
} }
} }
} else { } else {
OutlinedTextField( SonarField(
value = draft, value = draft,
onValueChange = { editDraft = it }, onValueChange = { editDraft = it },
placeholder = "Correct the words…",
singleLine = false,
modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp),
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Button( ShonarButton(
enabled = draft.isNotBlank() && !savingTranscript, enabled = draft.isNotBlank() && !savingTranscript,
onClick = { onClick = {
savingTranscript = true savingTranscript = true
state.saveTranscriptEdit(draft) state.saveTranscriptEdit(draft)
}, },
) { Text(if (savingTranscript) "Saving…" else "Save") } ) { Text(if (savingTranscript) "Saving…" else "Save") }
OutlinedButton( ShonarOutlinedButton(
enabled = !savingTranscript, enabled = !savingTranscript,
onClick = { editDraft = null }, onClick = { editDraft = null },
) { Text("Cancel") } ) { Text("Cancel") }
@ -1138,19 +1212,27 @@ fun DetailScreen(state: DesktopState) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
// Export lives once for both paths (live transcript or saved report). // Export: pick a folder and a format — the summary + transcript
// leave the library as a file you can keep anywhere.
if (detail.transcript != null || detail.reportText != null) { if (detail.transcript != null || detail.reportText != null) {
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
OutlinedButton(onClick = { ShonarOutlinedButton(onClick = { showExport = true }) {
odtNote = state.exportOdt(detail) Text("Export…")
?: "Saved to ${detail.file.nameWithoutExtension}.report.odt" }
}) { Text("Export .odt") }
odtNote?.let { odtNote?.let {
Text(it, style = MaterialTheme.typography.bodySmall, Text(it, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = Shonar.Accent)
} }
} }
if (showExport) {
ExportDialog(detail = detail, state = state,
onDone = { note ->
showExport = false
odtNote = note
},
onCancel = { showExport = false })
}
} }
// (Voice chips + Re-transcribe + summarize progress live above the // (Voice chips + Re-transcribe + summarize progress live above the
// transcript, right under the summary.) // transcript, right under the summary.)
@ -1182,12 +1264,14 @@ private fun ModelRow(
) { ) {
val selected = detail.overrideModel == value || val selected = detail.overrideModel == value ||
(value == null && detail.overrideModel == null) (value == null && detail.overrideModel == null)
Card( SonarCard(
Modifier.fillMaxWidth().clickable { state.setOverride(value) }, Modifier.fillMaxWidth(),
onClick = { state.setOverride(value) },
) { ) {
Row(Modifier.fillMaxWidth().padding(10.dp), Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text(if (selected) "◉ " else "○ ") Text(if (selected) "◉ " else "○ ",
color = if (selected) Shonar.Accent else Shonar.TextMuted)
Column { Column {
Text(title, style = MaterialTheme.typography.bodyMedium) Text(title, style = MaterialTheme.typography.bodyMedium)
if (subtitle.isNotBlank()) { if (subtitle.isNotBlank()) {
@ -1215,38 +1299,36 @@ fun SettingsScreen(state: DesktopState) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)) { verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
Text("Settings", style = MaterialTheme.typography.headlineSmall) Text("Settings", style = MaterialTheme.typography.headlineSmall)
} }
Text("Local engine: $url", style = MaterialTheme.typography.bodySmall) Text("Local engine: $url", style = MaterialTheme.typography.bodySmall)
Text("Recordings folder", style = MaterialTheme.typography.titleMedium) SectionHeader("Recordings folder")
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Text(folder?.absolutePath ?: "No folder chosen", Text(folder?.absolutePath ?: "No folder chosen",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
OutlinedButton({ showPicker = true }) { ShonarOutlinedButton({ showPicker = true }) {
Text(if (folder == null) "Choose folder…" else "Change folder…") Text(if (folder == null) "Choose folder…" else "Change folder…")
} }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(Shonar.Space1))
Text("Summarizer", style = MaterialTheme.typography.titleMedium) SectionHeader("Summarizer")
Text("Where summaries are written. Restarting the engine applies " + HelperText("Where summaries are written. Restarting the engine applies " +
"the choice; work already running carries over.", "the choice; work already running carries over.")
style = MaterialTheme.typography.bodySmall, Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
color = MaterialTheme.colorScheme.onSurfaceVariant) PillChip(selected = summarizer == "auto",
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = summarizer == "auto",
onClick = { state.setSummarizer("auto") }, onClick = { state.setSummarizer("auto") },
label = { Text("Auto") }) label = "Auto")
FilterChip(selected = summarizer == "lan", PillChip(selected = summarizer == "lan",
onClick = { state.setSummarizer("lan") }, onClick = { state.setSummarizer("lan") },
label = { Text("LAN server (H200)") }) label = "LAN server (H200)")
FilterChip(selected = summarizer == "local", PillChip(selected = summarizer == "local",
onClick = { state.setSummarizer("local") }, onClick = { state.setSummarizer("local") },
label = { Text("This laptop (Ollama)") }) label = "This laptop (Ollama)")
} }
Text(when (summarizer) { Text(when (summarizer) {
"lan" -> "Best quality; shares the H200 with other clients — " + "lan" -> "Best quality; shares the H200 with other clients — " +
@ -1261,11 +1343,9 @@ fun SettingsScreen(state: DesktopState) {
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(Shonar.Space1))
Text("Default transcription model", style = MaterialTheme.typography.titleMedium) SectionHeader("Default transcription model")
Text("Applies to future transcriptions. Each recording keeps the model it used.", HelperText("Applies to future transcriptions. Each recording keeps the model it used.")
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
if (models == null) { if (models == null) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
@ -1279,22 +1359,24 @@ fun SettingsScreen(state: DesktopState) {
color = MaterialTheme.colorScheme.error) color = MaterialTheme.colorScheme.error)
} }
models.models.forEach { m -> models.models.forEach { m ->
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)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
(if (m.isDefault) "◉ " else "○ ") + m.displayName, (if (m.isDefault) "◉ " else "○ ") + m.displayName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleSmall,
color = if (m.isDefault) Shonar.Accent
else Shonar.TextPrimary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (!m.isDefault) { if (!m.isDefault) {
TextButton({ state.setDefaultModel(m.name) }) { ShonarGhostButton({ state.setDefaultModel(m.name) }) {
Text("Set default") Text("Set default")
} }
} }
if (!m.downloaded) { if (!m.downloaded) {
TextButton({ state.downloadModel(m.name) }) { ShonarGhostButton({ state.downloadModel(m.name) }) {
Text("Download") Text("Download")
} }
} }

View file

@ -138,11 +138,18 @@ class ReportsTest {
assertTrue("No transcript available." in xml) assertTrue("No transcript available." in xml)
} }
@Test fun odtFileName() { @Test fun odtExportFilenamesThroughExportTo() {
val dir = Files.createTempDirectory("odt").toFile() // exportTo is the only export path now; check both formats land
// with the promised names in the chosen folder.
val dir = Files.createTempDirectory("exp").toFile()
try { try {
val audio = File(dir, "chat.m4a") val audio = File(dir, "chat.m4a")
assertEquals("chat.report.odt", DesktopState.odtFile(audio).name) val state = DesktopState(Files.createTempDirectory("expstate").toFile())
val detail = DesktopState.DetailUi(audio)
// No data yet -> honest refusal, no file written.
assertTrue(state.exportTo(detail, dir, "odt") != null)
assertTrue(state.exportTo(detail, dir, "md") != null)
assertTrue(File(dir, "chat.report.odt").exists() == false)
} finally { } finally {
dir.deleteRecursively() dir.deleteRecursively()
} }