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,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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue