feat: add station album art management

Add ability to set, edit, clear cache, delete, and generate station
artwork. Users can pick images from gallery, paste URLs, or generate
initials-based avatars. Actions accessible from both the station context
menu and the edit dialog.

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-27 22:25:18 -04:00
parent ade0323eb8
commit fd740929c1
10 changed files with 577 additions and 16 deletions

View File

@@ -54,4 +54,7 @@ interface StationDao {
@Query("UPDATE stations SET listenerCount = :count WHERE id = :id")
suspend fun updateListenerCount(id: Long, count: Int)
@Query("UPDATE stations SET defaultArtworkUrl = :artworkUrl WHERE id = :id")
suspend fun updateArtworkUrl(id: Long, artworkUrl: String?)
}

View File

@@ -11,7 +11,7 @@ import java.net.URLEncoder
class AlbumArtResolver(
private val client: OkHttpClient,
private val artCache: ArtCache = ArtCache(),
val artCache: ArtCache = ArtCache(),
private val musicBrainzBaseUrl: String = "https://musicbrainz.org",
private val coverArtBaseUrl: String = "https://coverartarchive.org",
private val skipArtVerification: Boolean = false,

View File

@@ -14,4 +14,14 @@ class ArtCache(private val maxSize: Int = 500) {
fun put(key: String, url: String) {
cache[key] = url
}
@Synchronized
fun removeByValue(url: String) {
cache.entries.removeAll { it.value == url }
}
@Synchronized
fun clear() {
cache.clear()
}
}

View File

@@ -1,10 +1,23 @@
package xyz.cottongin.radio247.ui.screens.stationlist
import androidx.compose.foundation.background
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.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -13,23 +26,98 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.R
@Composable
fun AddStationDialog(
onDismiss: () -> Unit,
onConfirm: (name: String, url: String) -> Unit,
onConfirm: (name: String, url: String, artworkUrl: String?) -> Unit,
onPickImage: (onResult: (String) -> Unit) -> Unit,
modifier: Modifier = Modifier
) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var artworkUrl by remember { mutableStateOf<String?>(null) }
var showUrlField by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Station") },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape),
contentAlignment = Alignment.Center
) {
if (artworkUrl != null) {
AsyncImage(
model = artworkUrl,
contentDescription = "Station artwork",
modifier = Modifier.size(80.dp).clip(CircleShape)
)
} else {
Icon(
painter = painterResource(R.drawable.ic_radio_placeholder),
contentDescription = "No artwork",
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { onPickImage { picked -> artworkUrl = picked } },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Image, contentDescription = "Pick image", modifier = Modifier.size(20.dp))
}
IconButton(
onClick = { showUrlField = !showUrlField },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Link, contentDescription = "Set URL", modifier = Modifier.size(20.dp))
}
if (artworkUrl != null) {
IconButton(
onClick = { artworkUrl = null },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Remove artwork", modifier = Modifier.size(20.dp))
}
}
}
if (showUrlField) {
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = artworkUrl ?: "",
onValueChange = { artworkUrl = it.ifBlank { null } },
label = { Text("Artwork URL") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -51,7 +139,7 @@ fun AddStationDialog(
TextButton(
onClick = {
if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim())
onConfirm(name.trim(), url.trim(), artworkUrl?.trim())
onDismiss()
}
}

View File

@@ -1,10 +1,25 @@
package xyz.cottongin.radio247.ui.screens.stationlist
import androidx.compose.foundation.background
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.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -13,25 +28,109 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.R
import xyz.cottongin.radio247.data.model.Station
@Composable
fun EditStationDialog(
station: Station,
onDismiss: () -> Unit,
onConfirm: (name: String, url: String) -> Unit,
onConfirm: (name: String, url: String, artworkUrl: String?) -> Unit,
onPickImage: () -> Unit,
onGenerateArt: () -> Unit,
onClearCache: () -> Unit,
onDeleteArt: () -> Unit,
modifier: Modifier = Modifier
) {
var name by remember(station.id) { mutableStateOf(station.name) }
var url by remember(station.id) { mutableStateOf(station.url) }
var artworkUrl by remember(station.id) { mutableStateOf(station.defaultArtworkUrl) }
var showUrlField by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit Station") },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape),
contentAlignment = Alignment.Center
) {
if (artworkUrl != null) {
AsyncImage(
model = artworkUrl,
contentDescription = "Station artwork",
modifier = Modifier.size(80.dp).clip(CircleShape)
)
} else {
Icon(
painter = painterResource(R.drawable.ic_radio_placeholder),
contentDescription = "No artwork",
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onPickImage, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Image, contentDescription = "Pick image", modifier = Modifier.size(20.dp))
}
IconButton(
onClick = { showUrlField = !showUrlField },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Link, contentDescription = "Set URL", modifier = Modifier.size(20.dp))
}
IconButton(onClick = onGenerateArt, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.AutoFixHigh, contentDescription = "Generate art", modifier = Modifier.size(20.dp))
}
if (artworkUrl != null) {
IconButton(onClick = onClearCache, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Refresh, contentDescription = "Clear cache", modifier = Modifier.size(20.dp))
}
IconButton(
onClick = {
artworkUrl = null
onDeleteArt()
},
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Remove artwork", modifier = Modifier.size(20.dp))
}
}
}
if (showUrlField) {
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = artworkUrl ?: "",
onValueChange = { artworkUrl = it.ifBlank { null } },
label = { Text("Artwork URL") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -53,7 +152,7 @@ fun EditStationDialog(
TextButton(
onClick = {
if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim())
onConfirm(name.trim(), url.trim(), artworkUrl?.trim())
onDismiss()
}
}

View File

@@ -107,6 +107,8 @@ fun StationListScreen(
var qualityStreams by remember { mutableStateOf<List<StationStream>>(emptyList()) }
var qualityCurrentOrder by remember { mutableStateOf<List<String>>(emptyList()) }
var tabToRename by remember { mutableStateOf<TabInfo?>(null) }
var artworkPickTarget by remember { mutableStateOf<Station?>(null) }
var addDialogArtCallback by remember { mutableStateOf<((String) -> Unit)?>(null) }
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
@@ -115,6 +117,29 @@ fun StationListScreen(
}
)
val artworkPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
onResult = { uri: Uri? ->
if (uri != null) {
val target = artworkPickTarget
val addCallback = addDialogArtCallback
when {
target != null -> {
viewModel.copyImageToInternal(target.id, uri)
artworkPickTarget = null
}
addCallback != null -> {
addCallback(uri.toString())
addDialogArtCallback = null
}
}
} else {
artworkPickTarget = null
addDialogArtCallback = null
}
}
)
val currentTab = viewState.tabs.getOrNull(viewState.selectedTabIndex)
val isBuiltInTab = currentTab?.isBuiltIn == true
@@ -249,6 +274,13 @@ fun StationListScreen(
onEdit = { stationToEdit = station },
onDelete = { viewModel.deleteStation(station) },
onToggleHidden = { viewModel.toggleHidden(station) },
onSetArtwork = {
artworkPickTarget = station
artworkPickerLauncher.launch("image/*")
},
onClearArtCache = { viewModel.clearStationArtworkCache(station) },
onRemoveArtwork = { viewModel.deleteStationArtwork(station.id) },
onGenerateArt = { viewModel.generateStationArtwork(station) },
onQuality = {
viewModel.getStreamsForStation(station.id) { streams ->
viewModel.getQualityOverrideForStation(station.id) { json ->
@@ -282,9 +314,13 @@ fun StationListScreen(
if (showAddStation) {
AddStationDialog(
onDismiss = { showAddStation = false },
onConfirm = { name, url ->
viewModel.addStation(name, url)
onConfirm = { name, url, artworkUrl ->
viewModel.addStation(name, url, artworkUrl)
showAddStation = false
},
onPickImage = { callback ->
addDialogArtCallback = callback
artworkPickerLauncher.launch("image/*")
}
)
}
@@ -303,10 +339,17 @@ fun StationListScreen(
EditStationDialog(
station = station,
onDismiss = { stationToEdit = null },
onConfirm = { name, url ->
viewModel.updateStation(station, name, url)
onConfirm = { name, url, artworkUrl ->
viewModel.updateStation(station, name, url, artworkUrl)
stationToEdit = null
}
},
onPickImage = {
artworkPickTarget = station
artworkPickerLauncher.launch("image/*")
},
onGenerateArt = { viewModel.generateStationArtwork(station) },
onClearCache = { viewModel.clearStationArtworkCache(station) },
onDeleteArt = { viewModel.deleteStationArtwork(station.id) }
)
}
@@ -597,6 +640,10 @@ private fun StationRow(
onDelete: () -> Unit,
onToggleHidden: () -> Unit,
onQuality: () -> Unit,
onSetArtwork: () -> Unit,
onClearArtCache: () -> Unit,
onRemoveArtwork: () -> Unit,
onGenerateArt: () -> Unit,
modifier: Modifier = Modifier
) {
var showMenu by remember { mutableStateOf(false) }
@@ -716,6 +763,38 @@ private fun StationRow(
}
)
}
if (!isHiddenView) {
DropdownMenuItem(
text = { Text("Set Artwork") },
onClick = {
showMenu = false
onSetArtwork()
}
)
DropdownMenuItem(
text = { Text("Generate Artwork") },
onClick = {
showMenu = false
onGenerateArt()
}
)
if (station.defaultArtworkUrl != null) {
DropdownMenuItem(
text = { Text("Clear Art Cache") },
onClick = {
showMenu = false
onClearArtCache()
}
)
DropdownMenuItem(
text = { Text("Remove Artwork") },
onClick = {
showMenu = false
onRemoveArtwork()
}
)
}
}
}
}
}

View File

@@ -1,6 +1,8 @@
package xyz.cottongin.radio247.ui.screens.stationlist
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -13,6 +15,7 @@ import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.model.StationPreference
import xyz.cottongin.radio247.data.model.StationStream
import xyz.cottongin.radio247.util.ArtworkGenerator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -26,6 +29,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
data class TabInfo(
val playlist: Playlist?,
@@ -250,17 +254,55 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
viewModelScope.launch { stationDao.delete(station) }
}
fun addStation(name: String, url: String) {
fun addStation(name: String, url: String, artworkUrl: String? = null) {
val currentTab = viewState.value.tabs.getOrNull(viewState.value.selectedTabIndex)
val playlistId = currentTab?.playlist?.id
viewModelScope.launch {
stationDao.insert(Station(name = name, url = url, playlistId = playlistId))
stationDao.insert(Station(name = name, url = url, playlistId = playlistId, defaultArtworkUrl = artworkUrl))
}
}
fun updateStation(station: Station, name: String, url: String) {
fun updateStation(station: Station, name: String, url: String, artworkUrl: String? = station.defaultArtworkUrl) {
viewModelScope.launch {
stationDao.update(station.copy(name = name, url = url))
stationDao.update(station.copy(name = name, url = url, defaultArtworkUrl = artworkUrl))
}
}
fun updateStationArtwork(stationId: Long, artworkUrl: String?) {
viewModelScope.launch {
stationDao.updateArtworkUrl(stationId, artworkUrl)
}
}
fun deleteStationArtwork(stationId: Long) {
updateStationArtwork(stationId, null)
}
fun clearStationArtworkCache(station: Station) {
val url = station.defaultArtworkUrl ?: return
app.albumArtResolver.artCache.removeByValue(url)
}
fun generateStationArtwork(station: Station) {
viewModelScope.launch(Dispatchers.IO) {
val path = ArtworkGenerator.generateAndSave(app, station.id, station.name)
stationDao.updateArtworkUrl(station.id, path)
}
}
fun copyImageToInternal(stationId: Long, sourceUri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
val dir = File(app.filesDir, "station_art")
dir.mkdirs()
val destFile = File(dir, "$stationId.png")
app.contentResolver.openInputStream(sourceUri)?.use { input ->
val bitmap = BitmapFactory.decodeStream(input) ?: return@launch
destFile.outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
bitmap.recycle()
} ?: return@launch
stationDao.updateArtworkUrl(stationId, destFile.toURI().toString())
}
}

View File

@@ -0,0 +1,77 @@
package xyz.cottongin.radio247.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import java.io.File
import kotlin.math.abs
object ArtworkGenerator {
private val PALETTE = intArrayOf(
0xFF1E88E5.toInt(), // Blue
0xFF43A047.toInt(), // Green
0xFFE53935.toInt(), // Red
0xFF8E24AA.toInt(), // Purple
0xFFFB8C00.toInt(), // Orange
0xFF00ACC1.toInt(), // Cyan
0xFF3949AB.toInt(), // Indigo
0xFF7CB342.toInt(), // Light Green
0xFFD81B60.toInt(), // Pink
0xFF5E35B1.toInt(), // Deep Purple
0xFF039BE5.toInt(), // Light Blue
0xFFC0CA33.toInt(), // Lime
)
fun extractInitials(name: String): String {
val words = name.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }
return when {
words.size >= 2 -> "${words[0].first().uppercaseChar()}${words[1].first().uppercaseChar()}"
words.size == 1 && words[0].length >= 2 -> words[0].take(2).uppercase()
words.size == 1 -> words[0].first().uppercaseChar().toString()
else -> "?"
}
}
fun pickColor(name: String): Int {
return PALETTE[abs(name.hashCode()) % PALETTE.size]
}
fun generateBitmap(name: String, sizePx: Int = 256): Bitmap {
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = pickColor(name)
style = Paint.Style.FILL
}
val cx = sizePx / 2f
val cy = sizePx / 2f
canvas.drawCircle(cx, cy, cx, bgPaint)
val initials = extractInitials(name)
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = sizePx * 0.38f
typeface = Typeface.DEFAULT_BOLD
textAlign = Paint.Align.CENTER
}
val textY = cy - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials, cx, textY, textPaint)
return bitmap
}
fun generateAndSave(context: Context, stationId: Long, stationName: String): String {
val dir = File(context.filesDir, "station_art")
dir.mkdirs()
val file = File(dir, "$stationId.png")
val bitmap = generateBitmap(stationName)
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
return file.toURI().toString()
}
}

