Reskin to dark-navy + cyan design system; tidy recording rows, settings access on top

This commit is contained in:
avi 2026-09-18 15:32:41 -05:00
commit 47f7cb9336
5 changed files with 986 additions and 170 deletions

View file

@ -1,26 +1,37 @@
package com.shonar.ui.detail
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@ -35,10 +46,14 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.shonar.ShonarApplication
import com.shonar.ui.theme.LocalShonarExtras
import com.shonar.ui.theme.ShonarSpacing
import com.shonar.ui.theme.ShonarStatusPill
/**
* Recording details: playback, rename, file info. Local-only — no tabs,
* no server content.
* no server content. Restyled to the shared dark-navy + cyan system;
* behavior unchanged.
*/
@Composable
fun DetailsScreen(recordingId: String, onBack: () -> Unit) {
@ -47,13 +62,13 @@ fun DetailsScreen(recordingId: String, onBack: () -> Unit) {
val vm: DetailsViewModel = viewModel(factory = DetailsViewModel.factory(app, recordingId))
val uiState by vm.state.collectAsState()
Scaffold { padding ->
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(16.dp),
Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
when (val s = uiState) {
@ -77,17 +92,27 @@ fun DetailsScreen(recordingId: String, onBack: () -> Unit) {
@Composable
private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) {
val cs = MaterialTheme.colorScheme
var editing by remember { mutableStateOf(false) }
var draft by remember(title) { mutableStateOf(title) }
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.background(cs.primary.copy(alpha = 0.14f), RoundedCornerShape(10.dp))
.border(1.dp, cs.primary.copy(alpha = 0.45f), RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Mic, contentDescription = null, tint = cs.primary, modifier = Modifier.size(20.dp))
}
Text(
title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
color = cs.onBackground,
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
maxLines = 2,
)
IconButton(onClick = { editing = true }) {
Icon(Icons.Filled.Edit, contentDescription = "Rename")
}
IconButton(onClick = { editing = true }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.Edit, contentDescription = "Rename recording", tint = cs.onSurfaceVariant)
}
if (editing) {
AlertDialog(
@ -102,7 +127,10 @@ private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) {
)
},
confirmButton = {
TextButton(onClick = { onRename(draft); editing = false }) { Text("Save") }
TextButton(
onClick = { onRename(draft); editing = false },
enabled = draft.isNotBlank(),
) { Text("Save") }
},
dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } },
)
@ -111,8 +139,10 @@ private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) {
@Composable
private fun ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) {
PlaybackCard(ready = ready, onToggle = vm::togglePlayback,
onSeek = vm::seekTo, onSpeed = vm::cycleSpeed)
PlaybackCard(
ready = ready, onToggle = vm::togglePlayback,
onSeek = vm::seekTo, onSpeed = vm::cycleSpeed,
)
ready.playbackError?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
@ -131,41 +161,106 @@ private fun PlaybackCard(
onSeek: (Long) -> Unit,
onSpeed: () -> Unit,
) {
Card(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
val cs = MaterialTheme.colorScheme
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = cs.surfaceVariant),
border = androidx.compose.foundation.BorderStroke(
1.dp,
if (ready.playing) cs.primary else cs.outline.copy(alpha = 0.55f),
),
modifier = Modifier.fillMaxWidth(),
) {
if (ready.playing) {
Box(Modifier.fillMaxWidth().height(2.dp).background(cs.primary))
}
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = onToggle,
modifier = Modifier
.size(56.dp)
.background(cs.primary.copy(alpha = 0.14f), androidx.compose.foundation.shape.CircleShape)
.border(1.dp, cs.primary.copy(alpha = 0.5f), androidx.compose.foundation.shape.CircleShape),
) {
IconButton(onClick = onToggle) {
Icon(
if (ready.playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (ready.playing) "Pause" else "Play",
tint = cs.primary,
modifier = Modifier.size(28.dp),
)
}
Column(Modifier.weight(1f)) {
Column(
Modifier.weight(1f).padding(horizontal = 12.dp),
) {
Text(
ready.recording.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
)
Text(
if (ready.playing) "Playing" else "Paused",
style = MaterialTheme.typography.labelMedium,
color = LocalShonarExtras.current.muted,
)
}
ShonarStatusPill("Saved")
}
Slider(
value = ready.positionMs.toFloat(),
onValueChange = { onSeek(it.toLong()) },
valueRange = 0f..ready.durationMs.coerceAtLeast(1).toFloat(),
colors = SliderDefaults.colors(
thumbColor = cs.primary,
activeTrackColor = cs.primary,
inactiveTrackColor = cs.outline.copy(alpha = 0.6f),
),
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(formatMs(ready.positionMs), style = MaterialTheme.typography.bodySmall)
Text(formatMs(ready.durationMs), style = MaterialTheme.typography.bodySmall)
Text(
formatMs(ready.positionMs),
style = MaterialTheme.typography.bodySmall,
color = cs.onSurfaceVariant,
)
OutlinedButton(
onClick = onSpeed,
shape = RoundedCornerShape(10.dp),
border = androidx.compose.foundation.BorderStroke(
1.dp, cs.primary.copy(alpha = 0.55f),
),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp),
) { Text("${ready.speed}×", style = MaterialTheme.typography.labelLarge) }
Text(
formatMs(ready.durationMs),
style = MaterialTheme.typography.bodySmall,
color = cs.onSurfaceVariant,
)
}
}
TextButton(onClick = onSpeed) { Text("${ready.speed}×") }
}
}
}
@Composable
private fun InfoCard(ready: DetailsViewModel.Ready) {
val cs = MaterialTheme.colorScheme
val r = ready.recording
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = cs.surface),
border = androidx.compose.foundation.BorderStroke(1.dp, cs.outline.copy(alpha = 0.55f)),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(ShonarSpacing.cardPad)) {
Text(
"File details",
style = MaterialTheme.typography.titleSmall,
color = cs.primary,
)
androidx.compose.foundation.layout.Spacer(Modifier.height(8.dp))
InfoRow("Length", formatMs(r.durationMs))
InfoRow("Size", formatBytes(r.sizeBytes))
InfoRow("Recorded", ready.recordedLabel)
@ -176,19 +271,28 @@ private fun InfoCard(ready: DetailsViewModel.Ready) {
@Composable
private fun InfoRow(label: String, value: String) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium)
Row(
Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
label, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 12.dp),
)
Text(
value, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun LoadingRow() {
Row(
Modifier.fillMaxWidth(),
Modifier.fillMaxWidth().padding(24.dp),
horizontalArrangement = Arrangement.Center,
) { CircularProgressIndicator() }
) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) }
}
private fun formatMs(ms: Long): String {

View file

@ -4,8 +4,12 @@ import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -13,18 +17,24 @@ 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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@ -45,14 +55,29 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.shonar.ShonarApplication
import com.shonar.recording.RecordingEntity
import com.shonar.recording.RecordingSnapshot
import com.shonar.settings.BuiltInSettings
import com.shonar.ui.theme.LocalShonarExtras
import com.shonar.ui.theme.ShonarChip
import com.shonar.ui.theme.ShonarOutlineAction
import com.shonar.ui.theme.ShonarSearchField
import com.shonar.ui.theme.ShonarSpacing
import java.text.DateFormat
import java.util.Date
import kotlinx.coroutines.launch
private enum class SortMode { NAME, NEWEST, OLDEST }
/**
* Home: record, local library, settings. Everything stays on this phone —
* no accounts, no servers. File sync (if wanted) is the user's own tooling.
* Includes the first-launch recording-consent notice which must be
* acknowledged before anything else.
*
* Visual language follows the reference: dark navy surfaces, cyan accent,
* subtle borders, compact scannable rows. All controls below map 1:1 to
* pre-existing behavior — styling only.
*/
@Composable
fun HomeScreen(
@ -69,6 +94,8 @@ fun HomeScreen(
val scope = rememberCoroutineScope()
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
var storageOk by remember { mutableStateOf<Boolean?>(null) }
var query by remember { mutableStateOf("") }
var sort by remember { mutableStateOf(SortMode.NEWEST) }
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// Re-check file access on every return (user may have just flipped it
// on in system Settings).
@ -110,12 +137,27 @@ fun HomeScreen(
.getOrDefault(true)
}
// UI-only filtering/sorting over the already-loaded Room list.
// No data is hidden from the repository — purely presentational.
val visible = remember(recordings, query, sort) {
val q = query.trim().lowercase()
val filtered = if (q.isBlank()) recordings
else recordings.filter { it.title.lowercase().contains(q) }
when (sort) {
SortMode.NAME -> filtered.sortedBy { it.title.lowercase() }
SortMode.NEWEST -> filtered.sortedByDescending { it.createdAtEpochMs }
SortMode.OLDEST -> filtered.sortedBy { it.createdAtEpochMs }
}
}
val isRecording = recorderState.phase != RecordingSnapshot.Phase.IDLE
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
if (consentSeen == true) {
if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
if (!isRecording) {
startRecording()
} else {
vm.stop()
@ -124,10 +166,13 @@ fun HomeScreen(
},
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
icon = { Icon(Icons.Filled.Mic, contentDescription = null) },
text = {
Text(if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) "Record" else "Stop")
icon = {
Icon(
if (isRecording) Icons.Filled.Pause else Icons.Filled.Mic,
contentDescription = null,
)
},
text = { Text(if (!isRecording) "Record" else "Stop") },
)
},
) { padding ->
@ -135,17 +180,24 @@ fun HomeScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(horizontal = ShonarSpacing.screenH),
verticalArrangement = Arrangement.spacedBy(ShonarSpacing.listGap),
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 24.dp),
contentPadding = PaddingValues(top = 20.dp, bottom = 96.dp),
) {
item { Text("SHONAR", style = MaterialTheme.typography.headlineMedium) }
item { HeaderBlock(recording = isRecording, onOpenSettings = onOpenSettings) }
item {
Text(
"Oral Notes and Audio Recorder — everything stays on this phone",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
ShonarSearchField(
value = query,
onValueChange = { query = it },
placeholder = "Search saved recordings…",
modifier = Modifier.fillMaxWidth(),
)
}
item {
SortRow(
sort = sort,
onSort = { sort = it },
)
}
item { StorageChip() }
@ -164,12 +216,7 @@ fun HomeScreen(
})
}
}
item { Spacer(Modifier.height(8.dp)) }
item {
QuickCard("Settings", "Appearance, custom settings",
Icons.Filled.Settings, onOpenSettings)
}
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
if (isRecording) {
item {
RecordingStatusCard(
state = recorderState,
@ -182,22 +229,44 @@ fun HomeScreen(
}
if (recordings.isNotEmpty()) {
item {
Text("Your recordings", style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth())
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
"Your recordings",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"${visible.size} of ${recordings.size}",
style = MaterialTheme.typography.labelMedium,
color = LocalShonarExtras.current.muted,
)
}
}
notice?.let { msg ->
item {
Text(msg, color = MaterialTheme.colorScheme.error,
Text(
msg, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth())
modifier = Modifier.fillMaxWidth(),
)
}
}
if (visible.isEmpty()) {
item {
EmptyState(text = "No recordings match “$query”.")
}
} else {
items(
items = recordings,
items = visible,
key = { it.id },
) { recording ->
RecordingRow(
recording = recording,
selected = playingId == recording.id,
playing = playingId == recording.id,
onPlay = { vm.togglePlayback(recording) },
onDelete = { vm.delete(recording) },
@ -205,15 +274,21 @@ fun HomeScreen(
onRename = { vm.rename(recording, it) },
)
}
}
} else {
item {
Text(
"Your recordings will appear here and stay on this device.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
EmptyState(text = "Your recordings will appear here and stay on this device.")
}
}
item { Spacer(Modifier.height(4.dp)) }
item {
QuickCard(
"Settings",
"Appearance, library folder",
Icons.Filled.Settings,
onOpenSettings,
)
}
}
}
@ -227,29 +302,163 @@ fun HomeScreen(
}
}
// ---------------------------------------------------------------------------
// Header — mirrors reference title block, adapted to Android.
// ---------------------------------------------------------------------------
@Composable
private fun HeaderBlock(recording: Boolean, onOpenSettings: () -> Unit) {
val cs = MaterialTheme.colorScheme
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.background(
cs.primary.copy(alpha = 0.14f),
RoundedCornerShape(12.dp),
)
.border(1.dp, cs.primary.copy(alpha = 0.45f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = null,
tint = cs.primary,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
"Recordings",
style = MaterialTheme.typography.headlineSmall,
color = cs.onBackground,
)
Text(
if (recording) "Recording in progress — stays on this phone"
else "Your saved recordings stay on this phone",
style = MaterialTheme.typography.bodySmall,
color = cs.onSurfaceVariant,
)
}
if (recording) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(8.dp)
.background(cs.error, androidx.compose.foundation.shape.CircleShape),
)
Spacer(Modifier.width(6.dp))
Text(
"REC",
style = MaterialTheme.typography.labelMedium,
color = cs.error,
)
}
Spacer(Modifier.width(4.dp))
}
IconButton(onClick = onOpenSettings, modifier = Modifier.size(48.dp)) {
Icon(
Icons.Filled.Settings,
contentDescription = "Open settings",
tint = cs.onSurfaceVariant,
)
}
}
}
@Composable
private fun SortRow(
sort: SortMode,
onSort: (SortMode) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Sort",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ShonarChip("Name", sort == SortMode.NAME, { onSort(SortMode.NAME) })
ShonarChip("Newest", sort == SortMode.NEWEST, { onSort(SortMode.NEWEST) })
ShonarChip("Oldest", sort == SortMode.OLDEST, { onSort(SortMode.OLDEST) })
}
}
}
// ---------------------------------------------------------------------------
// Recording hero — same controls as before, clearer hierarchy.
// ---------------------------------------------------------------------------
@Composable
private fun RecordingStatusCard(
state: com.shonar.recording.RecordingSnapshot,
state: RecordingSnapshot,
onPause: () -> Unit,
onResume: () -> Unit,
onStop: () -> Unit,
onDiscard: () -> Unit,
) {
val cs = MaterialTheme.colorScheme
var confirmDiscard by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
val paused = state.phase == RecordingSnapshot.Phase.PAUSED
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = cs.surfaceVariant),
border = androidx.compose.foundation.BorderStroke(1.dp, cs.primary.copy(alpha = 0.7f)),
modifier = Modifier.fillMaxWidth(),
) {
// Left accent bar effect via top border line.
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.background(cs.primary),
)
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(10.dp)
.background(
if (paused) cs.onSurfaceVariant else cs.error,
androidx.compose.foundation.shape.CircleShape,
),
)
Spacer(Modifier.width(8.dp))
Text(
if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress",
if (paused) "Recording paused" else "Recording in progress",
style = MaterialTheme.typography.titleMedium,
color = cs.onSurface,
modifier = Modifier.weight(1f),
)
Text(
formatDuration(state.elapsedMs),
style = MaterialTheme.typography.titleLarge,
color = cs.onSurface,
)
Text(formatDuration(state.elapsedMs), style = MaterialTheme.typography.bodyLarge)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) onResume else onPause) {
Text(if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Resume" else "Pause")
}
TextButton(onClick = onStop) { Text("Finish") }
Text(
if (paused) "Paused — resume or finish when ready."
else "Capturing audio. Pause, finish, or discard below.",
style = MaterialTheme.typography.bodySmall,
color = cs.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShonarOutlineAction(
label = if (paused) "Resume" else "Pause",
onClick = if (paused) onResume else onPause,
)
ShonarOutlineAction(label = "Finish", onClick = onStop)
Spacer(Modifier.weight(1f))
TextButton(onClick = { confirmDiscard = true }) {
Text("Discard", color = MaterialTheme.colorScheme.error)
Text("Discard", color = cs.error)
}
}
}
@ -271,35 +480,101 @@ private fun RecordingStatusCard(
}
}
// ---------------------------------------------------------------------------
// Recording row — reference-inspired: icon tile, title, metadata, pill, actions.
// ---------------------------------------------------------------------------
@Composable
private fun RecordingRow(
recording: com.shonar.recording.RecordingEntity,
recording: RecordingEntity,
selected: Boolean,
playing: Boolean,
onPlay: () -> Unit,
onDelete: () -> Unit,
onOpen: () -> Unit,
onRename: (String) -> Unit,
) {
val cs = MaterialTheme.colorScheme
var menu by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf(false) }
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
var confirmDelete by remember { mutableStateOf(false) }
Card(
onClick = onOpen,
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) cs.surfaceVariant else cs.surface,
),
border = androidx.compose.foundation.BorderStroke(
1.dp,
if (selected) cs.primary else cs.outline.copy(alpha = 0.55f),
),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.weight(1f)) {
Text(recording.title, style = MaterialTheme.typography.titleMedium)
Text(
"${formatDuration(recording.durationMs)} • ${formatBytes(recording.sizeBytes)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
if (selected) {
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.background(cs.primary),
)
}
TextButton(onClick = onPlay) { Text(if (playing) "Pause" else "Play") }
androidx.compose.material3.IconButton(onClick = { menu = true }) {
androidx.compose.material3.Icon(
androidx.compose.material.icons.Icons.Filled.MoreVert,
contentDescription = "More actions",
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.background(
if (selected) cs.primary.copy(alpha = 0.16f) else cs.surfaceVariant,
RoundedCornerShape(10.dp),
)
.border(
1.dp,
if (selected) cs.primary.copy(alpha = 0.5f) else cs.outline.copy(alpha = 0.4f),
RoundedCornerShape(10.dp),
),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = null,
tint = if (selected) cs.primary else cs.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
recording.title,
style = MaterialTheme.typography.titleSmall,
color = cs.onSurface,
maxLines = 2,
)
Spacer(Modifier.height(2.dp))
Text(
"${formatBytes(recording.sizeBytes)} • ${formatDuration(recording.durationMs)} • ${formatDate(recording.createdAtEpochMs)}",
style = MaterialTheme.typography.bodySmall,
color = cs.onSurfaceVariant,
maxLines = 1,
)
}
IconButton(
onClick = onPlay,
modifier = Modifier.size(48.dp),
) {
Icon(
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (playing) "Pause preview" else "Play preview",
tint = cs.primary,
modifier = Modifier.size(24.dp),
)
}
IconButton(onClick = { menu = true }, modifier = Modifier.size(48.dp)) {
Icon(
Icons.Filled.MoreVert,
contentDescription = "More actions for ${recording.title}",
tint = cs.onSurfaceVariant,
)
}
androidx.compose.material3.DropdownMenu(
@ -312,7 +587,10 @@ private fun RecordingRow(
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
onClick = { menu = false; onDelete() },
trailingIcon = {
Icon(Icons.Filled.Delete, contentDescription = null)
},
onClick = { menu = false; confirmDelete = true },
)
}
}
@ -346,6 +624,19 @@ private fun RecordingRow(
dismissButton = { TextButton(onClick = { renaming = false }) { Text("Cancel") } },
)
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Delete recording?") },
text = { Text("“${recording.title}” will be removed from this phone. This can't be undone.") },
confirmButton = {
TextButton(onClick = { confirmDelete = false; onDelete() }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
)
}
}
private fun formatDuration(durationMs: Long): String {
@ -359,18 +650,33 @@ private fun formatBytes(bytes: Long): String = when {
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
private fun formatDate(epochMs: Long): String = runCatching {
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(epochMs))
}.getOrDefault("--")
/** Shown when the OS file grant is missing: library stays empty until then. */
@Composable
private fun StorageAccessCard(onGrant: () -> Unit) {
Card(colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer)) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
border = androidx.compose.foundation.BorderStroke(
1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.4f),
),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("File access is off — your recordings can't be listed.",
Text(
"File access is off — your recordings can't be listed.",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer)
Text("SHONAR keeps its library in your Music folder. Allow all-files access, then come back.",
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"SHONAR keeps its library in your Music folder. Allow all-files access, then come back.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer)
color = MaterialTheme.colorScheme.onErrorContainer,
)
TextButton(onClick = onGrant) { Text("Open system settings") }
}
}
@ -379,21 +685,52 @@ private fun StorageAccessCard(onGrant: () -> Unit) {
/** Fixed on-phone storage indicator — no selection, nothing leaves the phone. */
@Composable
private fun StorageChip() {
Card(colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.PhoneAndroid, contentDescription = null,
val cs = MaterialTheme.colorScheme
Card(
shape = RoundedCornerShape(50),
colors = CardDefaults.cardColors(containerColor = cs.surfaceVariant),
border = androidx.compose.foundation.BorderStroke(1.dp, cs.outline.copy(alpha = 0.6f)),
) {
Row(
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.PhoneAndroid, contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer)
tint = cs.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
Text("Stored: on this phone",
Text(
"Stored: on this phone",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer)
color = cs.onSurfaceVariant,
)
}
}
}
@Composable
private fun EmptyState(text: String) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
border = androidx.compose.foundation.BorderStroke(
1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
@Composable
private fun ConsentDialog(onAcknowledge: () -> Unit) {
AlertDialog(
@ -421,22 +758,39 @@ private fun QuickCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
val cs = MaterialTheme.colorScheme
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = cs.surface),
border = androidx.compose.foundation.BorderStroke(1.dp, cs.outline.copy(alpha = 0.55f)),
) {
Row(
Modifier
.fillMaxWidth()
.padding(20.dp),
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp))
Spacer(Modifier.size(16.dp))
Column {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(subtitle, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Box(
Modifier
.size(40.dp)
.background(cs.surfaceVariant, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
icon, contentDescription = null,
tint = cs.primary,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.size(12.dp))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
subtitle, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View file

@ -55,6 +55,7 @@ import androidx.compose.ui.unit.dp
import com.shonar.ShonarApplication
import com.shonar.settings.SettingDefinition
import com.shonar.settings.SettingType
import com.shonar.ui.theme.ShonarSectionLabel
import com.shonar.ui.theme.parseColor
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
@ -81,57 +82,90 @@ fun SettingsScreen(onBack: () -> Unit) {
Column(Modifier.fillMaxWidth()) {
TopAppBar(
title = { Text("Settings") },
title = {
Column {
Text("Settings", style = MaterialTheme.typography.titleLarge)
Text(
"Appearance, library, custom settings",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { vm.export { exportText = it } }) {
IconButton(onClick = { vm.export { exportText = it } }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.Download, contentDescription = "Export settings")
}
IconButton(onClick = { showImportDialog = true }) {
IconButton(onClick = { showImportDialog = true }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.Upload, contentDescription = "Import settings")
}
IconButton(onClick = { showAddDialog = true }) {
IconButton(onClick = { showAddDialog = true }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.Add, contentDescription = "Add custom setting")
}
},
)
state.message?.let { msg ->
androidx.compose.material3.Card(
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
border = androidx.compose.foundation.BorderStroke(
1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.6f),
),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Text(
msg,
color = MaterialTheme.colorScheme.error,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
LazyColumn(Modifier.fillMaxWidth()) {
LazyColumn(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp,
),
) {
state.categories.forEach { section ->
item {
Text(
ShonarSectionLabel(
section.name,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 2.dp),
)
}
section.items.forEach { row ->
item(key = row.def.id) {
androidx.compose.material3.Card(
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
border = androidx.compose.foundation.BorderStroke(
1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
),
) {
SettingRowView(
row = row,
onValueChange = { jsonValue -> vm.setValue(row.def.id, jsonValue) },
onReset = { vm.reset(row.def.id) },
onDelete = if (row.def.custom) ({ pendingDelete = row.def }) else null,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
item { Spacer(Modifier.height(32.dp)) }
}
item { Spacer(Modifier.height(16.dp)) }
}
}

View file

@ -0,0 +1,211 @@
package com.shonar.ui.theme
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Shared presentational primitives matching the reference design.
* No business logic here — purely visual.
*/
// -- Search ---------------------------------------------------------------
@Composable
fun ShonarSearchField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
placeholder = { Text(placeholder, color = LocalShonarExtras.current.muted) },
leadingIcon = {
Icon(
Icons.Filled.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
),
modifier = modifier,
)
}
// -- Chips (sort / filter) -------------------------------------------------
@Composable
fun ShonarChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val cs = MaterialTheme.colorScheme
if (selected) {
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(50),
border = androidx.compose.foundation.BorderStroke(1.dp, cs.primary),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = cs.primary.copy(alpha = 0.16f),
contentColor = cs.primary,
),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
modifier = modifier.defaultMinSize(minHeight = 40.dp),
) { Text(label, style = MaterialTheme.typography.labelLarge) }
} else {
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(50),
border = androidx.compose.foundation.BorderStroke(1.dp, cs.outline),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = cs.surface,
contentColor = cs.onSurfaceVariant,
),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
modifier = modifier.defaultMinSize(minHeight = 40.dp),
) { Text(label, style = MaterialTheme.typography.labelLarge) }
}
}
// -- Status pill ("Saved" / "Ready") ---------------------------------------
@Composable
fun ShonarStatusPill(text: String, modifier: Modifier = Modifier) {
val extras = LocalShonarExtras.current
Row(
modifier = modifier
.border(1.dp, extras.success.copy(alpha = 0.5f), RoundedCornerShape(50))
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
Icons.Filled.Check,
contentDescription = null,
tint = extras.success,
modifier = Modifier.size(14.dp),
)
Text(
text,
style = MaterialTheme.typography.labelMedium,
color = extras.success,
)
}
}
// -- List row shell: subtle border, selected = cyan border + left accent ----
@Composable
fun ShonarRowCard(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit,
) {
val cs = MaterialTheme.colorScheme
Card(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) cs.surfaceVariant else cs.surface,
),
border = androidx.compose.foundation.BorderStroke(
1.dp,
if (selected) cs.primary else cs.outline.copy(alpha = 0.55f),
),
modifier = modifier,
) {
Row(
modifier = Modifier.padding(ShonarSpacing.cardPad),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
}
// -- Small outline action (Rename-style) ------------------------------------
@Composable
fun ShonarOutlineAction(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: @Composable (() -> Unit)? = null,
destructive: Boolean = false,
) {
val cs = MaterialTheme.colorScheme
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(10.dp),
border = androidx.compose.foundation.BorderStroke(
1.dp,
if (destructive) cs.error.copy(alpha = 0.7f) else cs.primary.copy(alpha = 0.6f),
),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = if (destructive) cs.error else cs.onSurface,
),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp),
modifier = modifier.defaultMinSize(minHeight = 44.dp),
) {
icon?.invoke()
Text(label, style = MaterialTheme.typography.labelLarge)
}
}
// -- Section label -----------------------------------------------------------
@Composable
fun ShonarSectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = modifier,
)
}
// -- Destructive text button (dialogs, inline) -------------------------------
@Composable
fun ShonarDestructiveTextButton(label: String, onClick: () -> Unit, enabled: Boolean = true) {
TextButton(onClick = onClick, enabled = enabled) {
Text(label, color = MaterialTheme.colorScheme.error)
}
}

View file

@ -14,36 +14,81 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
import com.shonar.settings.SettingsManager
// SHONAR palette — deep-sea sonar: dark navy + teal ping.
// SHONAR design system — deep-sea sonar: dark navy + teal ping.
//
// Reference: dark premium interface, deep navy/near-black backgrounds,
// cyan/teal accent, subtle borders, controlled radii, clean hierarchy.
// This is the SINGLE source of truth for color/shape/spacing. Screens
// must use these tokens (via MaterialTheme + ShonarExtras) instead of
// hard-coded colors.
private val Teal = Color(0xFF4FD1C5)
private val TealBright = Color(0xFF5EEAD4)
private val TealDark = Color(0xFF2A9D94)
private val DeepNavy = Color(0xFF0B1220)
private val NavySurface = Color(0xFF121C2E)
private val DeepNavy = Color(0xFF070D18)
private val NavySurface = Color(0xFF0F1A2B)
private val NavyElevated = Color(0xFF16233A)
private val Ink = Color(0xFF10141A)
// Semantic tokens (dark). Light scheme derives lighter variants below.
private val BorderDark = Color(0xFF22364F)
private val BorderStrongDark = Color(0xFF2DD4BF)
private val TextPrimaryDark = Color(0xFFE6EDF3)
private val TextSecondaryDark = Color(0xFF9FB3C8)
private val TextMutedDark = Color(0xFF6B7E94)
private val SuccessDark = Color(0xFF34D399)
private val SuccessBgDark = Color(0xFF0B2E26)
private val WarningDark = Color(0xFFF6C44C)
private val ErrorDark = Color(0xFFFF6B6B)
private fun schemeFor(accent: Color, dark: Boolean) = if (dark) {
darkColorScheme(
primary = accent,
onPrimary = if (accent.luminance() > 0.4) Ink else Color.White,
secondary = accent.copy(alpha = 0.8f),
tertiary = SuccessDark,
// FABs (the mic button) paint with primaryContainer, not primary —
// derive it from the accent so accent_color actually changes them.
primaryContainer = lerp(NavySurface, accent, 0.35f),
primaryContainer = lerp(NavyElevated, accent, 0.28f),
onPrimaryContainer = if (accent.luminance() > 0.4) Ink else Color.White,
background = DeepNavy,
surface = NavySurface,
onBackground = Color(0xFFE6EDF3),
onSurface = Color(0xFFE6EDF3),
surfaceVariant = NavyElevated,
surfaceContainer = NavySurface,
surfaceContainerHigh = NavyElevated,
onBackground = TextPrimaryDark,
onSurface = TextPrimaryDark,
onSurfaceVariant = TextSecondaryDark,
outline = BorderDark,
outlineVariant = BorderDark.copy(alpha = 0.6f),
error = ErrorDark,
errorContainer = Color(0xFF3A1414),
onErrorContainer = Color(0xFFFFB4AB),
secondaryContainer = NavyElevated,
onSecondaryContainer = TextSecondaryDark,
)
} else {
lightColorScheme(
primary = accent,
onPrimary = if (accent.luminance() > 0.4) Ink else Color.White,
secondary = accent.copy(alpha = 0.8f),
primary = TealDark,
onPrimary = Color.White,
secondary = TealDark.copy(alpha = 0.85f),
tertiary = Color(0xFF0E7C6B),
primaryContainer = lerp(Color.White, accent, 0.35f),
onPrimaryContainer = if (accent.luminance() > 0.4) Ink else Color.White,
onPrimaryContainer = Ink,
background = Color(0xFFF4F7FA),
surface = Color.White,
surfaceVariant = Color(0xFFEAF0F6),
onBackground = Ink,
onSurface = Ink,
onSurfaceVariant = Color(0xFF4A5B70),
outline = Color(0xFFC9D5E2),
outlineVariant = Color(0xFFDCE5EF),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC),
secondaryContainer = Color(0xFFEAF0F6),
onSecondaryContainer = Color(0xFF3A4A5E),
)
}
@ -71,11 +116,33 @@ fun ShonarTheme(
}
}
val dark = darkOverride ?: isSystemInDarkTheme()
val extras = if (dark) {
ShonarExtras(
success = SuccessDark,
onSuccess = TextPrimaryDark,
successContainer = SuccessBgDark,
borderStrong = accent,
muted = TextMutedDark,
)
} else {
ShonarExtras(
success = Color(0xFF0E7C6B),
onSuccess = Color.White,
successContainer = Color(0xFFD7F2E5),
borderStrong = TealDark,
muted = Color(0xFF6B7E94),
)
}
androidx.compose.runtime.CompositionLocalProvider(
LocalShonarExtras provides extras,
) {
MaterialTheme(
colorScheme = schemeFor(accent, dark),
shapes = ShonarShapes,
content = content,
)
}
}
/** Parse "#RRGGBB"/"#AARRGGBB" (validated by SettingsManager); fallback teal. */
fun parseColor(hex: String, fallback: Color = Teal): Color {
@ -87,3 +154,49 @@ fun parseColor(hex: String, fallback: Color = Teal): Color {
else -> fallback
}
}
// ---------------------------------------------------------------------------
// Shared tokens: shape / spacing / extra semantic colors.
// ---------------------------------------------------------------------------
/** Controlled radii: cards 12dp, controls 10dp, chips pill. No excessive rounding. */
val ShonarShapes: androidx.compose.material3.Shapes
@Composable
get() = MaterialTheme.shapes.copy(
extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
small = androidx.compose.foundation.shape.RoundedCornerShape(10.dp),
medium = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
large = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
extraLarge = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
)
/** Extra semantic colors that Material3 ColorScheme has no slot for. */
@androidx.compose.runtime.Immutable
data class ShonarExtras(
val success: Color,
val onSuccess: Color,
val successContainer: Color,
val borderStrong: Color,
val muted: Color,
)
val LocalShonarExtras: androidx.compose.runtime.ProvidableCompositionLocal<ShonarExtras> =
androidx.compose.runtime.staticCompositionLocalOf {
ShonarExtras(
success = SuccessDark,
onSuccess = TextPrimaryDark,
successContainer = SuccessBgDark,
borderStrong = BorderStrongDark,
muted = TextMutedDark,
)
}
/** Spacing scale used consistently across screens. */
object ShonarSpacing {
val screenH = 16.dp
val section = 20.dp
val cardPad = 14.dp
val gap = 12.dp
val tight = 8.dp
val listGap = 10.dp
}