Reskin to dark-navy + cyan design system; tidy recording rows, settings access on top
This commit is contained in:
parent
9c866c858b
commit
47f7cb9336
5 changed files with 986 additions and 170 deletions
|
|
@ -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) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = { editing = true }) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = "Rename")
|
||||
}
|
||||
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,
|
||||
color = cs.onBackground,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
|
||||
maxLines = 2,
|
||||
)
|
||||
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,
|
||||
) {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
if (ready.playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (ready.playing) "Pause" else "Play",
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Slider(
|
||||
value = ready.positionMs.toFloat(),
|
||||
onValueChange = { onSeek(it.toLong()) },
|
||||
valueRange = 0f..ready.durationMs.coerceAtLeast(1).toFloat(),
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
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),
|
||||
) {
|
||||
Text(formatMs(ready.positionMs), style = MaterialTheme.typography.bodySmall)
|
||||
Text(formatMs(ready.durationMs), style = MaterialTheme.typography.bodySmall)
|
||||
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).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,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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,38 +229,66 @@ 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = recordings,
|
||||
key = { it.id },
|
||||
) { recording ->
|
||||
RecordingRow(
|
||||
recording = recording,
|
||||
playing = playingId == recording.id,
|
||||
onPlay = { vm.togglePlayback(recording) },
|
||||
onDelete = { vm.delete(recording) },
|
||||
onOpen = { onOpenDetail(recording.id) },
|
||||
onRename = { vm.rename(recording, it) },
|
||||
)
|
||||
if (visible.isEmpty()) {
|
||||
item {
|
||||
EmptyState(text = "No recordings match “$query”.")
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
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) },
|
||||
onOpen = { onOpenDetail(recording.id) },
|
||||
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 (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(
|
||||
if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
if (paused) "Paused — resume or finish when ready."
|
||||
else "Capturing audio. Pause, finish, or discard below.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = cs.onSurfaceVariant,
|
||||
)
|
||||
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") }
|
||||
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()) {
|
||||
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(),
|
||||
) {
|
||||
if (selected) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.background(cs.primary),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(14.dp),
|
||||
Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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,
|
||||
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),
|
||||
)
|
||||
}
|
||||
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",
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
Text(
|
||||
msg,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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) {
|
||||
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)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(32.dp)) }
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
211
android/app/src/main/java/com/shonar/ui/theme/Components.kt
Normal file
211
android/app/src/main/java/com/shonar/ui/theme/Components.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,10 +116,32 @@ fun ShonarTheme(
|
|||
}
|
||||
}
|
||||
val dark = darkOverride ?: isSystemInDarkTheme()
|
||||
MaterialTheme(
|
||||
colorScheme = schemeFor(accent, dark),
|
||||
content = content,
|
||||
)
|
||||
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. */
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue