diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index a3af382..f39abf2 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -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") } } diff --git a/app/src/main/kotlin/com/shonar/desktop/Main.kt b/app/src/main/kotlin/com/shonar/desktop/Main.kt index 3f64b12..7860e5a 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Main.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Main.kt @@ -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) diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index a916033..4886d4c 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -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(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(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(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") } } diff --git a/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt b/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt index d624331..2b387b8 100644 --- a/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt @@ -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() }