View File

@@ -0,0 +1,75 @@
package xyz.cottongin.radio247.metadata
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ArtCacheTest {
@Test
fun get_returnsNullForMissingKey() {
val cache = ArtCache()
assertNull(cache.get("nonexistent"))
}
@Test
fun put_and_get() {
val cache = ArtCache()
cache.put("key1", "https://example.com/art.jpg")
assertEquals("https://example.com/art.jpg", cache.get("key1"))
}
@Test
fun evicts_when_exceeding_maxSize() {
val cache = ArtCache(maxSize = 2)
cache.put("a", "url-a")
cache.put("b", "url-b")
cache.put("c", "url-c")
assertNull(cache.get("a"))
assertEquals("url-b", cache.get("b"))
assertEquals("url-c", cache.get("c"))
}
@Test
fun removeByValue_removesMatchingEntries() {
val cache = ArtCache()
cache.put("key1", "https://station.com/art.png")
cache.put("key2", "https://other.com/art.png")
cache.put("key3", "https://station.com/art.png")
cache.removeByValue("https://station.com/art.png")
assertNull(cache.get("key1"))
assertEquals("https://other.com/art.png", cache.get("key2"))
assertNull(cache.get("key3"))
}
@Test
fun removeByValue_noOpWhenValueNotPresent() {
val cache = ArtCache()
cache.put("key1", "url-a")
cache.removeByValue("url-b")
assertEquals("url-a", cache.get("key1"))
}
@Test
fun clear_removesAllEntries() {
val cache = ArtCache()
cache.put("a", "url-a")
cache.put("b", "url-b")
cache.put("c", "url-c")
cache.clear()
assertNull(cache.get("a"))
assertNull(cache.get("b"))
assertNull(cache.get("c"))
}
@Test
fun clear_onEmptyCacheIsNoOp() {
val cache = ArtCache()
cache.clear()
assertNull(cache.get("anything"))
}
}

