diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index 53dccbf..f9c75c7 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -170,6 +170,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { prefs.getString(KEY_SORT)?.takeIf { it in SORT_ORDERS } ?.let { _sortOrder.value = it } _summarizer.value = prefs.getString(KEY_SUMMARIZER) ?: "auto" + loadNotifyState() prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } } ?.let { setFolder(it, silent = true) } ensureReady() @@ -641,6 +642,76 @@ class DesktopState(private val appDir: File = defaultAppDir()) { private val _liveProgress = MutableStateFlow>(emptyMap()) val liveProgress = _liveProgress.asStateFlow() + // ---- summary-ready notifications ------------------------------------------ + // One-shot toast ("Summary ready — ", auto-dismiss) plus a per-file + // "new summary" badge that clears when the recording is opened. Announce + // happens exactly once per (file, summary version): the toast fires on the + // completion path wherever it runs (pump, reprocess, adoption, or opening + // the detail screen on a finished job), and the unseen set persists in + // settings so a finish right before quitting still shows the badge later. + data class SummaryToast(val label: String, val file: File) + private val _summaryToast = MutableStateFlow(null) + val summaryToast = _summaryToast.asStateFlow() + private val _unseenSummaries = MutableStateFlow>(emptySet()) + val unseenSummaries = _unseenSummaries.asStateFlow() + private val announcedSummaries = mutableSetOf() + private var summaryToastJob: Job? = null + + private suspend fun loadNotifyState() { + _unseenSummaries.value = + SummaryNotify.decode(prefs.getString(KEY_UNSEEN_SUMMARIES)) + announcedSummaries.addAll( + (prefs.getString(KEY_ANNOUNCED_SUMMARIES) ?: "") + .split("\n").filter { it.isNotBlank() }) + } + + /** Call when a summarize job for [file] reached `succeeded` with [version]. + * The badge is skipped when the detail screen is open on this very file + * — the user is watching it finish; only the toast fires. */ + private fun announceSummaryReady(file: File, version: Int) { + val key = SummaryNotify.seenKey(file.name, version) + if (!announcedSummaries.add(key)) return + scope.launch { + prefs.putString(KEY_ANNOUNCED_SUMMARIES, + SummaryNotify.trimSeen(announcedSummaries.toList()).joinToString("\n")) + } + // Suppress the badge only while the user is actually looking at this + // recording (the toast still fires). _detail survives navigating + // away, so the screen must be checked too. + if (!(_screen.value == Screen.DETAIL && _detail.value?.file == file)) { + _unseenSummaries.value = _unseenSummaries.value + file.name + scope.launch { persistUnseen() } + } + _summaryToast.value = SummaryToast("Summary ready — ${file.nameWithoutExtension}", file) + summaryToastJob?.cancel() + summaryToastJob = scope.launch { + delay(12_000) + if (_summaryToast.value?.label == + "Summary ready — ${file.nameWithoutExtension}") + _summaryToast.value = null + } + } + + /** Toast "View": open the finished recording (clears its badge). */ + fun viewSummaryReady(toast: SummaryToast) { + _summaryToast.value = null + if (toast.file.isFile) openDetail(toast.file) + } + + fun dismissSummaryToast() { _summaryToast.value = null } + + /** Called from openDetail: the badge for this file is now acknowledged. */ + private fun clearUnseenSummary(file: File) { + if (file.name !in _unseenSummaries.value) return + _unseenSummaries.value = _unseenSummaries.value - file.name + scope.launch { persistUnseen() } + } + + private suspend fun persistUnseen() { + prefs.putString(KEY_UNSEEN_SUMMARIES, + SummaryNotify.encode(_unseenSummaries.value)) + } + private fun setLive(name: String, live: LiveProgress?) { _liveProgress.value = _liveProgress.value.toMutableMap().apply { if (live == null) remove(name) else put(name, live) @@ -730,6 +801,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { .getOrNull() ?: return false val summary = runCatching { parseSummary(provider.fetchSummary(refKey)) }.getOrNull() saveReport(DetailUi(file = f, transcript = transcript, summary = summary)) + summary?.let { announceSummaryReady(f, it.version) } true } catch (e: Exception) { setLive(f.name, null) @@ -804,6 +876,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (transcript != null || summary != null) { saveReport(DetailUi(file = f, transcript = transcript, summary = summary)) } + summary?.let { announceSummaryReady(f, it.version) } rescanStatuses() } @@ -1258,6 +1331,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { fun openDetail(file: File) { pollJob?.cancel() + clearUnseenSummary(file) // Show the saved report immediately — otherwise opening a finished // file looks empty and the summary appears unreachable. val saved = runCatching { reportFile(file).readText() }.getOrNull() @@ -1585,6 +1659,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { .getOrNull() d0.busy = null _detail.value = d0.copy() + d0.summary?.let { announceSummaryReady(d0.file, it.version) } if (d0.transcript != null || d0.summary != null) { saveReport(d0) saveMapping(d0.file, RemoteMapping(remoteId, model = d0.overrideModel, @@ -1741,6 +1816,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } /** Summarizer choice: "auto" | "lan" | "local" (see setSummarizer). */ const val KEY_SUMMARIZER = "llm.summarizer" + const val KEY_UNSEEN_SUMMARIES = "notify.unseen_summaries" + const val KEY_ANNOUNCED_SUMMARIES = "notify.announced_summaries" /** Preferred local summarization model (Ollama). */ const val DEFAULT_LLM_MODEL = "qwen3:4b" /** LAN H200 inference server (private 10.x network, never internet). */ diff --git a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt index 77a52ec..3e0e508 100644 --- a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt +++ b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt @@ -1,7 +1,40 @@ package com.shonar.desktop +import org.json.JSONArray import java.io.File +/** + * Pure summary-completion notification logic (unit-tested; no coroutines). + * + * Two signals for a finished summary: a one-shot toast (fires where the job + * completed, wherever that is in the app) and a persistent per-file badge + * ("new summary") that stays until the user opens that recording. The unseen + * set persists across restarts so a summary finished right before closing + * the app still greets you. Dedupe is per (file, summary version) so a + * re-summarize re-announces but repeated polling of the same v does not. + */ +object SummaryNotify { + /** unseen set <-> settings.json string (JSON array of file names). */ + fun encode(names: Set): String { + val arr = JSONArray() + names.sorted().forEach { arr.put(it) } + return arr.toString() + } + + fun decode(raw: String?): Set = runCatching { + val arr = JSONArray(raw ?: "[]") + (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } } + .toSet() + }.getOrDefault(emptySet()) + + /** dedupe key so one summary version announces exactly once. */ + fun seenKey(fileName: String, version: Int): String = "$fileName#$version" + + /** keep the persisted "already announced" list bounded. */ + fun trimSeen(seen: List, max: Int = 200): List = + if (seen.size <= max) seen else seen.takeLast(max) +} + /** * Pure library-queue logic (unit-tested; no coroutines, no Android). * diff --git a/app/src/main/kotlin/com/shonar/desktop/Main.kt b/app/src/main/kotlin/com/shonar/desktop/Main.kt index e403836..3f64b12 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Main.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Main.kt @@ -10,6 +10,7 @@ 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 @@ -151,7 +152,30 @@ fun main() = application { // (a delete from Detail lands back on Library, // but the toast must appear either way). val undoToast by state.undoTrash.collectAsState() + val summaryToast by state.summaryToast.collectAsState() Column(Modifier.fillMaxSize()) { + summaryToast?.let { t -> + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Row(Modifier.fillMaxWidth() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically) { + Text(t.label, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f)) + TextButton({ state.viewSummaryReady(t) }) { + Text("View") + } + TextButton({ state.dismissSummaryToast() }) { + Text("✕") + } + } + } + Spacer(Modifier.height(8.dp)) + } undoToast?.let { t -> Card(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth() diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 7648aef..0d32599 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -198,6 +198,7 @@ fun FolderPickerDialog( fun LibraryScreen(state: DesktopState) { val folder by state.folder.collectAsState() val allEntries by state.entries.collectAsState() + val unseenSummaries by state.unseenSummaries.collectAsState() val statusFilter by state.libraryFilter.collectAsState() // Status lens over the sorted list (counts above also follow it so // "Transcribe all (N)" reflects what's visible when filtered). @@ -437,12 +438,16 @@ fun LibraryScreen(state: DesktopState) { "failed" + (e.statusNote?.let { " ($it)" } ?: "") FileStatus.NEW -> "not transcribed" }, - ) + if (state.sortOrder.collectAsState().value != "name") + ) + (if (e.file.name in unseenSummaries) + // Summary finished while you were away; + // clears when you open this recording. + " • ✦ new summary" + else "") + (if (state.sortOrder.collectAsState().value != "name") // Date sorts: show what they sorted by. " • " + java.time.LocalDate.ofInstant( java.time.Instant.ofEpochMilli(e.file.lastModified()), java.time.ZoneId.systemDefault()) - else ""), + else "")), style = MaterialTheme.typography.bodySmall, color = when (e.status) { FileStatus.FAILED -> MaterialTheme.colorScheme.error diff --git a/app/src/test/kotlin/com/shonar/desktop/SummaryNotifyTest.kt b/app/src/test/kotlin/com/shonar/desktop/SummaryNotifyTest.kt new file mode 100644 index 0000000..ae4ef7f --- /dev/null +++ b/app/src/test/kotlin/com/shonar/desktop/SummaryNotifyTest.kt @@ -0,0 +1,32 @@ +package com.shonar.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SummaryNotifyTest { + @Test fun `encode-decode roundtrip is sorted and stable`() { + val names = setOf("b.m4a", "a.m4a") + assertEquals("""["a.m4a","b.m4a"]""", SummaryNotify.encode(names)) + assertEquals(names, SummaryNotify.decode(SummaryNotify.encode(names))) + } + + @Test fun `decode tolerates garbage and null`() { + assertEquals(emptySet(), SummaryNotify.decode(null)) + assertEquals(emptySet(), SummaryNotify.decode("not json")) + assertEquals(setOf("x.m4a"), SummaryNotify.decode("""["x.m4a",""]""")) + } + + @Test fun `seenKey dedupes per version`() { + assertEquals("f.m4a#3", SummaryNotify.seenKey("f.m4a", 3)) + assertTrue(SummaryNotify.seenKey("f.m4a", 3) != SummaryNotify.seenKey("f.m4a", 4)) + } + + @Test fun `trimSeen keeps the newest entries`() { + val many = (1..300).map { "k$it" } + val trimmed = SummaryNotify.trimSeen(many, max = 200) + assertEquals(200, trimmed.size) + assertEquals("k300", trimmed.last()) + assertTrue("k1" !in trimmed) + } +}