feat: rename app to SatsAmp + appearance themes (violet/blue/green/orange accents, System/Dark/Light modes)

- project dir ~/Projects/RadAmp -> ~/Projects/SatsAmp; classes, strings, launcher label, UA strings renamed; applicationId + radamp.db kept so existing installs/data survive
- UiPrefs (SharedPreferences) + UiTokens: 4 accents x dark/light, every text/accent pair AA-verified (accentOnFill picks ink vs white per accent)
- legacy Premium* colors rewritten to LocalTokens.current reads; theme switches live, persists across restart
- Appearance dialog in Library top bar (swatches + System/Dark/Light), verified on-device: orange live-switch, light mode + persistence, folder-tab regression pass
This commit is contained in:
avi 2026-09-07 18:45:29 -05:00
commit 1b7c6febbb
1351 changed files with 1971 additions and 1663 deletions

View file

@ -9,18 +9,18 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".RadAmpApp"
android:name=".SatsAmpApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.RadAmp">
android:theme="@style/Theme.SatsAmp">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.RadAmp">
android:theme="@style/Theme.SatsAmp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View file

@ -1,7 +0,0 @@
package com.rada
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class RadAmpApp : Application()

View file

@ -0,0 +1,13 @@
package com.rada
import android.app.Application
import com.rada.ui.theme.UiPrefs
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class SatsAmpApp : Application() {
override fun onCreate() {
super.onCreate()
UiPrefs.init(this)
}
}

View file

