WIP snapshot: summary-ready toast + unseen badge (safety commit before power loss; sibling session actively editing — may not compile yet)
This commit is contained in:
parent
3061629dc5
commit
add622d649
5 changed files with 173 additions and 2 deletions
|
|
@ -170,6 +170,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
prefs.getString(KEY_SORT)?.takeIf { it in SORT_ORDERS }
|
prefs.getString(KEY_SORT)?.takeIf { it in SORT_ORDERS }
|
||||||
?.let { _sortOrder.value = it }
|
?.let { _sortOrder.value = it }
|
||||||
_summarizer.value = prefs.getString(KEY_SUMMARIZER) ?: "auto"
|
_summarizer.value = prefs.getString(KEY_SUMMARIZER) ?: "auto"
|
||||||
|
loadNotifyState()
|
||||||
prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } }
|
prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } }
|
||||||
?.let { setFolder(it, silent = true) }
|
?.let { setFolder(it, silent = true) }
|
||||||
ensureReady()
|
ensureReady()
|
||||||
|
|
@ -641,6 +642,76 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
private val _liveProgress = MutableStateFlow<Map<String, LiveProgress>>(emptyMap())
|
private val _liveProgress = MutableStateFlow<Map<String, LiveProgress>>(emptyMap())
|
||||||
val liveProgress = _liveProgress.asStateFlow()
|
val liveProgress = _liveProgress.asStateFlow()
|
||||||
|
|
||||||
|
// ---- summary-ready notifications ------------------------------------------
|
||||||
|
// One-shot toast ("Summary ready — <file>", 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<SummaryToast?>(null)
|
||||||
|
val summaryToast = _summaryToast.asStateFlow()
|
||||||
|
private val _unseenSummaries = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val unseenSummaries = _unseenSummaries.asStateFlow()
|
||||||
|
private val announcedSummaries = mutableSetOf<String>()
|
||||||
|
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?) {
|
private fun setLive(name: String, live: LiveProgress?) {
|
||||||
_liveProgress.value = _liveProgress.value.toMutableMap().apply {
|
_liveProgress.value = _liveProgress.value.toMutableMap().apply {
|
||||||
if (live == null) remove(name) else put(name, live)
|
if (live == null) remove(name) else put(name, live)
|
||||||
|
|
@ -730,6 +801,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
.getOrNull() ?: return false
|
.getOrNull() ?: return false
|
||||||
val summary = runCatching { parseSummary(provider.fetchSummary(refKey)) }.getOrNull()
|
val summary = runCatching { parseSummary(provider.fetchSummary(refKey)) }.getOrNull()
|
||||||
saveReport(DetailUi(file = f, transcript = transcript, summary = summary))
|
saveReport(DetailUi(file = f, transcript = transcript, summary = summary))
|
||||||
|
summary?.let { announceSummaryReady(f, it.version) }
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
setLive(f.name, null)
|
setLive(f.name, null)
|
||||||
|
|
@ -804,6 +876,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
if (transcript != null || summary != null) {
|
if (transcript != null || summary != null) {
|
||||||
saveReport(DetailUi(file = f, transcript = transcript, summary = summary))
|
saveReport(DetailUi(file = f, transcript = transcript, summary = summary))
|
||||||
}
|
}
|
||||||
|
summary?.let { announceSummaryReady(f, it.version) }
|
||||||
rescanStatuses()
|
rescanStatuses()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1258,6 +1331,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
|
|
||||||
fun openDetail(file: File) {
|
fun openDetail(file: File) {
|
||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
|
clearUnseenSummary(file)
|
||||||
// Show the saved report immediately — otherwise opening a finished
|
// Show the saved report immediately — otherwise opening a finished
|
||||||
// file looks empty and the summary appears unreachable.
|
// file looks empty and the summary appears unreachable.
|
||||||
val saved = runCatching { reportFile(file).readText() }.getOrNull()
|
val saved = runCatching { reportFile(file).readText() }.getOrNull()
|
||||||
|
|
@ -1585,6 +1659,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
d0.busy = null
|
d0.busy = null
|
||||||
_detail.value = d0.copy()
|
_detail.value = d0.copy()
|
||||||
|
d0.summary?.let { announceSummaryReady(d0.file, it.version) }
|
||||||
if (d0.transcript != null || d0.summary != null) {
|
if (d0.transcript != null || d0.summary != null) {
|
||||||
saveReport(d0)
|
saveReport(d0)
|
||||||
saveMapping(d0.file, RemoteMapping(remoteId, model = d0.overrideModel,
|
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). */
|
/** Summarizer choice: "auto" | "lan" | "local" (see setSummarizer). */
|
||||||
const val KEY_SUMMARIZER = "llm.summarizer"
|
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). */
|
/** Preferred local summarization model (Ollama). */
|
||||||
const val DEFAULT_LLM_MODEL = "qwen3:4b"
|
const val DEFAULT_LLM_MODEL = "qwen3:4b"
|
||||||
/** LAN H200 inference server (private 10.x network, never internet). */
|
/** LAN H200 inference server (private 10.x network, never internet). */
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,40 @@
|
||||||
package com.shonar.desktop
|
package com.shonar.desktop
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
import java.io.File
|
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>): String {
|
||||||
|
val arr = JSONArray()
|
||||||
|
names.sorted().forEach { arr.put(it) }
|
||||||
|
return arr.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(raw: String?): Set<String> = 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<String>, max: Int = 200): List<String> =
|
||||||
|
if (seen.size <= max) seen else seen.takeLast(max)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure library-queue logic (unit-tested; no coroutines, no Android).
|
* Pure library-queue logic (unit-tested; no coroutines, no Android).
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ 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.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.TextButton
|
||||||
|
|
@ -151,7 +152,30 @@ fun main() = application {
|
||||||
// (a delete from Detail lands back on Library,
|
// (a delete from Detail lands back on Library,
|
||||||
// but the toast must appear either way).
|
// but the toast must appear either way).
|
||||||
val undoToast by state.undoTrash.collectAsState()
|
val undoToast by state.undoTrash.collectAsState()
|
||||||
|
val summaryToast by state.summaryToast.collectAsState()
|
||||||
Column(Modifier.fillMaxSize()) {
|
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 ->
|
undoToast?.let { t ->
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth()
|
Row(Modifier.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,7 @@ fun FolderPickerDialog(
|
||||||
fun LibraryScreen(state: DesktopState) {
|
fun LibraryScreen(state: DesktopState) {
|
||||||
val folder by state.folder.collectAsState()
|
val folder by state.folder.collectAsState()
|
||||||
val allEntries by state.entries.collectAsState()
|
val allEntries by state.entries.collectAsState()
|
||||||
|
val unseenSummaries by state.unseenSummaries.collectAsState()
|
||||||
val statusFilter by state.libraryFilter.collectAsState()
|
val statusFilter by state.libraryFilter.collectAsState()
|
||||||
// Status lens over the sorted list (counts above also follow it so
|
// Status lens over the sorted list (counts above also follow it so
|
||||||
// "Transcribe all (N)" reflects what's visible when filtered).
|
// "Transcribe all (N)" reflects what's visible when filtered).
|
||||||
|
|
@ -437,12 +438,16 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
"failed" + (e.statusNote?.let { " ($it)" } ?: "")
|
"failed" + (e.statusNote?.let { " ($it)" } ?: "")
|
||||||
FileStatus.NEW -> "not transcribed"
|
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.
|
// Date sorts: show what they sorted by.
|
||||||
" • " + java.time.LocalDate.ofInstant(
|
" • " + java.time.LocalDate.ofInstant(
|
||||||
java.time.Instant.ofEpochMilli(e.file.lastModified()),
|
java.time.Instant.ofEpochMilli(e.file.lastModified()),
|
||||||
java.time.ZoneId.systemDefault())
|
java.time.ZoneId.systemDefault())
|
||||||
else ""),
|
else "")),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = when (e.status) {
|
color = when (e.status) {
|
||||||
FileStatus.FAILED -> MaterialTheme.colorScheme.error
|
FileStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||||
|
|
|
||||||
32
app/src/test/kotlin/com/shonar/desktop/SummaryNotifyTest.kt
Normal file
32
app/src/test/kotlin/com/shonar/desktop/SummaryNotifyTest.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue