Shell: persistent sidebar (views, engine status, theme) + library top bar with search/sort/batch; responsive rail and drawer modes; Ctrl+F search
This commit is contained in:
parent
6e5a47ed5c
commit
ceb3be3dda
4 changed files with 361 additions and 67 deletions
|
|
@ -248,6 +248,8 @@ fun SonarField(
|
||||||
leadingIcon: (@Composable () -> Unit)? = null,
|
leadingIcon: (@Composable () -> Unit)? = null,
|
||||||
imeAction: ImeAction = ImeAction.Done,
|
imeAction: ImeAction = ImeAction.Done,
|
||||||
onDone: () -> Unit = {},
|
onDone: () -> Unit = {},
|
||||||
|
/** Extra modifier for the text input itself (focus requester). */
|
||||||
|
inputModifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var isFocused by androidx.compose.runtime.remember {
|
var isFocused by androidx.compose.runtime.remember {
|
||||||
androidx.compose.runtime.mutableStateOf(false)
|
androidx.compose.runtime.mutableStateOf(false)
|
||||||
|
|
@ -278,7 +280,7 @@ fun SonarField(
|
||||||
cursorBrush = SolidColor(Shonar.Accent),
|
cursorBrush = SolidColor(Shonar.Accent),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = imeAction),
|
keyboardOptions = KeyboardOptions(imeAction = imeAction),
|
||||||
keyboardActions = KeyboardActions(onDone = { onDone() }),
|
keyboardActions = KeyboardActions(onDone = { onDone() }),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = inputModifier.fillMaxWidth(),
|
||||||
decorationBox = { inner ->
|
decorationBox = { inner ->
|
||||||
if (value.isEmpty()) {
|
if (value.isEmpty()) {
|
||||||
Text(placeholder,
|
Text(placeholder,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
|
|
@ -99,6 +102,7 @@ fun main() = application {
|
||||||
) {
|
) {
|
||||||
val baseDensity = LocalDensity.current
|
val baseDensity = LocalDensity.current
|
||||||
val themeMode by state.themeMode.collectAsState()
|
val themeMode by state.themeMode.collectAsState()
|
||||||
|
val searchFocus = androidx.compose.ui.focus.FocusRequester()
|
||||||
ShonarTheme(mode = themeMode) {
|
ShonarTheme(mode = themeMode) {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier.fillMaxSize().onPreviewKeyEvent { e ->
|
Modifier.fillMaxSize().onPreviewKeyEvent { e ->
|
||||||
|
|
@ -125,6 +129,11 @@ fun main() = application {
|
||||||
} else false
|
} else false
|
||||||
} else if (!e.isCtrlPressed) false
|
} else if (!e.isCtrlPressed) false
|
||||||
else when (e.key) {
|
else when (e.key) {
|
||||||
|
Key.F -> {
|
||||||
|
if (screen == DesktopState.Screen.LIBRARY) {
|
||||||
|
searchFocus.requestFocus(); true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
Key.Equals, Key.Plus, Key.NumPadAdd, Key.DirectionUp -> {
|
Key.Equals, Key.Plus, Key.NumPadAdd, Key.DirectionUp -> {
|
||||||
zoom = (zoom * 1.1f).coerceAtMost(3f); true
|
zoom = (zoom * 1.1f).coerceAtMost(3f); true
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +154,35 @@ fun main() = application {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalDensity provides ScaledDensity(baseDensity, zoom),
|
LocalDensity provides ScaledDensity(baseDensity, zoom),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize().padding(20.dp)) {
|
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
val wide = maxWidth >= 1100.dp
|
||||||
|
val medium = maxWidth >= 760.dp
|
||||||
|
var drawerOpen by androidx.compose.runtime.remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxSize().padding(
|
||||||
|
top = Shonar.Space4, bottom = Shonar.Space4,
|
||||||
|
start = 0.dp, end = Shonar.Space4)) {
|
||||||
|
if (medium) {
|
||||||
|
ShonarSidebar(
|
||||||
|
state = state,
|
||||||
|
collapsed = !wide,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(Shonar.Space4))
|
||||||
|
}
|
||||||
|
Column(Modifier.fillMaxSize().weight(1f)) {
|
||||||
|
if (screen == DesktopState.Screen.LIBRARY) {
|
||||||
|
LibraryTopBar(
|
||||||
|
state = state,
|
||||||
|
showMenuButton = !medium,
|
||||||
|
onMenu = { drawerOpen = true },
|
||||||
|
searchFocus = searchFocus,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Shonar.Space3))
|
||||||
|
}
|
||||||
|
|
||||||
// Undo-delete toast sits above Library AND Detail
|
// Undo-delete toast sits above Library AND Detail
|
||||||
// (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).
|
||||||
|
|
@ -194,6 +231,25 @@ fun main() = application {
|
||||||
DesktopState.Screen.SETTINGS -> SettingsScreen(state)
|
DesktopState.Screen.SETTINGS -> SettingsScreen(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // content column
|
||||||
|
} // shell row
|
||||||
|
// Narrow screens: sidebar as an overlay drawer.
|
||||||
|
if (!medium && drawerOpen) {
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(Color(0x99000000))
|
||||||
|
.clickable { drawerOpen = false },
|
||||||
|
) {
|
||||||
|
ShonarSidebar(
|
||||||
|
state = state,
|
||||||
|
collapsed = false,
|
||||||
|
onNavigate = { drawerOpen = false },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.padding(Shonar.Space2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,15 +218,6 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
val selected by state.selected.collectAsState()
|
val selected by state.selected.collectAsState()
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(Shonar.Space3)) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(Shonar.Space3)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
ShonarWordmark(modifier = Modifier.weight(1f))
|
|
||||||
ShonarGhostButton({ state.go(DesktopState.Screen.SETTINGS) }) {
|
|
||||||
Icon(Icons.Filled.Settings, contentDescription = "Settings",
|
|
||||||
modifier = Modifier.size(Shonar.IconSm))
|
|
||||||
Spacer(Modifier.width(Shonar.Space2))
|
|
||||||
Text("Settings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
SonarCard(Modifier.fillMaxWidth()) {
|
SonarCard(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
|
Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
|
||||||
|
|
@ -239,25 +230,6 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
val auto = state.autoTranscribe.collectAsState().value
|
|
||||||
Checkbox(auto, { state.setAutoTranscribe(it) })
|
|
||||||
Text("Auto-transcribe new files",
|
|
||||||
style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
val batch = state.batch.collectAsState().value
|
|
||||||
val untranscribed = entries.count {
|
|
||||||
!it.hasReport && it.status != FileStatus.QUEUED &&
|
|
||||||
it.status != FileStatus.RUNNING
|
|
||||||
}
|
|
||||||
if (batch == null && untranscribed > 0) {
|
|
||||||
ShonarOutlinedButton({ state.transcribeAll() }) {
|
|
||||||
Text("Transcribe all ($untranscribed)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Batch progress: settled count + current file's own fraction.
|
// Batch progress: settled count + current file's own fraction.
|
||||||
val batch = state.batch.collectAsState().value
|
val batch = state.batch.collectAsState().value
|
||||||
if (batch != null) {
|
if (batch != null) {
|
||||||
|
|
@ -305,43 +277,6 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SonarField(query, { state.setQuery(it) },
|
|
||||||
placeholder = "Search saved transcripts…",
|
|
||||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(Icons.Filled.Search, contentDescription = null,
|
|
||||||
tint = Shonar.TextSecondary,
|
|
||||||
modifier = Modifier.size(Shonar.IconSm))
|
|
||||||
})
|
|
||||||
// Sort order (persisted): name A–Z, newest first, oldest first.
|
|
||||||
// Date modes also surface the file date on each row below.
|
|
||||||
if (folder != null && query.isBlank()) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
|
||||||
Text("Sort", style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
val order = state.sortOrder.collectAsState().value
|
|
||||||
listOf("name" to "Name", "new" to "Newest",
|
|
||||||
"old" to "Oldest").forEach { (value, label) ->
|
|
||||||
PillChip(
|
|
||||||
selected = order == value,
|
|
||||||
onClick = { state.setSortOrder(value) },
|
|
||||||
label = label,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(Shonar.Space3))
|
|
||||||
Text("Show", style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
listOf("all" to "All", "new" to "Untranscribed")
|
|
||||||
.forEach { (value, label) ->
|
|
||||||
PillChip(
|
|
||||||
selected = statusFilter == value,
|
|
||||||
onClick = { state.setLibraryFilter(value) },
|
|
||||||
label = label,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (selected.isNotEmpty() && query.isBlank()) {
|
if (selected.isNotEmpty() && query.isBlank()) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically,
|
Row(verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||||
|
|
|
||||||
301
app/src/main/kotlin/com/shonar/desktop/Shell.kt
Normal file
301
app/src/main/kotlin/com/shonar/desktop/Shell.kt
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
package com.shonar.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.WbSunny
|
||||||
|
import androidx.compose.material.icons.filled.Nightlight
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspace shell: a persistent sidebar (library views, engine
|
||||||
|
* status, settings, theme) plus a context top bar (search, sort,
|
||||||
|
* batch actions) above the content region.
|
||||||
|
*
|
||||||
|
* Responsive:
|
||||||
|
* - ≥1100dp: full sidebar (labels + counts)
|
||||||
|
* - 760–1100dp: icon rail (labels move to accessibility names)
|
||||||
|
* - <760dp: sidebar hides; a menu button in the top bar opens it as
|
||||||
|
* an overlay drawer over the content.
|
||||||
|
*
|
||||||
|
* The sidebar's library views ARE the session filter lens (state.
|
||||||
|
* libraryFilter) — never persisted, so a stale lens can't hide files
|
||||||
|
* at startup. Detail / Engine / Settings keep their own headers; the
|
||||||
|
* top bar only carries library chrome.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Library views offered by the sidebar; value = libraryFilter. */
|
||||||
|
private val LIBRARY_VIEWS = listOf(
|
||||||
|
"all" to "All recordings",
|
||||||
|
"new" to "Needs transcription",
|
||||||
|
"failed" to "Failed",
|
||||||
|
"done" to "Ready",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShonarSidebar(
|
||||||
|
state: DesktopState,
|
||||||
|
collapsed: Boolean, // icon rail mode
|
||||||
|
onNavigate: () -> Unit = {}, // closes the drawer on narrow screens
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val screen by state.screen.collectAsState()
|
||||||
|
val filter by state.libraryFilter.collectAsState()
|
||||||
|
val connected by state.connected.collectAsState()
|
||||||
|
val allEntries by state.entries.collectAsState()
|
||||||
|
|
||||||
|
val counts = mapOf(
|
||||||
|
"all" to allEntries.size,
|
||||||
|
"new" to allEntries.count {
|
||||||
|
!it.hasReport && it.status != FileStatus.QUEUED &&
|
||||||
|
it.status != FileStatus.RUNNING && it.status != FileStatus.DONE
|
||||||
|
},
|
||||||
|
"failed" to allEntries.count { it.status == FileStatus.FAILED },
|
||||||
|
"done" to allEntries.count {
|
||||||
|
it.hasReport || it.status == FileStatus.DONE
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxHeight()
|
||||||
|
.width(if (collapsed) Shonar.RailW else Shonar.SidebarW),
|
||||||
|
color = Shonar.SurfaceAlt,
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(vertical = Shonar.Space3)) {
|
||||||
|
// Brand block: logo tile (+ wordmark when expanded).
|
||||||
|
if (collapsed) {
|
||||||
|
Box(Modifier.padding(horizontal = Shonar.Space3)
|
||||||
|
.padding(bottom = Shonar.Space3)) {
|
||||||
|
SonarLogo(size = Shonar.ControlH)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = Shonar.Space3)
|
||||||
|
.padding(bottom = Shonar.Space3),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
SonarLogo(size = 26.dp)
|
||||||
|
Spacer(Modifier.width(Shonar.Space2))
|
||||||
|
Text("SHONAR",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = Shonar.TextPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Library views — the nav core.
|
||||||
|
LIBRARY_VIEWS.forEach { (value, label) ->
|
||||||
|
val selected = screen == DesktopState.Screen.LIBRARY &&
|
||||||
|
filter == value
|
||||||
|
NavRow(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
state.setLibraryFilter(value)
|
||||||
|
state.go(DesktopState.Screen.LIBRARY)
|
||||||
|
onNavigate()
|
||||||
|
},
|
||||||
|
label = label,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
horizontal = Shonar.Space2),
|
||||||
|
icon = {
|
||||||
|
val (img, name) = when (value) {
|
||||||
|
"new" -> Icons.Filled.Mic to "mic"
|
||||||
|
"failed" -> Icons.Filled.Error to "error"
|
||||||
|
"done" -> Icons.Filled.CheckCircle to "check"
|
||||||
|
else -> Icons.Filled.Folder to "folder"
|
||||||
|
}
|
||||||
|
Icon(img,
|
||||||
|
contentDescription = if (collapsed) label else null,
|
||||||
|
tint = if (selected) Shonar.Accent
|
||||||
|
else Shonar.TextMuted,
|
||||||
|
modifier = Modifier.size(Shonar.IconSm))
|
||||||
|
},
|
||||||
|
count = if (collapsed) null else counts[value],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(Shonar.Space3))
|
||||||
|
// Engine status: live dot + label; click opens the engine
|
||||||
|
// screen (start/stop/logs). Honest state, always visible.
|
||||||
|
NavRow(
|
||||||
|
selected = screen == DesktopState.Screen.ENGINE,
|
||||||
|
onClick = {
|
||||||
|
state.go(DesktopState.Screen.ENGINE); onNavigate()
|
||||||
|
},
|
||||||
|
label = if (collapsed) "Engine" else
|
||||||
|
if (connected) "Engine · running" else "Engine · stopped",
|
||||||
|
modifier = Modifier.padding(horizontal = Shonar.Space2),
|
||||||
|
icon = {
|
||||||
|
StatusDot(
|
||||||
|
color = if (connected) Shonar.Accent
|
||||||
|
else Shonar.Error,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
horizontal = (Shonar.IconSm - 8.dp) / 2),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
// Settings + theme toggle pinned at the bottom.
|
||||||
|
NavRow(
|
||||||
|
selected = screen == DesktopState.Screen.SETTINGS,
|
||||||
|
onClick = {
|
||||||
|
state.go(DesktopState.Screen.SETTINGS); onNavigate()
|
||||||
|
},
|
||||||
|
label = "Settings",
|
||||||
|
modifier = Modifier.padding(horizontal = Shonar.Space2),
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Filled.Settings,
|
||||||
|
contentDescription =
|
||||||
|
if (collapsed) "Settings" else null,
|
||||||
|
tint = if (screen == DesktopState.Screen.SETTINGS)
|
||||||
|
Shonar.Accent else Shonar.TextMuted,
|
||||||
|
modifier = Modifier.size(Shonar.IconSm))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.padding(horizontal = Shonar.Space2)
|
||||||
|
.padding(top = Shonar.Space1),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = if (collapsed)
|
||||||
|
Arrangement.Center else Arrangement.Start,
|
||||||
|
) {
|
||||||
|
val mode = state.themeMode.collectAsState().value
|
||||||
|
ShonarIconButton(
|
||||||
|
onClick = {
|
||||||
|
state.setThemeMode(
|
||||||
|
if (mode == ThemeMode.DARK) ThemeMode.LIGHT
|
||||||
|
else ThemeMode.DARK)
|
||||||
|
},
|
||||||
|
label = if (mode == ThemeMode.DARK)
|
||||||
|
"Switch to light theme" else "Switch to dark theme",
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (mode == ThemeMode.DARK) Icons.Filled.WbSunny
|
||||||
|
else Icons.Filled.Nightlight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Shonar.TextSecondary,
|
||||||
|
modifier = Modifier.size(Shonar.IconSm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Library toolbar: search, sort, batch action, status meta. Only
|
||||||
|
* drawn over the LIBRARY screen; Detail/Engine/Settings carry their
|
||||||
|
* own headers. [searchFocus] lets Ctrl+F jump into the field. */
|
||||||
|
@Composable
|
||||||
|
fun LibraryTopBar(
|
||||||
|
state: DesktopState,
|
||||||
|
showMenuButton: Boolean,
|
||||||
|
onMenu: () -> Unit,
|
||||||
|
searchFocus: FocusRequester,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val query by state.query.collectAsState()
|
||||||
|
val connected by state.connected.collectAsState()
|
||||||
|
Column(modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (showMenuButton) {
|
||||||
|
ShonarIconButton(onClick = onMenu, label = "Open navigation") {
|
||||||
|
Icon(Icons.Filled.Menu, contentDescription = null,
|
||||||
|
tint = Shonar.TextSecondary,
|
||||||
|
modifier = Modifier.size(Shonar.IconSm))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(Shonar.Space2))
|
||||||
|
}
|
||||||
|
SonarField(query, { state.setQuery(it) },
|
||||||
|
placeholder = "Search saved transcripts…",
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
inputModifier = Modifier.focusRequester(searchFocus),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Filled.Search, contentDescription = null,
|
||||||
|
tint = Shonar.TextSecondary,
|
||||||
|
modifier = Modifier.size(Shonar.IconSm))
|
||||||
|
})
|
||||||
|
Spacer(Modifier.width(Shonar.Space2))
|
||||||
|
ShonarIconButton(
|
||||||
|
onClick = {
|
||||||
|
state.go(DesktopState.Screen.SETTINGS)
|
||||||
|
},
|
||||||
|
label = "Settings",
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = null,
|
||||||
|
tint = Shonar.TextSecondary,
|
||||||
|
modifier = Modifier.size(Shonar.IconSm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort + auto-transcribe + batch: second row, dense.
|
||||||
|
val batch = state.batch.collectAsState().value
|
||||||
|
val allEntries by state.entries.collectAsState()
|
||||||
|
val remaining = allEntries.count {
|
||||||
|
!it.hasReport && it.status != FileStatus.QUEUED &&
|
||||||
|
it.status != FileStatus.RUNNING
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||||
|
Text("Sort", style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Shonar.TextSecondary)
|
||||||
|
val order = state.sortOrder.collectAsState().value
|
||||||
|
listOf("name" to "Name", "new" to "Newest",
|
||||||
|
"old" to "Oldest").forEach { (value, label) ->
|
||||||
|
PillChip(
|
||||||
|
selected = order == value,
|
||||||
|
onClick = { state.setSortOrder(value) },
|
||||||
|
label = label,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
val auto = state.autoTranscribe.collectAsState().value
|
||||||
|
Checkbox(auto, { state.setAutoTranscribe(it) },
|
||||||
|
modifier = Modifier.size(28.dp))
|
||||||
|
Text("Auto-transcribe",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Shonar.TextSecondary)
|
||||||
|
}
|
||||||
|
if (batch == null && remaining > 0 && connected) {
|
||||||
|
ShonarOutlinedButton({ state.transcribeAll() }) {
|
||||||
|
Text("Transcribe all ($remaining)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page title row for non-library screens is owned by those screens;
|
||||||
|
* the shell adds only the sidebar divider hairline via Surface. */
|
||||||
Loading…
Add table
Add a link
Reference in a new issue