@ -17,7 +17,7 @@ import com.rada.data.local.entity.SavedSongEntity
version = 2,
exportSchema = false,
)
abstract class RadAmpDatabase : RoomDatabase() {
abstract class SatsAmpDatabase : RoomDatabase() {
abstract fun savedSongDao(): SavedSongDao
abstract fun playlistDao(): PlaylistDao
@ -50,10 +50,10 @@ abstract class RadAmpDatabase : RoomDatabase() {
}
}
fun create(context: Context): RadAmpDatabase {
fun create(context: Context): SatsAmpDatabase {
return Room.databaseBuilder(
context.applicationContext,
RadAmpDatabase::class.java,
SatsAmpDatabase::class.java,
"radamp.db",
).addMigrations(MIGRATION_1_2).build()
}

View file

@ -49,7 +49,7 @@ class StreamFetcher @Inject constructor() {
}
private fun download(url: String): String {
val request = Request.Builder().url(url).header("User-Agent", "RadAmp/1.0").build()
val request = Request.Builder().url(url).header("User-Agent", "SatsAmp/1.0").build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch playlist: ${response.code}")
return response.body?.string().orEmpty()

View file

@ -1,7 +1,7 @@
package com.rada.di
import android.content.Context
import com.rada.data.local.RadAmpDatabase
import com.rada.data.local.SatsAmpDatabase
import com.rada.data.local.dao.PlaylistDao
import com.rada.data.local.dao.SavedSongDao
import com.rada.data.repository.LibraryRepository
@ -23,18 +23,18 @@ object AppModule {
@Singleton
fun provideDatabase(
@ApplicationContext context: Context,
): RadAmpDatabase = RadAmpDatabase.create(context)
): SatsAmpDatabase = SatsAmpDatabase.create(context)
@Provides
@Singleton
fun provideSavedSongDao(
database: RadAmpDatabase,
database: SatsAmpDatabase,
): SavedSongDao = database.savedSongDao()
@Provides
@Singleton
fun providePlaylistDao(
database: RadAmpDatabase,
database: SatsAmpDatabase,
): PlaylistDao = database.playlistDao()
@Provides

View file

@ -89,7 +89,7 @@ class IcyStreamReader(
val request = Request.Builder()
.url(url)
.header("Icy-MetaData", "1")
.header("User-Agent", "RadAmp/0.1")
.header("User-Agent", "SatsAmp/0.1")
.build()
val response = okHttpClient.newCall(request).execute()

View file

@ -42,7 +42,7 @@ class MetadataEmbedder(private val context: Context) {
val tag = audio.tag
tag.setField(FieldKey.ARTIST, artist.ifEmpty { "Unknown Artist" })
tag.setField(FieldKey.TITLE, title.ifEmpty { "Unknown Title" })
tag.setField(FieldKey.ALBUM, stationName.ifEmpty { "RadAmp" })
tag.setField(FieldKey.ALBUM, stationName.ifEmpty { "SatsAmp" })
tag.setField(FieldKey.GENRE, "Radio")
tag.setField(FieldKey.COMMENT, "Recorded from $stationName")

View file

@ -13,7 +13,7 @@ import androidx.navigation.compose.rememberNavController
import com.rada.player.PlaybackManager
import com.rada.ui.navigation.NavGraph
import com.rada.ui.theme.GlassBackground
import com.rada.ui.theme.RadAmpTheme
import com.rada.ui.theme.SatsAmpTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@ -28,7 +28,7 @@ class MainActivity : ComponentActivity() {
playbackManager.connect()
setContent {
RadAmpTheme {
SatsAmpTheme {
GlassBackground {
Surface(
modifier = Modifier.fillMaxSize(),

View file

@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -75,13 +76,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.SortOrder
import com.rada.ui.theme.PremiumBorder
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
import com.rada.ui.theme.AppearanceDialog
import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -97,6 +93,7 @@ fun LibraryScreen(
var showSearch by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
var showImportMenu by remember { mutableStateOf(false) }
var showAppearance by remember { mutableStateOf(false) }
var playlistToDelete by remember { mutableStateOf<PlaylistEntity?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
@ -154,7 +151,7 @@ fun LibraryScreen(
Icon(
Icons.Default.PlaylistAdd,
contentDescription = "Import songs",
tint = if (uiState.isImporting) PremiumTextSecondary.copy(alpha = 0.4f) else PremiumPurple,
tint = if (uiState.isImporting) LocalTokens.current.textSecondary.copy(alpha = 0.4f) else LocalTokens.current.accentFill,
)
}
DropdownMenu(
@ -189,11 +186,18 @@ fun LibraryScreen(
Icon(
Icons.Default.Delete,
contentDescription = "Delete folder",
tint = PremiumTextSecondary,
tint = LocalTokens.current.textSecondary,
)
}
}
}
IconButton(onClick = { showAppearance = true }) {
Icon(
Icons.Default.Settings,
contentDescription = "Appearance settings",
tint = LocalTokens.current.textSecondary,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
@ -214,12 +218,12 @@ fun LibraryScreen(
modifier = Modifier.fillMaxWidth(),
edgePadding = 16.dp,
containerColor = Color.Transparent,
contentColor = PremiumPurple,
contentColor = LocalTokens.current.accentFill,
indicator = { tabPositions ->
if (selectedTabIndex < tabPositions.size) {
TabRowDefaults.SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
color = PremiumPurple,
color = LocalTokens.current.accentFill,
)
}
},
@ -270,7 +274,7 @@ fun LibraryScreen(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (selectedTabIndex == index) PremiumPurple else PremiumTextSecondary,
color = if (selectedTabIndex == index) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
)
},
)
@ -293,16 +297,16 @@ fun LibraryScreen(
Icon(
Icons.Default.Search,
contentDescription = "Search",
tint = PremiumTextSecondary,
tint = LocalTokens.current.textSecondary,
)
},
colors = TextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedIndicatorColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedIndicatorColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedContainerColor = LocalTokens.current.panel,
unfocusedContainerColor = LocalTokens.current.panel,
focusedIndicatorColor = LocalTokens.current.accentFill.copy(alpha = 0.5f),
unfocusedIndicatorColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
shape = RoundedCornerShape(12.dp),
)
@ -321,7 +325,7 @@ fun LibraryScreen(
Text(
text = "Sort",
style = MaterialTheme.typography.labelMedium,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
)
SortChip("Recent", uiState.sortOrder == SortOrder.DATE_SAVED, uiState.sortAscending) {
viewModel.setSortOrder(SortOrder.DATE_SAVED)
@ -345,14 +349,14 @@ fun LibraryScreen(
Icons.Default.MusicNote,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = PremiumPurple.copy(alpha = 0.3f),
tint = LocalTokens.current.accentFill.copy(alpha = 0.3f),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (uiState.selectedPlaylistId != null) "Playlist is empty"
else "No saved songs yet — tap + to import songs from your device",
style = MaterialTheme.typography.bodyLarge,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
)
}
}
@ -376,6 +380,10 @@ fun LibraryScreen(
// Long-press a folder tab: clear choice between deleting just the group
// (songs stay in All Songs) or deleting its songs and files as well.
if (showAppearance) {
AppearanceDialog(onDismiss = { showAppearance = false })
}
playlistToDelete?.let { playlist ->
AlertDialog(
onDismissRequest = { playlistToDelete = null },
@ -387,7 +395,7 @@ fun LibraryScreen(
viewModel.deletePlaylist(playlist, deleteSongs = false)
playlistToDelete = null
}) {
Text("Delete group only", color = PremiumText)
Text("Delete group only", color = LocalTokens.current.text)
}
TextButton(onClick = {
viewModel.deletePlaylist(playlist, deleteSongs = true)
@ -399,7 +407,7 @@ fun LibraryScreen(
},
dismissButton = {
TextButton(onClick = { playlistToDelete = null }) {
Text("Cancel", color = PremiumTextSecondary)
Text("Cancel", color = LocalTokens.current.textSecondary)
}
},
)
@ -427,14 +435,14 @@ private fun SortChip(
} else null,
label = { Text(label) },
colors = FilterChipDefaults.filterChipColors(
containerColor = PremiumSurface,
labelColor = PremiumTextSecondary,
selectedContainerColor = PremiumPurple.copy(alpha = 0.22f),
selectedLabelColor = PremiumPurpleLight,
containerColor = LocalTokens.current.panel,
labelColor = LocalTokens.current.textSecondary,
selectedContainerColor = LocalTokens.current.accentFill.copy(alpha = 0.22f),
selectedLabelColor = LocalTokens.current.accentText,
),
border = FilterChipDefaults.filterChipBorder(
borderColor = PremiumBorder,
selectedBorderColor = PremiumPurple,
borderColor = LocalTokens.current.border,
selectedBorderColor = LocalTokens.current.accentFill,
enabled = true,
selected = selected,
),
@ -459,7 +467,7 @@ private fun SongListItem(
Icon(
Icons.Default.MusicNote,
contentDescription = null,
tint = if (isPlaying) PremiumPurple else PremiumTextSecondary,
tint = if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(16.dp))
@ -470,12 +478,12 @@ private fun SongListItem(
fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = PremiumText,
color = LocalTokens.current.text,
)
Text(
text = song.artist,
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -483,7 +491,7 @@ private fun SongListItem(
Text(
text = song.stationName,
style = MaterialTheme.typography.labelSmall,
color = PremiumTextSecondary.copy(alpha = 0.6f),
color = LocalTokens.current.textSecondary.copy(alpha = 0.6f),
)
}
}
@ -492,14 +500,14 @@ private fun SongListItem(
if (song.isFavorite) Icons.Default.Favorite
else Icons.Default.FavoriteBorder,
contentDescription = if (song.isFavorite) "Unfavorite" else "Favorite",
tint = if (song.isFavorite) PremiumPurple else PremiumTextSecondary,
tint = if (song.isFavorite) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = PremiumTextSecondary,
tint = LocalTokens.current.textSecondary,
)
}
}

View file

@ -40,17 +40,16 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.player.PlaybackManager
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.RadAmpGlass
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
import com.rada.ui.theme.LocalTokens
import com.rada.ui.theme.SatsAmpGlass
@Composable
fun NowPlayingBar(
onNavigateToPlayer: () -> Unit,
viewModel: NowPlayingViewModel = hiltViewModel(),
) {
val tokens = LocalTokens.current
val stationName by viewModel.currentStationName.collectAsState()
val isPlaying by viewModel.isPlaying.collectAsState()
val bufferState by viewModel.bufferState.collectAsState()
@ -65,11 +64,11 @@ fun NowPlayingBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(RadAmpGlass.rCard))
.clip(RoundedCornerShape(SatsAmpGlass.rCard))
// glass: textSafe 90% overlay + 6% white + highlight border
.background(RadAmpGlass.textSafe)
.background(RadAmpGlass.surface)
.border(1.dp, RadAmpGlass.border, RoundedCornerShape(RadAmpGlass.rCard))
.background(tokens.textSafe)
.background(tokens.glassFill)
.border(1.dp, tokens.border, RoundedCornerShape(SatsAmpGlass.rCard))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@ -86,8 +85,8 @@ fun NowPlayingBar(
modifier = Modifier
.fillMaxWidth()
.height(2.dp),
color = PremiumPurple,
trackColor = PremiumSurface,
color = LocalTokens.current.accentFill,
trackColor = LocalTokens.current.panel,
)
Row(
@ -101,7 +100,7 @@ fun NowPlayingBar(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(14.dp))
.background(PremiumPurple),
.background(LocalTokens.current.accentFill),
contentAlignment = Alignment.Center,
) {
Icon(
@ -127,7 +126,7 @@ fun NowPlayingBar(
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = PremiumText,
color = LocalTokens.current.text,
)
}
IconButton(
@ -137,7 +136,7 @@ fun NowPlayingBar(
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = PremiumPurple,
tint = LocalTokens.current.accentFill,
modifier = Modifier.size(28.dp),
)
}

View file

@ -89,17 +89,10 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.Station
import com.rada.player.PlaybackManager
import com.rada.ui.theme.LocalTokens
import com.rada.ui.theme.GradientEnd
import com.rada.ui.theme.GradientMid
import com.rada.ui.theme.GradientStart
import com.rada.ui.theme.PremiumCyan
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
import com.rada.ui.theme.PremiumBorder
@Composable
fun PlayerScreen(
@ -195,7 +188,7 @@ fun PlayerScreen(
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
BottomAppBar(
containerColor = PremiumSurface.copy(alpha = 0.94f),
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
contentColor = MaterialTheme.colorScheme.onSurface,
) {
NavigationBarItem(
@ -287,7 +280,7 @@ private fun EmptyStateView(
text = "Find your sound",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = PremiumText,
color = LocalTokens.current.text,
textAlign = TextAlign.Center,
)
@ -296,7 +289,7 @@ private fun EmptyStateView(
Text(
text = "Choose a station or add music\nto start listening",
style = MaterialTheme.typography.bodyLarge,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
textAlign = TextAlign.Center,
lineHeight = 24.sp,
)
@ -310,7 +303,7 @@ private fun EmptyStateView(
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PremiumPurple,
containerColor = LocalTokens.current.accentFill,
),
) {
Icon(
@ -335,7 +328,7 @@ private fun EmptyStateView(
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = PremiumPurpleLight,
contentColor = LocalTokens.current.accentText,
),
) {
Icon(
@ -355,7 +348,8 @@ private fun EmptyStateView(
@Composable
private fun WaveGraphic(modifier: Modifier = Modifier) {
val gradientColors = listOf(GradientStart, GradientMid, GradientEnd)
val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd)
val accentGlow = LocalTokens.current.accentFill
Canvas(modifier = modifier) {
val width = size.width
@ -397,7 +391,7 @@ private fun WaveGraphic(modifier: Modifier = Modifier) {
// Draw subtle glow at center
drawCircle(
brush = Brush.radialGradient(
colors = listOf(PremiumPurple.copy(alpha = 0.15f), Color.Transparent),
colors = listOf(accentGlow.copy(alpha = 0.15f), Color.Transparent),
center = Offset(width / 2, centerY),
radius = width / 3,
),
@ -583,12 +577,12 @@ private fun PlaylistPickerDialog(
Icon(Icons.Default.Search, contentDescription = "Search")
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedContainerColor = LocalTokens.current.panel,
unfocusedContainerColor = LocalTokens.current.panel,
focusedBorderColor = LocalTokens.current.accentFill.copy(alpha = 0.5f),
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
shape = RoundedCornerShape(12.dp),
)
@ -617,7 +611,7 @@ private fun PlaylistPickerDialog(
Text(
text = playlist.name,
style = MaterialTheme.typography.bodyLarge,
color = PremiumText,
color = LocalTokens.current.text,
)
}
}
@ -626,14 +620,14 @@ private fun PlaylistPickerDialog(
Text(
text = "No matching playlists",
style = MaterialTheme.typography.bodyMedium,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
Text(
text = "No playlists yet. Create one below.",
style = MaterialTheme.typography.bodyMedium,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
modifier = Modifier.padding(vertical = 8.dp),
)
}
@ -648,10 +642,10 @@ private fun PlaylistPickerDialog(
label = { Text("Playlist name") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedBorderColor = LocalTokens.current.accentFill,
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
)
Spacer(modifier = Modifier.height(8.dp))
@ -701,7 +695,7 @@ private fun SaveSongButton(
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
saveState == PlaybackManager.SaveState.ERROR -> MaterialTheme.colorScheme.error
canSave -> PremiumPurple
canSave -> LocalTokens.current.accentFill
else -> MaterialTheme.colorScheme.surfaceVariant
}

View file

@ -57,13 +57,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.local.entity.PlaylistEntity
import com.rada.ui.theme.PremiumBorder
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -176,12 +170,12 @@ fun PlaylistManagerScreen(
Icon(Icons.Default.Search, contentDescription = "Search")
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedContainerColor = LocalTokens.current.panel,
unfocusedContainerColor = LocalTokens.current.panel,
focusedBorderColor = LocalTokens.current.accentFill.copy(alpha = 0.5f),
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
shape = RoundedCornerShape(12.dp),
)
@ -194,22 +188,22 @@ fun PlaylistManagerScreen(
DropdownMenuItem(
text = { Text("Name A–Z") },
onClick = { viewModel.setSortOrder(PlaylistSortOrder.NAME_ASC); showSortMenu = false },
leadingIcon = { if (sortOrder == PlaylistSortOrder.NAME_ASC) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
leadingIcon = { if (sortOrder == PlaylistSortOrder.NAME_ASC) Icon(Icons.Default.Search, null, tint = LocalTokens.current.accentFill) else null },
)
DropdownMenuItem(
text = { Text("Name Z–A") },
onClick = { viewModel.setSortOrder(PlaylistSortOrder.NAME_DESC); showSortMenu = false },
leadingIcon = { if (sortOrder == PlaylistSortOrder.NAME_DESC) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
leadingIcon = { if (sortOrder == PlaylistSortOrder.NAME_DESC) Icon(Icons.Default.Search, null, tint = LocalTokens.current.accentFill) else null },
)
DropdownMenuItem(
text = { Text("Newest first") },
onClick = { viewModel.setSortOrder(PlaylistSortOrder.DATE_NEWEST); showSortMenu = false },
leadingIcon = { if (sortOrder == PlaylistSortOrder.DATE_NEWEST) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
leadingIcon = { if (sortOrder == PlaylistSortOrder.DATE_NEWEST) Icon(Icons.Default.Search, null, tint = LocalTokens.current.accentFill) else null },
)
DropdownMenuItem(
text = { Text("Oldest first") },
onClick = { viewModel.setSortOrder(PlaylistSortOrder.DATE_OLDEST); showSortMenu = false },
leadingIcon = { if (sortOrder == PlaylistSortOrder.DATE_OLDEST) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
leadingIcon = { if (sortOrder == PlaylistSortOrder.DATE_OLDEST) Icon(Icons.Default.Search, null, tint = LocalTokens.current.accentFill) else null },
)
}
}
@ -224,13 +218,13 @@ fun PlaylistManagerScreen(
Icons.AutoMirrored.Filled.PlaylistPlay,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = PremiumPurple.copy(alpha = 0.3f),
tint = LocalTokens.current.accentFill.copy(alpha = 0.3f),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (searchQuery.isNotBlank()) "No matching playlists" else "No playlists yet",
style = MaterialTheme.typography.bodyLarge,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(
@ -287,7 +281,7 @@ private fun PlaylistListItem(
Icon(
Icons.AutoMirrored.Filled.PlaylistPlay,
contentDescription = null,
tint = PremiumPurple,
tint = LocalTokens.current.accentFill,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(16.dp))
@ -296,14 +290,14 @@ private fun PlaylistListItem(
text = playlist.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = PremiumText,
color = LocalTokens.current.text,
)
}
IconButton(onClick = onRename) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = PremiumTextSecondary,
tint = LocalTokens.current.textSecondary,
)
}
IconButton(onClick = onDelete) {
@ -334,10 +328,10 @@ private fun CreatePlaylistDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedBorderColor = LocalTokens.current.accentFill,
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
)
},
@ -376,10 +370,10 @@ private fun RenamePlaylistDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedBorderColor = LocalTokens.current.accentFill,
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
)
},

View file

@ -52,13 +52,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.Station
import com.rada.ui.theme.PremiumBorder
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
import com.rada.ui.theme.LocalTokens
private val genreColors = mapOf(
"Lofi" to 0xFF7C3AED,
@ -154,12 +148,12 @@ fun StationBrowserScreen(
Icon(Icons.Default.Search, contentDescription = "Search")
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedContainerColor = LocalTokens.current.panel,
unfocusedContainerColor = LocalTokens.current.panel,
focusedBorderColor = LocalTokens.current.accentFill.copy(alpha = 0.5f),
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
shape = RoundedCornerShape(12.dp),
)
@ -222,10 +216,10 @@ private fun AddStationDialog(
label = { Text("Station name") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedBorderColor = LocalTokens.current.accentFill,
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
)
Spacer(modifier = Modifier.height(8.dp))
@ -237,10 +231,10 @@ private fun AddStationDialog(
placeholder = { Text("https://...") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedBorderColor = LocalTokens.current.accentFill,
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
)
}
@ -291,10 +285,10 @@ private fun RenameStationDialog(
label = { Text("Station name") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedBorderColor = LocalTokens.current.accentFill,
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
)
if (isRenamed && originalName != null) {
@ -302,7 +296,7 @@ private fun RenameStationDialog(
Text(
text = "Renamed from \"$originalName\"",
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
)
}
}
@ -360,7 +354,7 @@ private fun GenreHeader(genre: String, count: Int) {
Text(
text = "$count",
style = MaterialTheme.typography.labelMedium,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
)
}
}
@ -379,7 +373,7 @@ private fun StationCard(
.padding(horizontal = 12.dp)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = PremiumSurfaceVariant,
containerColor = LocalTokens.current.glassFillStrong,
),
shape = RoundedCornerShape(12.dp),
) {
@ -393,13 +387,13 @@ private fun StationCard(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(PremiumPurple.copy(alpha = 0.15f)),
.background(LocalTokens.current.accentFill.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = PremiumPurpleLight,
tint = LocalTokens.current.accentText,
modifier = Modifier.size(20.dp),
)
}
@ -409,13 +403,13 @@ private fun StationCard(
text = station.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = PremiumText,
color = LocalTokens.current.text,
)
if (station.genre.isNotEmpty()) {
Text(
text = station.genre,
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
)
}
}
@ -423,7 +417,7 @@ private fun StationCard(
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = PremiumTextSecondary,
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)
}
@ -432,7 +426,7 @@ private fun StationCard(
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
tint = PremiumTextSecondary,
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)
}

View file

@ -55,13 +55,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.StreamEntry
import com.rada.ui.theme.PremiumBorder
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -140,12 +134,12 @@ fun StreamsScreen(
Icon(Icons.Default.Search, contentDescription = "Search")
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedContainerColor = LocalTokens.current.panel,
unfocusedContainerColor = LocalTokens.current.panel,
focusedBorderColor = LocalTokens.current.accentFill.copy(alpha = 0.5f),
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
shape = RoundedCornerShape(12.dp),
)
@ -163,14 +157,14 @@ fun StreamsScreen(
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = PremiumPurple,
color = LocalTokens.current.accentFill,
)
Spacer(modifier = Modifier.width(10.dp))
}
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = if (error != null) Color(0xFFEF4444) else PremiumTextSecondary,
color = if (error != null) Color(0xFFEF4444) else LocalTokens.current.textSecondary,
)
}
}
@ -185,20 +179,20 @@ fun StreamsScreen(
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = PremiumTextSecondary,
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(48.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "No streams yet",
style = MaterialTheme.typography.titleMedium,
color = PremiumText,
color = LocalTokens.current.text,
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Tap + to add a stream URL or an .m3u playlist",
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
)
}
}
@ -262,10 +256,10 @@ private fun AddStreamDialog(
placeholder = { Text("https://example.com/stream.m3u") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedBorderColor = LocalTokens.current.accentFill,
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
)
Spacer(modifier = Modifier.height(8.dp))
@ -276,17 +270,17 @@ private fun AddStreamDialog(
label = { Text("Display name (optional)") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedBorderColor = LocalTokens.current.accentFill,
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Paste a direct stream URL, or an .m3u/.pls playlist to add all of its streams.",
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
)
}
},
@ -332,10 +326,10 @@ private fun RenameStreamDialog(
label = { Text("Display name") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
focusedBorderColor = LocalTokens.current.accentFill,
unfocusedBorderColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
)
},
@ -367,20 +361,20 @@ private fun GroupHeader(group: String, count: Int) {
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(PremiumPurple),
.background(LocalTokens.current.accentFill),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = group.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = PremiumPurpleLight,
color = LocalTokens.current.accentText,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$count",
style = MaterialTheme.typography.labelMedium,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
)
}
}
@ -399,7 +393,7 @@ private fun StreamCard(
.padding(horizontal = 12.dp)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (isPlaying) PremiumPurple.copy(alpha = 0.12f) else PremiumSurfaceVariant,
containerColor = if (isPlaying) LocalTokens.current.accentFill.copy(alpha = 0.12f) else LocalTokens.current.glassFillStrong,
),
shape = RoundedCornerShape(12.dp),
) {
@ -413,13 +407,13 @@ private fun StreamCard(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(if (isPlaying) PremiumPurple else PremiumPurple.copy(alpha = 0.15f)),
.background(if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.accentFill.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(
if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = null,
tint = if (isPlaying) Color.White else PremiumPurpleLight,
tint = if (isPlaying) Color.White else LocalTokens.current.accentText,
modifier = Modifier.size(22.dp),
)
}
@ -429,12 +423,12 @@ private fun StreamCard(
text = entry.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = PremiumText,
color = LocalTokens.current.text,
)
Text(
text = entry.url,
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
color = LocalTokens.current.textSecondary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
@ -443,7 +437,7 @@ private fun StreamCard(
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = PremiumTextSecondary,
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)
}
@ -451,7 +445,7 @@ private fun StreamCard(
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
tint = PremiumTextSecondary,
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)
}

View file

@ -0,0 +1,155 @@
package com.rada.ui.theme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Static swatch preview colors for the picker (independent of active theme). */
internal val AccentSwatches = mapOf(
AccentMode.VIOLET to Color(0xFF7C4DFF),
AccentMode.BLUE to Color(0xFF4C7DFF),
AccentMode.GREEN to Color(0xFF34C759),
AccentMode.ORANGE to Color(0xFFF97316),
)
/**
* Appearance sheet: accent color (violet/blue/green/orange) + brightness
* (System / Dark "black+white" / Light "white+black"). Persists via UiPrefs;
* the whole UI recomposes live through LocalTokens.
*/
@Composable
fun AppearanceDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val tokens = LocalTokens.current
val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark()
AlertDialog(
onDismissRequest = onDismiss,
containerColor = tokens.panel,
titleContentColor = tokens.text,
textContentColor = tokens.textSecondary,
shape = RoundedCornerShape(SatsAmpGlass.rCard),
title = { Text("Appearance", fontWeight = FontWeight.Bold) },
text = {
Column {
Text(
"ACCENT",
fontSize = 12.sp,
letterSpacing = 0.4.sp,
fontWeight = FontWeight.SemiBold,
color = tokens.textMuted,
)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
AccentMode.entries.forEach { mode ->
val selected = UiPrefs.accent == mode
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(AccentSwatches.getValue(mode))
.then(
if (selected) Modifier.border(2.dp, tokens.text, CircleShape)
else Modifier.border(1.dp, tokens.borderInteractive, CircleShape)
)
.clickable { UiPrefs.setAccent(mode, context) },
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
Icons.Default.Check,
contentDescription = "${mode.label} accent selected",
tint = if (mode == AccentMode.GREEN || mode == AccentMode.ORANGE) Color(0xFF141419) else Color.White,
modifier = Modifier.size(20.dp),
)
}
}
}
}
Spacer(Modifier.height(22.dp))
Text(
"MODE",
fontSize = 12.sp,
letterSpacing = 0.4.sp,
fontWeight = FontWeight.SemiBold,
color = tokens.textMuted,
)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
AppMode.entries.forEach { mode ->
val selected = UiPrefs.mode == mode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(SatsAmpGlass.rInput))
.background(if (selected) tokens.accentSoft else Color.Transparent)
.border(
1.dp,
if (selected) tokens.accentFill else tokens.borderInteractive,
RoundedCornerShape(SatsAmpGlass.rInput),
)
.clickable { UiPrefs.setMode(mode, context) }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
mode.label,
fontSize = 13.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
color = if (selected) tokens.accentText else tokens.textSecondary,
)
}
}
}
Spacer(Modifier.height(14.dp))
Text(
when (UiPrefs.mode) {
AppMode.LIGHT -> if (lightMode) "White background, black text." else "Applied after reopen if the system keeps dark mode."
AppMode.DARK -> "Black background, white text."
AppMode.SYSTEM -> "Follows your system dark-mode setting."
},
fontSize = 12.sp,
color = tokens.textMuted,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold)
}
},
)
}
private fun Color.luminanceIsDark(): Boolean {
fun f(c: Float) = if (c <= 0.04045f) c / 12.92f else Math.pow(((c + 0.055) / 1.055).toDouble(), 2.4).toFloat()
val l = 0.2126f * f(red) + 0.7152f * f(green) + 0.0722f * f(blue)
return l < 0.45f
}

View file

@ -1,46 +1,32 @@
package com.rada.ui.theme
import com.rada.ui.theme.LocalTokens
import androidx.compose.ui.graphics.Color
// ── RadAmp Glass palette (see design/glass-radio-theme.css for tokens + WCAG data) ──
// Base: flat dark charcoal gradient #23232B → #1B1B22
val PremiumBlack = Color(0xFF1B1B22) // base bottom
val PremiumDark = Color(0xFF23232B) // base top
val PremiumSurface = Color(0xFF202029) // solid panel (~90% opaque base)
val PremiumSurfaceVariant = Color(0xFF262631)
val PremiumBorder = Color(0x1AFFFFFF) // glass border 10% white (decorative)
// Interactive borders need 3:1 (WCAG 1.4.11): 36% white over glass
val PremiumBorderInteractive = Color(0x5CFFFFFF)
// ── SatsAmp fixed palette ──
// User-configurable colors live in UiTokens (Tokens.kt) and are read via
// LocalTokens.current at call sites. Only mode-independent constants here.
val PremiumPurple = Color(0xFF7C4DFF) // accent
val PremiumPurpleHover = Color(0xFF8E66FF)
val PremiumPurplePressed = Color(0xFF6A3DE0)
val PremiumPurpleSoft = Color(0x297C4DFF) // accent soft fill 16% — chips/active
val PremiumPurpleLight = Color(0xFFA78BFA)
// AA-safe accent TEXT on glass (7C4DFF is only 2.71:1 on glass)
val PremiumAccentText = Color(0xFFA78BFF)
val PremiumBlack = Color(0xFF1B1B22)
val BaseTop = Color(0xFF23232B)
val BaseBottom = Color(0xFF1B1B22)
val PremiumSurfaceSolid = Color(0xFF202029)
val PremiumError = Color(0xFFEF4444)
val PremiumWarning = Color(0xFFF59E0B)
// Fixed brand colors (not user-configurable)
val PremiumBlue = Color(0xFF3B82F6)
val PremiumCyan = Color(0xFF06B6D4)
val PremiumPink = Color(0xFFEC4899)
// Text tokens: all pairs ≥4.5:1 on base AND on glass (measured; see demo page)
val PremiumText = Color(0xFFFFFFFF) // 13.1:1 on glass
val PremiumTextSecondary = Color(0xFFD9D9DE) // 10.5:1 on glass (body 88% white)
val PremiumTextMuted = Color(0xFF8C8C96) // 5.1:1 on base (caption floor, never dimmer)
val PremiumSuccess = Color(0xFF34C759)
val PremiumError = Color(0xFFEF4444)
val PremiumWarning = Color(0xFFF59E0B)
// Gradient colors for accents
val GradientStart = Color(0xFF7C4DFF)
// Accent-following gradient stops resolve to the active accent at composition
val GradientMid = Color(0xFF3B82F6)
val GradientEnd = Color(0xFF06B6D4)
// Base background gradient (flat charcoal, very subtle)
val BaseTop = Color(0xFF23232B)
val BaseBottom = Color(0xFF1B1B22)
// Live indicator
val LiveGreen = Color(0xFF34C759)
// Legacy aliases (fixed violet) — for non-composable contexts only
val PremiumPurpleStatic = Color(0xFF7C4DFF)
// Legacy terminal palette (cliamp-inspired)
val TerminalBlack = Color(0xFF000000)
@ -64,7 +50,7 @@ val TerminalCyan = Color(0xFF00D4FF)
val TerminalMagenta = Color(0xFFFF55FF)
// Legacy aliases used by existing code
val SaveButton = PremiumSuccess
val SaveButtonDisabled = PremiumSurfaceVariant
val SaveConfirm = PremiumSuccess
val SaveButton = Color(0xFF34C759)
val SaveButtonDisabled = Color(0xFF262631)
val SaveConfirm = Color(0xFF34C759)
val ErrorRed = PremiumError

View file

@ -1,106 +1,145 @@
package com.rada.ui.theme
import com.rada.ui.theme.LocalTokens
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
private val PremiumColorScheme = darkColorScheme(
primary = PremiumPurple,
onPrimary = Color.White, // 4.81:1 on #7C4DFF
primaryContainer = PremiumPurpleSoft,
onPrimaryContainer = PremiumAccentText, // AA-safe accent text on glass
val LocalTokens = staticCompositionLocalOf { darkTokens(AccentMode.VIOLET) }
secondary = PremiumBlue,
onSecondary = Color.White,
secondaryContainer = PremiumBlue.copy(alpha = 0.15f),
onSecondaryContainer = PremiumBlue,
tertiary = PremiumCyan,
onTertiary = Color.White,
tertiaryContainer = PremiumCyan.copy(alpha = 0.15f),
onTertiaryContainer = PremiumCyan,
background = BaseBottom, // charcoal gradient bottom
onBackground = PremiumText,
surface = PremiumDark, // base top
onSurface = PremiumText,
surfaceVariant = PremiumSurface, // solid panel
onSurfaceVariant = PremiumTextSecondary,
error = PremiumError,
onError = Color.White,
errorContainer = PremiumError.copy(alpha = 0.15f),
onErrorContainer = PremiumError,
outline = PremiumBorder, // decorative 10% white glass border
outlineVariant = PremiumBorder.copy(alpha = 0.5f),
inverseSurface = PremiumText,
inverseOnSurface = BaseBottom,
inversePrimary = PremiumAccentText,
surfaceTint = PremiumPurple,
)
@Composable
fun SatsAmpTheme(
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val dark = when (UiPrefs.mode) {
AppMode.DARK -> true
AppMode.LIGHT -> false
AppMode.SYSTEM -> isSystemInDarkTheme()
}
val tok = tokensFor(UiPrefs.accent, dark)
val schemeArgs = SchemeArgs(
primary = tok.accentFill,
onPrimary = tok.accentOnFill,
primaryContainer = tok.accentSoft,
onPrimaryContainer = tok.accentText,
secondary = tok.accentText,
onSecondary = tok.text,
background = tok.bgBottom,
onBackground = tok.text,
surface = tok.bgTop,
onSurface = tok.text,
surfaceVariant = tok.panel,
onSurfaceVariant = tok.textSecondary,
error = PremiumError,
onError = Color.White,
outline = tok.border,
outlineVariant = tok.borderInteractive,
inverseSurface = tok.text,
inverseOnSurface = tok.bgBottom,
inversePrimary = tok.accentText,
surfaceTint = tok.accentFill,
)
val colorScheme = if (dark) buildDark(schemeArgs) else buildLight(schemeArgs)
CompositionLocalProvider(LocalTokens provides tok) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
}
/**
* RadAmp Glass tokens — mirrors design/glass-radio-theme.css.
* SatsAmp Glass tokens — mirrors design/glass-radio-theme.css.
* Compose has no backdrop blur without a lib, so "glass" = translucent white
* layer + inner highlight border over the charcoal base. Text never sits on
* glass alone without the 90%-opaque overlay (GlassTextSafe).
* glass alone without the 90%-opaque overlay (textSafe).
*/
object RadAmpGlass {
val bgGradient = Brush.verticalGradient(listOf(BaseTop, BaseBottom))
// very subtle violet glows, painted under content, never behind text
val glowTop = Brush.radialGradient(
colors = listOf(PremiumPurple.copy(alpha = 0.14f), Color.Transparent),
center = androidx.compose.ui.geometry.Offset(900f, 260f),
radius = 900f,
)
val surface = Color(0x0FFFFFFF) // rgba(255,255,255,0.06)
val surfaceStrong = Color(0x17FFFFFF) // hover 0.09
val border = Color(0x1AFFFFFF) // 0.10 decorative
val borderInteractive = Color(0x5CFFFFFF)// 0.36 → 3:1 on glass
val highlight = Color(0x24FFFFFF) // inset top highlight 0.14
val textSafe = Color(0xE61B1B22) // 90% opaque overlay behind text
object SatsAmpGlass {
val rCard = 20.dp
val rInput = 14.dp
}
/** App background: charcoal (or paper) gradient + accent glow UNDER all content. */
@Composable
fun GlassBackground(content: @Composable () -> Unit) {
Box(Modifier.fillMaxSize().background(RadAmpGlass.bgGradient)) {
Box(Modifier.fillMaxSize().background(RadAmpGlass.glowTop))
val tok = LocalTokens.current
Box(
Modifier
.fillMaxSize()
.background(Brush.verticalGradient(listOf(tok.bgTop, tok.bgBottom))),
) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(tok.glow, Color.Transparent),
center = androidx.compose.ui.geometry.Offset(900f, 260f),
radius = 900f,
),
),
)
content()
}
}
object RadAmpGradients {
/** Gradient helpers that follow the active accent. */
object SatsAmpGradients {
val primary = Brush.linearGradient(
colors = listOf(GradientStart, GradientMid, GradientEnd)
colors = listOf(PremiumPurpleStatic, GradientMid, GradientEnd)
)
val primaryHorizontal = Brush.horizontalGradient(
colors = listOf(GradientStart, GradientMid, GradientEnd)
colors = listOf(PremiumPurpleStatic, GradientMid, GradientEnd)
)
val subtle = Brush.verticalGradient(
colors = listOf(PremiumSurface, PremiumBlack)
)
val cardGlow = Brush.radialGradient(
colors = listOf(PremiumPurple.copy(alpha = 0.1f), Color.Transparent)
colors = listOf(PremiumSurfaceSolid, PremiumBlack)
)
@Composable
fun cardGlow(): Brush {
val tok = LocalTokens.current
return Brush.radialGradient(colors = listOf(tok.glow, Color.Transparent))
}
@Composable
fun accentBrush(): Brush {
val tok = LocalTokens.current
return Brush.linearGradient(listOf(tok.accentFill, tok.accentFillHover))
}
}
@Composable
fun RadAmpTheme(
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = PremiumColorScheme,
content = content,
)
}
private data class SchemeArgs(
val primary: Color, val onPrimary: Color, val primaryContainer: Color, val onPrimaryContainer: Color,
val secondary: Color, val onSecondary: Color, val background: Color, val onBackground: Color,
val surface: Color, val onSurface: Color, val surfaceVariant: Color, val onSurfaceVariant: Color,
val error: Color, val onError: Color, val outline: Color, val outlineVariant: Color,
val inverseSurface: Color, val inverseOnSurface: Color, val inversePrimary: Color, val surfaceTint: Color,
)
private fun buildDark(a: SchemeArgs) = darkColorScheme(
primary = a.primary, onPrimary = a.onPrimary, primaryContainer = a.primaryContainer,
onPrimaryContainer = a.onPrimaryContainer, secondary = a.secondary, onSecondary = a.onSecondary,
background = a.background, onBackground = a.onBackground, surface = a.surface, onSurface = a.onSurface,
surfaceVariant = a.surfaceVariant, onSurfaceVariant = a.onSurfaceVariant,
error = a.error, onError = a.onError, outline = a.outline, outlineVariant = a.outlineVariant,
inverseSurface = a.inverseSurface, inverseOnSurface = a.inverseOnSurface,
inversePrimary = a.inversePrimary, surfaceTint = a.surfaceTint,
)
private fun buildLight(a: SchemeArgs) = lightColorScheme(
primary = a.primary, onPrimary = a.onPrimary, primaryContainer = a.primaryContainer,
onPrimaryContainer = a.onPrimaryContainer, secondary = a.secondary, onSecondary = a.onSecondary,
background = a.background, onBackground = a.onBackground, surface = a.surface, onSurface = a.onSurface,
surfaceVariant = a.surfaceVariant, onSurfaceVariant = a.onSurfaceVariant,
error = a.error, onError = a.onError, outline = a.outline, outlineVariant = a.outlineVariant,
inverseSurface = a.inverseSurface, inverseOnSurface = a.inverseOnSurface,
inversePrimary = a.inversePrimary, surfaceTint = a.surfaceTint,
)

View file

@ -0,0 +1,139 @@
package com.rada.ui.theme
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
/** User-selectable accent colors. AA-safe variants live in UiTokens. */
enum class AccentMode(val label: String) { VIOLET("Violet"), BLUE("Blue"), GREEN("Green"), ORANGE("Orange") }
/** App brightness mode. DARK = black-with-white, LIGHT = white-with-black. */
enum class AppMode(val label: String) { SYSTEM("System"), DARK("Dark"), LIGHT("Light") }
/**
* Tiny state-backed prefs (Compose-observable, persisted to SharedPreferences).
* No DataStore dependency needed: two enums, read once at app start.
*/
object UiPrefs {
var accent by mutableStateOf(AccentMode.VIOLET)
private set
var mode by mutableStateOf(AppMode.SYSTEM)
private set
fun init(context: Context) {
val sp = context.getSharedPreferences("ui_prefs", Context.MODE_PRIVATE)
accent = runCatching { AccentMode.valueOf(sp.getString("accent", null) ?: "VIOLET") }
.getOrDefault(AccentMode.VIOLET)
mode = runCatching { AppMode.valueOf(sp.getString("mode", null) ?: "SYSTEM") }
.getOrDefault(AppMode.SYSTEM)
}
fun setAccent(value: AccentMode, context: Context) {
accent = value
context.getSharedPreferences("ui_prefs", Context.MODE_PRIVATE).edit().putString("accent", value.name).apply()
}
fun setMode(value: AppMode, context: Context) {
mode = value
context.getSharedPreferences("ui_prefs", Context.MODE_PRIVATE).edit().putString("mode", value.name).apply()
}
}
/** Full surface + accent token set for one (accent, brightness) combination. */
data class UiTokens(
// background
val bgTop: Color,
val bgBottom: Color,
val glow: Color, // painted under content only, never behind text
// glass surfaces
val glassFill: Color, // translucent layer
val glassFillStrong: Color,
val border: Color, // decorative 1px
val borderInteractive: Color, // >=3:1 vs glass (WCAG 1.4.11)
val textSafe: Color, // ~90% opaque overlay under text on glass
val panel: Color, // solid panel for lists/body
val panelBorder: Color,
// text
val text: Color,
val textSecondary: Color,
val textMuted: Color, // caption floor, never below 4.5:1
// accent (all AA-verified — see design/glass-radio-theme.css + demo)
val accentFill: Color,
val accentFillHover: Color,
val accentOnFill: Color, // icon/label color ON accent fills
val accentText: Color, // accent-colored TEXT on glass/base
val accentSoft: Color, // active-state fill behind accent text/icons
val success: Color,
val focusRing: Color,
)
// ── dark brightness (charcoal #23232B → #1B1B22) ──
private val InkDark = Color(0xFF141419)
internal fun darkTokens(a: AccentMode) = when (a) {
AccentMode.VIOLET -> UiTokens(
bgTop = BaseTop, bgBottom = BaseBottom, glow = Color(0xFF7C4DFF).copy(alpha = 0.14f),
glassFill = Color(0x0FFFFFFF), glassFillStrong = Color(0x17FFFFFF),
border = Color(0x1AFFFFFF), borderInteractive = Color(0x5CFFFFFF),
textSafe = Color(0xE61B1B22), panel = PremiumSurfaceSolid, panelBorder = Color(0x14FFFFFF),
text = Color.White, textSecondary = Color(0xFFD9D9DE), textMuted = Color(0xFF8C8C96),
accentFill = Color(0xFF7C4DFF), accentFillHover = Color(0xFF8E66FF),
accentOnFill = Color.White, accentText = Color(0xFFA78BFF),
accentSoft = Color(0x297C4DFF), success = Color(0xFF34C759), focusRing = Color(0xFFA78BFF),
)
AccentMode.BLUE -> UiTokens(
bgTop = BaseTop, bgBottom = BaseBottom, glow = Color(0xFF4C7DFF).copy(alpha = 0.13f),
glassFill = Color(0x0FFFFFFF), glassFillStrong = Color(0x17FFFFFF),
border = Color(0x1AFFFFFF), borderInteractive = Color(0x5CFFFFFF),
textSafe = Color(0xE61B1B22), panel = PremiumSurfaceSolid, panelBorder = Color(0x14FFFFFF),
text = Color.White, textSecondary = Color(0xFFD9D9DE), textMuted = Color(0xFF8C8C96),
accentFill = Color(0xFF4C7DFF), accentFillHover = Color(0xFF6390FF),
accentOnFill = InkDark, accentText = Color(0xFF93B4FF),
accentSoft = Color(0x294C7DFF), success = Color(0xFF34C759), focusRing = Color(0xFF93B4FF),
)
AccentMode.GREEN -> UiTokens(
bgTop = BaseTop, bgBottom = BaseBottom, glow = Color(0xFF1FA855).copy(alpha = 0.13f),
glassFill = Color(0x0FFFFFFF), glassFillStrong = Color(0x17FFFFFF),
border = Color(0x1AFFFFFF), borderInteractive = Color(0x5CFFFFFF),
textSafe = Color(0xE61B1B22), panel = PremiumSurfaceSolid, panelBorder = Color(0x14FFFFFF),
text = Color.White, textSecondary = Color(0xFFD9D9DE), textMuted = Color(0xFF8C8C96),
accentFill = Color(0xFF1FA855), accentFillHover = Color(0xFF2BBD64),
accentOnFill = InkDark, accentText = Color(0xFF5BD98A),
accentSoft = Color(0x291FA855), success = Color(0xFF34C759), focusRing = Color(0xFF5BD98A),
)
AccentMode.ORANGE -> UiTokens(
bgTop = BaseTop, bgBottom = BaseBottom, glow = Color(0xFFF97316).copy(alpha = 0.12f),
glassFill = Color(0x0FFFFFFF), glassFillStrong = Color(0x17FFFFFF),
border = Color(0x1AFFFFFF), borderInteractive = Color(0x5CFFFFFF),
textSafe = Color(0xE61B1B22), panel = PremiumSurfaceSolid, panelBorder = Color(0x14FFFFFF),
text = Color.White, textSecondary = Color(0xFFD9D9DE), textMuted = Color(0xFF8C8C96),
accentFill = Color(0xFFF97316), accentFillHover = Color(0xFFFB8A3C),
accentOnFill = InkDark, accentText = Color(0xFFFFB37E),
accentSoft = Color(0x29F97316), success = Color(0xFF34C759), focusRing = Color(0xFFFFB37E),
)
}
// ── light brightness (paper #F2F1F7 → #E7E5F0; dark ink text) ──
private fun lightTokens(a: AccentMode): UiTokens {
val (fill, hover, textOnGlass, soft) = when (a) {
AccentMode.VIOLET -> arrayOf(Color(0xFF6A3DE0), Color(0xFF5B2EDB), Color(0xFF5B2EDB), Color(0x1F6A3DE0))
AccentMode.BLUE -> arrayOf(Color(0xFF2563EB), Color(0xFF1D4ED8), Color(0xFF1D4ED8), Color(0x1F2563EB))
AccentMode.GREEN -> arrayOf(Color(0xFF0C7A38), Color(0xFF0F7A36), Color(0xFF0F7A36), Color(0x1F0C7A38))
AccentMode.ORANGE -> arrayOf(Color(0xFFC2570A), Color(0xFFB34700), Color(0xFFB34700), Color(0x1FC2570A))
}
return UiTokens(
bgTop = Color(0xFFF2F1F7), bgBottom = Color(0xFFE7E5F0), glow = fill.copy(alpha = 0.10f),
glassFill = Color(0x9EFFFFFF), glassFillStrong = Color(0xC7FFFFFF),
border = Color(0x1A23232B), borderInteractive = Color(0x7323232B),
textSafe = Color(0xEBFBFAFE), panel = Color(0xFFFBFAFE), panelBorder = Color(0x1A23232B),
text = Color(0xFF1B1B22), textSecondary = Color(0xCC1B1B22), textMuted = Color(0x9E1B1B22),
accentFill = fill, accentFillHover = hover,
accentOnFill = Color.White, accentText = textOnGlass,
accentSoft = soft, success = Color(0xFF0F7A36), focusRing = textOnGlass,
)
}
fun tokensFor(accent: AccentMode, dark: Boolean): UiTokens =
if (dark) darkTokens(accent) else lightTokens(accent)

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RadAmp</string>
<string name="app_name">SatsAmp</string>
<string name="playback_channel_name">Playback</string>
<string name="playback_channel_description">Radio playback controls</string>
<string name="notification_play">Play</string>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.RadAmp" parent="android:Theme.Material.NoActionBar" />
<style name="Theme.SatsAmp" parent="android:Theme.Material.NoActionBar" />
</resources>