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))
}
/** Write the .odt export next to the audio (same base name as the
* .md report, .report.odt extension). Returns null on success or a
* human reason it failed. */
fun exportOdt(d: DetailUi): String? {
val out = odtFile(d.file)
return runCatching {
out.writeBytes(renderOdt(d.file.nameWithoutExtension, d.transcript, d.summary))
/** Export summary + transcript into [dir] in "md" or "odt" format.
* Returns null on success (caller shows the path) or a human
* reason it failed. Never touches the library's own report file. */
fun exportTo(d: DetailUi, dir: File, format: String): String? {
if (!dir.isDirectory) return "That folder no longer exists."
val base = d.file.nameWithoutExtension
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(
onSuccess = { null },
onFailure = { "Couldn't save ${out.name}: ${it.message}" },
)
}
}
else -> "Unknown export format."
}
}
companion object {
/** 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 =
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.padding
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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@ -155,41 +152,39 @@ fun main() = application {
val summaryToast by state.summaryToast.collectAsState()
Column(Modifier.fillMaxSize()) {
summaryToast?.let { t ->
Card(
SonarCard(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Row(Modifier.fillMaxWidth()
.padding(horizontal = 12.dp),
.padding(horizontal = Shonar.CardPadding),
verticalAlignment = Alignment.CenterVertically) {
Text(t.label,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f))
TextButton({ state.viewSummaryReady(t) }) {
ShonarGhostButton({ state.viewSummaryReady(t) }) {
Text("View")
}
TextButton({ state.dismissSummaryToast() }) {
ShonarGhostButton({ state.dismissSummaryToast() }) {
Text("✕")
}
}
}
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(Shonar.Space2))
}
undoToast?.let { t ->
Card(Modifier.fillMaxWidth()) {
SonarCard(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth()
.padding(horizontal = 12.dp),
.padding(horizontal = Shonar.CardPadding),
verticalAlignment = Alignment.CenterVertically) {
Text(t.label,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f))
TextButton({ state.undoLastTrash() }) {
ShonarGhostButton({ state.undoLastTrash() }) {
Text("Undo")
}
}
}
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(Shonar.Space2))
}
when (screen) {
DesktopState.Screen.ENGINE -> EngineScreen(state)

View file

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

View file

@ -138,11 +138,18 @@ class ReportsTest {
assertTrue("No transcript available." in xml)
}
@Test fun odtFileName() {
val dir = Files.createTempDirectory("odt").toFile()
@Test fun odtExportFilenamesThroughExportTo() {
// 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 {
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 {
dir.deleteRecursively()
}