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:
parent
a3d16a6c18
commit
b5113f4abc
4 changed files with 248 additions and 144 deletions
|
|
@ -1744,17 +1744,40 @@ 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))
|
||||
}.fold(
|
||||
onSuccess = { null },
|
||||
onFailure = { "Couldn't save ${out.name}: ${it.message}" },
|
||||
)
|
||||
/** 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 {
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue