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
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Edit
|
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.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -35,10 +46,14 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.shonar.ShonarApplication
|
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,
|
* 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
|
@Composable
|
||||||
fun DetailsScreen(recordingId: String, onBack: () -> Unit) {
|
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 vm: DetailsViewModel = viewModel(factory = DetailsViewModel.factory(app, recordingId))
|
||||||
val uiState by vm.state.collectAsState()
|
val uiState by vm.state.collectAsState()
|
||||||
|
|
||||||
Scaffold { padding ->
|
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
when (val s = uiState) {
|
when (val s = uiState) {
|
||||||
|
|
@ -77,17 +92,27 @@ fun DetailsScreen(recordingId: String, onBack: () -> Unit) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) {
|
private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) {
|
||||||
|
val cs = MaterialTheme.colorScheme
|
||||||
var editing by remember { mutableStateOf(false) }
|
var editing by remember { mutableStateOf(false) }
|
||||||
var draft by remember(title) { mutableStateOf(title) }
|
var draft by remember(title) { mutableStateOf(title) }
|
||||||
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
Box(
|
||||||
Text(
|
modifier = Modifier
|
||||||
title,
|
.size(40.dp)
|
||||||
style = MaterialTheme.typography.titleLarge,
|
.background(cs.primary.copy(alpha = 0.14f), RoundedCornerShape(10.dp))
|
||||||
modifier = Modifier.weight(1f),
|
.border(1.dp, cs.primary.copy(alpha = 0.45f), RoundedCornerShape(10.dp)),
|
||||||
)
|
contentAlignment = Alignment.Center,
|
||||||
IconButton(onClick = { editing = true }) {
|
) {
|
||||||
Icon(Icons.Filled.Edit, contentDescription = "Rename")
|
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) {
|
if (editing) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|
@ -102,7 +127,10 @@ private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
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") } },
|
dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } },
|
||||||
)
|
)
|
||||||
|
|
@ -111,8 +139,10 @@ private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) {
|
private fun ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) {
|
||||||
PlaybackCard(ready = ready, onToggle = vm::togglePlayback,
|
PlaybackCard(
|
||||||
onSeek = vm::seekTo, onSpeed = vm::cycleSpeed)
|
ready = ready, onToggle = vm::togglePlayback,
|
||||||
|
onSeek = vm::seekTo, onSpeed = vm::cycleSpeed,
|
||||||
|
)
|
||||||
ready.playbackError?.let {
|
ready.playbackError?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error,
|
Text(it, color = MaterialTheme.colorScheme.error,
|
||||||
style = MaterialTheme.typography.bodySmall)
|
style = MaterialTheme.typography.bodySmall)
|
||||||
|
|
@ -131,41 +161,106 @@ private fun PlaybackCard(
|
||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onSpeed: () -> Unit,
|
onSpeed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
val cs = MaterialTheme.colorScheme
|
||||||
Row(
|
Card(
|
||||||
Modifier.fillMaxWidth().padding(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
colors = CardDefaults.cardColors(containerColor = cs.surfaceVariant),
|
||||||
) {
|
border = androidx.compose.foundation.BorderStroke(
|
||||||
IconButton(onClick = onToggle) {
|
1.dp,
|
||||||
Icon(
|
if (ready.playing) cs.primary else cs.outline.copy(alpha = 0.55f),
|
||||||
if (ready.playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
),
|
||||||
contentDescription = if (ready.playing) "Pause" else "Play",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
) {
|
||||||
}
|
if (ready.playing) {
|
||||||
Column(Modifier.weight(1f)) {
|
Box(Modifier.fillMaxWidth().height(2.dp).background(cs.primary))
|
||||||
Slider(
|
}
|
||||||
value = ready.positionMs.toFloat(),
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
onValueChange = { onSeek(it.toLong()) },
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
valueRange = 0f..ready.durationMs.coerceAtLeast(1).toFloat(),
|
IconButton(
|
||||||
)
|
onClick = onToggle,
|
||||||
Row(
|
modifier = Modifier
|
||||||
Modifier.fillMaxWidth(),
|
.size(56.dp)
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
.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)
|
Icon(
|
||||||
Text(formatMs(ready.durationMs), style = MaterialTheme.typography.bodySmall)
|
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
|
@Composable
|
||||||
private fun InfoCard(ready: DetailsViewModel.Ready) {
|
private fun InfoCard(ready: DetailsViewModel.Ready) {
|
||||||
|
val cs = MaterialTheme.colorScheme
|
||||||
val r = ready.recording
|
val r = ready.recording
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
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("Length", formatMs(r.durationMs))
|
||||||
InfoRow("Size", formatBytes(r.sizeBytes))
|
InfoRow("Size", formatBytes(r.sizeBytes))
|
||||||
InfoRow("Recorded", ready.recordedLabel)
|
InfoRow("Recorded", ready.recordedLabel)
|
||||||
|
|
@ -176,19 +271,28 @@ private fun InfoCard(ready: DetailsViewModel.Ready) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InfoRow(label: String, value: String) {
|
private fun InfoRow(label: String, value: String) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(
|
||||||
Text(label, style = MaterialTheme.typography.labelMedium,
|
Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
Text(value, style = MaterialTheme.typography.bodyMedium)
|
) {
|
||||||
|
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
|
@Composable
|
||||||
private fun LoadingRow() {
|
private fun LoadingRow() {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth().padding(24.dp),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
) { CircularProgressIndicator() }
|
) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatMs(ms: Long): String {
|
private fun formatMs(ms: Long): String {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,12 @@ import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Mic
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
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.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
|
@ -45,14 +55,29 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.shonar.ShonarApplication
|
import com.shonar.ShonarApplication
|
||||||
|
import com.shonar.recording.RecordingEntity
|
||||||
|
import com.shonar.recording.RecordingSnapshot
|
||||||
import com.shonar.settings.BuiltInSettings
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private enum class SortMode { NAME, NEWEST, OLDEST }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home: record, local library, settings. Everything stays on this phone —
|
* Home: record, local library, settings. Everything stays on this phone —
|
||||||
* no accounts, no servers. File sync (if wanted) is the user's own tooling.
|
* no accounts, no servers. File sync (if wanted) is the user's own tooling.
|
||||||
* Includes the first-launch recording-consent notice which must be
|
* Includes the first-launch recording-consent notice which must be
|
||||||
* acknowledged before anything else.
|
* 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
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
|
|
@ -69,6 +94,8 @@ fun HomeScreen(
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
|
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
|
||||||
var storageOk 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
|
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||||
// Re-check file access on every return (user may have just flipped it
|
// Re-check file access on every return (user may have just flipped it
|
||||||
// on in system Settings).
|
// on in system Settings).
|
||||||
|
|
@ -110,12 +137,27 @@ fun HomeScreen(
|
||||||
.getOrDefault(true)
|
.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(
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (consentSeen == true) {
|
if (consentSeen == true) {
|
||||||
if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
if (!isRecording) {
|
||||||
startRecording()
|
startRecording()
|
||||||
} else {
|
} else {
|
||||||
vm.stop()
|
vm.stop()
|
||||||
|
|
@ -124,10 +166,13 @@ fun HomeScreen(
|
||||||
},
|
},
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
icon = { Icon(Icons.Filled.Mic, contentDescription = null) },
|
icon = {
|
||||||
text = {
|
Icon(
|
||||||
Text(if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) "Record" else "Stop")
|
if (isRecording) Icons.Filled.Pause else Icons.Filled.Mic,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
text = { Text(if (!isRecording) "Record" else "Stop") },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|
@ -135,17 +180,24 @@ fun HomeScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = ShonarSpacing.screenH),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(ShonarSpacing.listGap),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
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 {
|
item {
|
||||||
Text(
|
ShonarSearchField(
|
||||||
"Oral Notes and Audio Recorder — everything stays on this phone",
|
value = query,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
onValueChange = { query = it },
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
placeholder = "Search saved recordings…",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SortRow(
|
||||||
|
sort = sort,
|
||||||
|
onSort = { sort = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { StorageChip() }
|
item { StorageChip() }
|
||||||
|
|
@ -164,12 +216,7 @@ fun HomeScreen(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(8.dp)) }
|
if (isRecording) {
|
||||||
item {
|
|
||||||
QuickCard("Settings", "Appearance, custom settings",
|
|
||||||
Icons.Filled.Settings, onOpenSettings)
|
|
||||||
}
|
|
||||||
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
|
||||||
item {
|
item {
|
||||||
RecordingStatusCard(
|
RecordingStatusCard(
|
||||||
state = recorderState,
|
state = recorderState,
|
||||||
|
|
@ -182,38 +229,66 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
if (recordings.isNotEmpty()) {
|
if (recordings.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text("Your recordings", style = MaterialTheme.typography.titleLarge,
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth())
|
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 ->
|
notice?.let { msg ->
|
||||||
item {
|
item {
|
||||||
Text(msg, color = MaterialTheme.colorScheme.error,
|
Text(
|
||||||
|
msg, color = MaterialTheme.colorScheme.error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.fillMaxWidth())
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(
|
if (visible.isEmpty()) {
|
||||||
items = recordings,
|
item {
|
||||||
key = { it.id },
|
EmptyState(text = "No recordings match “$query”.")
|
||||||
) { recording ->
|
}
|
||||||
RecordingRow(
|
} else {
|
||||||
recording = recording,
|
items(
|
||||||
playing = playingId == recording.id,
|
items = visible,
|
||||||
onPlay = { vm.togglePlayback(recording) },
|
key = { it.id },
|
||||||
onDelete = { vm.delete(recording) },
|
) { recording ->
|
||||||
onOpen = { onOpenDetail(recording.id) },
|
RecordingRow(
|
||||||
onRename = { vm.rename(recording, it) },
|
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 {
|
} else {
|
||||||
item {
|
item {
|
||||||
Text(
|
EmptyState(text = "Your recordings will appear here and stay on this device.")
|
||||||
"Your recordings will appear here and stay on this device.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
@Composable
|
||||||
private fun RecordingStatusCard(
|
private fun RecordingStatusCard(
|
||||||
state: com.shonar.recording.RecordingSnapshot,
|
state: RecordingSnapshot,
|
||||||
onPause: () -> Unit,
|
onPause: () -> Unit,
|
||||||
onResume: () -> Unit,
|
onResume: () -> Unit,
|
||||||
onStop: () -> Unit,
|
onStop: () -> Unit,
|
||||||
onDiscard: () -> Unit,
|
onDiscard: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val cs = MaterialTheme.colorScheme
|
||||||
var confirmDiscard by remember { mutableStateOf(false) }
|
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)) {
|
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(
|
Text(
|
||||||
if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress",
|
if (paused) "Paused — resume or finish when ready."
|
||||||
style = MaterialTheme.typography.titleMedium,
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
TextButton(onClick = if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) onResume else onPause) {
|
ShonarOutlineAction(
|
||||||
Text(if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Resume" else "Pause")
|
label = if (paused) "Resume" else "Pause",
|
||||||
}
|
onClick = if (paused) onResume else onPause,
|
||||||
TextButton(onClick = onStop) { Text("Finish") }
|
)
|
||||||
|
ShonarOutlineAction(label = "Finish", onClick = onStop)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
TextButton(onClick = { confirmDiscard = true }) {
|
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
|
@Composable
|
||||||
private fun RecordingRow(
|
private fun RecordingRow(
|
||||||
recording: com.shonar.recording.RecordingEntity,
|
recording: RecordingEntity,
|
||||||
|
selected: Boolean,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
onRename: (String) -> Unit,
|
onRename: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val cs = MaterialTheme.colorScheme
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
var renaming 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(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(14.dp),
|
Modifier.fillMaxWidth().padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f)) {
|
Box(
|
||||||
Text(recording.title, style = MaterialTheme.typography.titleMedium)
|
modifier = Modifier
|
||||||
Text(
|
.size(44.dp)
|
||||||
"${formatDuration(recording.durationMs)} • ${formatBytes(recording.sizeBytes)}",
|
.background(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
if (selected) cs.primary.copy(alpha = 0.16f) else cs.surfaceVariant,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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") }
|
Spacer(Modifier.width(12.dp))
|
||||||
androidx.compose.material3.IconButton(onClick = { menu = true }) {
|
Column(Modifier.weight(1f)) {
|
||||||
androidx.compose.material3.Icon(
|
Text(
|
||||||
androidx.compose.material.icons.Icons.Filled.MoreVert,
|
recording.title,
|
||||||
contentDescription = "More actions",
|
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(
|
androidx.compose.material3.DropdownMenu(
|
||||||
|
|
@ -312,7 +587,10 @@ private fun RecordingRow(
|
||||||
)
|
)
|
||||||
androidx.compose.material3.DropdownMenuItem(
|
androidx.compose.material3.DropdownMenuItem(
|
||||||
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
|
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") } },
|
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 {
|
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))
|
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. */
|
/** Shown when the OS file grant is missing: library stays empty until then. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun StorageAccessCard(onGrant: () -> Unit) {
|
private fun StorageAccessCard(onGrant: () -> Unit) {
|
||||||
Card(colors = CardDefaults.cardColors(
|
Card(
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer)) {
|
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)) {
|
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,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer)
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
Text("SHONAR keeps its library in your Music folder. Allow all-files access, then come back.",
|
)
|
||||||
|
Text(
|
||||||
|
"SHONAR keeps its library in your Music folder. Allow all-files access, then come back.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer)
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
TextButton(onClick = onGrant) { Text("Open system settings") }
|
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. */
|
/** Fixed on-phone storage indicator — no selection, nothing leaves the phone. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun StorageChip() {
|
private fun StorageChip() {
|
||||||
Card(colors = CardDefaults.cardColors(
|
val cs = MaterialTheme.colorScheme
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
|
Card(
|
||||||
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
shape = RoundedCornerShape(50),
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
colors = CardDefaults.cardColors(containerColor = cs.surfaceVariant),
|
||||||
Icon(Icons.Filled.PhoneAndroid, contentDescription = null,
|
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),
|
modifier = Modifier.size(18.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer)
|
tint = cs.onSurfaceVariant,
|
||||||
|
)
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text("Stored: on this phone",
|
Text(
|
||||||
|
"Stored: on this phone",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
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
|
@Composable
|
||||||
private fun ConsentDialog(onAcknowledge: () -> Unit) {
|
private fun ConsentDialog(onAcknowledge: () -> Unit) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|
@ -421,22 +758,39 @@ private fun QuickCard(
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth(),
|
val cs = MaterialTheme.colorScheme
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
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(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(20.dp),
|
.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(icon, contentDescription = null,
|
Box(
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
Modifier
|
||||||
modifier = Modifier.size(32.dp))
|
.size(40.dp)
|
||||||
Spacer(Modifier.size(16.dp))
|
.background(cs.surfaceVariant, RoundedCornerShape(10.dp)),
|
||||||
Column {
|
contentAlignment = Alignment.Center,
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
) {
|
||||||
Text(subtitle, style = MaterialTheme.typography.bodySmall,
|
Icon(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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.ShonarApplication
|
||||||
import com.shonar.settings.SettingDefinition
|
import com.shonar.settings.SettingDefinition
|
||||||
import com.shonar.settings.SettingType
|
import com.shonar.settings.SettingType
|
||||||
|
import com.shonar.ui.theme.ShonarSectionLabel
|
||||||
import com.shonar.ui.theme.parseColor
|
import com.shonar.ui.theme.parseColor
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
@ -81,57 +82,90 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||||
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
TopAppBar(
|
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 = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
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")
|
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")
|
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")
|
Icon(Icons.Filled.Add, contentDescription = "Add custom setting")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
state.message?.let { msg ->
|
state.message?.let { msg ->
|
||||||
Text(
|
androidx.compose.material3.Card(
|
||||||
msg,
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||||
color = MaterialTheme.colorScheme.error,
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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),
|
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 ->
|
state.categories.forEach { section ->
|
||||||
item {
|
item {
|
||||||
Text(
|
ShonarSectionLabel(
|
||||||
section.name,
|
section.name,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
modifier = Modifier.padding(top = 12.dp, bottom = 2.dp),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
section.items.forEach { row ->
|
section.items.forEach { row ->
|
||||||
item(key = row.def.id) {
|
item(key = row.def.id) {
|
||||||
SettingRowView(
|
androidx.compose.material3.Card(
|
||||||
row = row,
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||||
onValueChange = { jsonValue -> vm.setValue(row.def.id, jsonValue) },
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
onReset = { vm.reset(row.def.id) },
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
onDelete = if (row.def.custom) ({ pendingDelete = row.def }) else null,
|
),
|
||||||
)
|
border = androidx.compose.foundation.BorderStroke(
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
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.Color
|
||||||
import androidx.compose.ui.graphics.lerp
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.shonar.settings.SettingsManager
|
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 Teal = Color(0xFF4FD1C5)
|
||||||
|
private val TealBright = Color(0xFF5EEAD4)
|
||||||
private val TealDark = Color(0xFF2A9D94)
|
private val TealDark = Color(0xFF2A9D94)
|
||||||
private val DeepNavy = Color(0xFF0B1220)
|
private val DeepNavy = Color(0xFF070D18)
|
||||||
private val NavySurface = Color(0xFF121C2E)
|
private val NavySurface = Color(0xFF0F1A2B)
|
||||||
|
private val NavyElevated = Color(0xFF16233A)
|
||||||
private val Ink = Color(0xFF10141A)
|
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) {
|
private fun schemeFor(accent: Color, dark: Boolean) = if (dark) {
|
||||||
darkColorScheme(
|
darkColorScheme(
|
||||||
primary = accent,
|
primary = accent,
|
||||||
onPrimary = if (accent.luminance() > 0.4) Ink else Color.White,
|
onPrimary = if (accent.luminance() > 0.4) Ink else Color.White,
|
||||||
secondary = accent.copy(alpha = 0.8f),
|
secondary = accent.copy(alpha = 0.8f),
|
||||||
|
tertiary = SuccessDark,
|
||||||
// FABs (the mic button) paint with primaryContainer, not primary —
|
// FABs (the mic button) paint with primaryContainer, not primary —
|
||||||
// derive it from the accent so accent_color actually changes them.
|
// 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,
|
onPrimaryContainer = if (accent.luminance() > 0.4) Ink else Color.White,
|
||||||
background = DeepNavy,
|
background = DeepNavy,
|
||||||
surface = NavySurface,
|
surface = NavySurface,
|
||||||
onBackground = Color(0xFFE6EDF3),
|
surfaceVariant = NavyElevated,
|
||||||
onSurface = Color(0xFFE6EDF3),
|
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 {
|
} else {
|
||||||
lightColorScheme(
|
lightColorScheme(
|
||||||
primary = accent,
|
primary = TealDark,
|
||||||
onPrimary = if (accent.luminance() > 0.4) Ink else Color.White,
|
onPrimary = Color.White,
|
||||||
secondary = accent.copy(alpha = 0.8f),
|
secondary = TealDark.copy(alpha = 0.85f),
|
||||||
|
tertiary = Color(0xFF0E7C6B),
|
||||||
primaryContainer = lerp(Color.White, accent, 0.35f),
|
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()
|
val dark = darkOverride ?: isSystemInDarkTheme()
|
||||||
MaterialTheme(
|
val extras = if (dark) {
|
||||||
colorScheme = schemeFor(accent, dark),
|
ShonarExtras(
|
||||||
content = content,
|
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. */
|
/** Parse "#RRGGBB"/"#AARRGGBB" (validated by SettingsManager); fallback teal. */
|
||||||
|
|
@ -87,3 +154,49 @@ fun parseColor(hex: String, fallback: Color = Teal): Color {
|
||||||
else -> fallback
|
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