diff --git a/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt b/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt index a19b32d..b9b29d4 100644 --- a/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt @@ -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 { diff --git a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt index 0edb0b8..566f49c 100644 --- a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt @@ -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(null) } var storageOk by remember { mutableStateOf(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, + ) } } } diff --git a/android/app/src/main/java/com/shonar/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/shonar/ui/settings/SettingsScreen.kt index c47f2cd..5240f69 100644 --- a/android/app/src/main/java/com/shonar/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/settings/SettingsScreen.kt @@ -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)) } } } diff --git a/android/app/src/main/java/com/shonar/ui/theme/Components.kt b/android/app/src/main/java/com/shonar/ui/theme/Components.kt new file mode 100644 index 0000000..18facbd --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/theme/Components.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/shonar/ui/theme/Theme.kt b/android/app/src/main/java/com/shonar/ui/theme/Theme.kt index 847f3b9..859fab9 100644 --- a/android/app/src/main/java/com/shonar/ui/theme/Theme.kt +++ b/android/app/src/main/java/com/shonar/ui/theme/Theme.kt @@ -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 = + 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 +}