diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt index 3ad8a869..66507f87 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt @@ -1,10 +1,14 @@ package io.unom.punktfunk import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -42,6 +46,8 @@ 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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp /** @@ -62,6 +68,7 @@ internal fun ProfileScopeChips( onNew: () -> Unit, onRename: () -> Unit, onDuplicate: () -> Unit, + onRecolour: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, ) { @@ -111,6 +118,7 @@ internal fun ProfileScopeChips( DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { DropdownMenuItem(text = { Text("Rename…") }, onClick = { menu = false; onRename() }) DropdownMenuItem(text = { Text("Duplicate") }, onClick = { menu = false; onDuplicate() }) + DropdownMenuItem(text = { Text("Change colour…") }, onClick = { menu = false; onRecolour() }) DropdownMenuItem(text = { Text("Delete…") }, onClick = { menu = false; onDelete() }) } } @@ -185,6 +193,66 @@ internal fun ProfileNameFields(name: String, duplicate: Boolean, onNameChange: ( } } +/** + * Pick a profile's chip colour. The accent is the whole signal on the surfaces where a profile has + * no room for its name — a bound host card's chip, a pinned card's tint — so it is worth being able + * to choose rather than take whatever creation handed out. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun ProfileColourDialog( + profile: StreamProfile, + onPick: (String?) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Colour for “${profile.name}”") }, + text = { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + PROFILE_ACCENTS.forEach { hex -> + Swatch( + colour = accentColor(hex), + selected = profile.accent?.equals(hex, ignoreCase = true) == true, + onClick = { onPick(hex) }, + ) + } + // "No colour" is a real choice, not an absence: the chip then falls back to the + // theme's own accent, which is what a profile created before this picker existed has. + Swatch(colour = null, selected = profile.accent == null, onClick = { onPick(null) }) + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + ) +} + +/** One colour choice: a filled disc, ringed when it is the current one. `null` = no colour. */ +@Composable +private fun Swatch(colour: Color?, selected: Boolean, onClick: () -> Unit) { + val fill = colour ?: MaterialTheme.colorScheme.surfaceVariant + Box( + Modifier + .size(40.dp) + .clip(CircleShape) + .background(fill) + .then( + if (selected) { + Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + } else if (colour == null) { + Modifier.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) + } else { + Modifier + }, + ) + .clickable(onClick = onClick) + .semantics { contentDescription = if (colour == null) "No colour" else "Colour" }, + ) +} + /** * Deleting a profile is not destructive to anything but the profile — a host bound to it falls * back to the default settings and a card pinned to it disappears, neither of which is an error. diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt index f20d0557..d92cc1ee 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt @@ -322,12 +322,40 @@ class ProfileStore(context: Context) { } } +/** + * Chip colours a new profile is given, in order. Chosen to stay legible on a dark surface and to + * be distinguishable from each other at the size they are actually used — a 6dp dot on a chip and + * a tint on a pinned card. Deliberately NOT the presence green ([HostCard]'s online dot), which + * means something else entirely. + */ +val PROFILE_ACCENTS = listOf( + "#FF8A4C", // orange + "#60A5FA", // blue + "#F472B6", // pink + "#34D399", // green + "#FBBF24", // amber + "#A78BFA", // violet + "#22D3EE", // cyan + "#FB7185", // rose +) + +/** The first accent no existing profile is using, so two profiles don't look alike by accident. */ +fun nextAccent(existing: List): String { + val taken = existing.mapNotNull { it.accent?.lowercase() }.toSet() + return PROFILE_ACCENTS.firstOrNull { it.lowercase() !in taken } ?: PROFILE_ACCENTS.first() +} + /** * A new, empty profile: it inherits everything, which is the right creation default under * inherit-by-exception (Duplicate covers "start from that other profile"). The id is 12 lowercase * hex characters — the shape the Rust `new_profile_id` mints. + * + * [accent] is presentation, not a setting, so it does NOT inherit — a profile with no colour would + * be indistinguishable from the defaults everywhere the accent is the whole signal (a bound card's + * chip, a pinned card's tint). Callers creating a profile from the UI pass [nextAccent]. */ -fun newProfile(name: String): StreamProfile = StreamProfile(id = newProfileId(), name = name) +fun newProfile(name: String, accent: String? = null): StreamProfile = + StreamProfile(id = newProfileId(), name = name, accent = accent) private val PROFILE_ID_RNG = SecureRandom() diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 3ca2606f..07aab500 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -131,6 +131,7 @@ fun SettingsScreen( var showControllers by remember { mutableStateOf(false) } var naming by remember { mutableStateOf(null) } var deleting by remember { mutableStateOf(null) } + var recolouring by remember { mutableStateOf(null) } // Every row renders the EFFECTIVE value: the globals with this profile's overrides on top, so a // row the profile doesn't override reads as the live global — and keeps following it. @@ -206,6 +207,7 @@ fun SettingsScreen( scopeId = copy.id } }, + onRecolour = { recolouring = active }, onDelete = { deleting = active }, modifier = Modifier.padding(top = 12.dp), ) @@ -309,7 +311,7 @@ fun SettingsScreen( confirmLabel = if (editing == null) "Create" else "Rename", taken = { profileStore.nameTaken(it, except = editing?.id) }, onConfirm = { name -> - val saved = editing?.copy(name = name) ?: newProfile(name) + val saved = editing?.copy(name = name) ?: newProfile(name, nextAccent(profiles)) profileStore.save(saved) profiles = profileStore.all() scopeId = saved.id @@ -319,6 +321,18 @@ fun SettingsScreen( ) } + recolouring?.let { profile -> + ProfileColourDialog( + profile = profile, + onPick = { hex -> + profileStore.save(profile.copy(accent = hex)) + profiles = profileStore.all() + recolouring = null + }, + onDismiss = { recolouring = null }, + ) + } + deleting?.let { profile -> val hosts = hostStore.all() DeleteProfileDialog( diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt index 2f90dc9c..2a6f3dd0 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt @@ -216,6 +216,25 @@ class ProfilesTest { assertEquals(base, base.effectiveFor(store.resolveFor(h, oneOff = null))) } + /** + * A profile created from the UI gets a colour, and a distinct one — the accent is the WHOLE + * signal on a bound host card's chip and a pinned card's tint, so two profiles sharing it (or + * having none) makes those surfaces say less than they look like they're saying. + */ + @Test + fun creationHandsOutADistinctColour() { + val made = mutableListOf() + repeat(PROFILE_ACCENTS.size) { made += newProfile("p$it", nextAccent(made)) } + assertEquals(PROFILE_ACCENTS, made.map { it.accent }) + // Past the palette it wraps rather than handing out nothing — a duplicate colour beats an + // invisible chip, and the picker is right there. + assertEquals(PROFILE_ACCENTS.first(), nextAccent(made)) + // A gap is reused before wrapping. + assertEquals(PROFILE_ACCENTS[2], nextAccent(made.filter { it.accent != PROFILE_ACCENTS[2] })) + // The colour is presentation, so it never reaches the resolved settings. + assertEquals(base, made.first().overrides.apply(base)) + } + @Test fun mintedIdsAreWellFormed() { val id = newProfileId()