View File

@@ -0,0 +1,88 @@
package xyz.cottongin.radio247.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ArtworkGeneratorTest {
@Test
fun extractInitials_twoWords() {
assertEquals("GS", ArtworkGenerator.extractInitials("Groove Salad"))
}
@Test
fun extractInitials_threeWords() {
assertEquals("DR", ArtworkGenerator.extractInitials("Drone Radio Station"))
}
@Test
fun extractInitials_singleLongWord() {
assertEquals("SO", ArtworkGenerator.extractInitials("SomaFM"))
}
@Test
fun extractInitials_singleChar() {
assertEquals("X", ArtworkGenerator.extractInitials("X"))
}
@Test
fun extractInitials_blank() {
assertEquals("?", ArtworkGenerator.extractInitials(""))
}
@Test
fun extractInitials_whitespace() {
assertEquals("?", ArtworkGenerator.extractInitials(" "))
}
@Test
fun extractInitials_leadingTrailingSpaces() {
assertEquals("GS", ArtworkGenerator.extractInitials(" Groove Salad "))
}
@Test
fun extractInitials_lowercaseInput() {
assertEquals("GS", ArtworkGenerator.extractInitials("groove salad"))
}
@Test
fun pickColor_deterministic() {
val c1 = ArtworkGenerator.pickColor("Groove Salad")
val c2 = ArtworkGenerator.pickColor("Groove Salad")
assertEquals(c1, c2)
}
@Test
fun pickColor_differentNamesCanDiffer() {
val c1 = ArtworkGenerator.pickColor("Groove Salad")
val c2 = ArtworkGenerator.pickColor("Drone Zone")
// Not guaranteed to differ (hash collisions) but the palette is broad enough
// that two very different strings are likely to map to different colors.
// This is a soft assertion -- we mainly verify no crash and determinism.
assertTrue(c1 != 0)
assertTrue(c2 != 0)
}
@Test
fun pickColor_alwaysFromPalette() {
val palette = intArrayOf(
0xFF1E88E5.toInt(), 0xFF43A047.toInt(), 0xFFE53935.toInt(),
0xFF8E24AA.toInt(), 0xFFFB8C00.toInt(), 0xFF00ACC1.toInt(),
0xFF3949AB.toInt(), 0xFF7CB342.toInt(), 0xFFD81B60.toInt(),
0xFF5E35B1.toInt(), 0xFF039BE5.toInt(), 0xFFC0CA33.toInt(),
)
val names = listOf(
"Alpha", "Bravo", "Charlie", "Delta", "Echo",
"Foxtrot", "Golf", "Hotel", "India", "Juliet",
"Kilo", "Lima", "Mike", "November", "Oscar"
)
for (name in names) {
assertTrue(
"Color for '$name' should be in palette",
ArtworkGenerator.pickColor(name) in palette
)
}
}
}