feat(client/android): a profile gets a colour, and you can change it

The accent was reserved in the schema and used everywhere it mattered — the
scope chip, a bound host card's chip, a pinned card's tint — but nothing ever
set one. `newProfile` left it null, and design §5.1's "Change color" was the
one item of the scope menu I didn't build. So every profile a user actually
created was colourless, and the only one that wasn't was a test profile I had
seeded by hand through adb. That inconsistency was the whole visible symptom.

Creation now hands out the first unused colour from an eight-entry palette, so
profiles are distinguishable from the moment they exist — which is the point
of the accent on the surfaces where a profile has no room for its name. The
palette avoids the presence green, which means "this host is up" and nothing
else. Past eight it wraps rather than handing out nothing: a repeated colour
beats an invisible chip, and the picker is right there.

"Change colour…" joins Rename / Duplicate / Delete, with "no colour" offered
as a real choice rather than only an initial state — a profile made before
this existed keeps working, and its chip falls back to the theme's accent.

The colour is presentation, not a setting: it is not in the overlay, so it
never reaches a resolved connect. Tested, and driven on glass end to end —
two profiles created through the dialog, distinct accents on their chips.
This commit is contained in:
2026-07-29 12:17:18 +02:00
parent 4803260aff
commit f2874a5324
4 changed files with 131 additions and 2 deletions
@@ -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.
@@ -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<StreamProfile>): 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()
@@ -131,6 +131,7 @@ fun SettingsScreen(
var showControllers by remember { mutableStateOf(false) }
var naming by remember { mutableStateOf<NameIntent?>(null) }
var deleting by remember { mutableStateOf<StreamProfile?>(null) }
var recolouring by remember { mutableStateOf<StreamProfile?>(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(
@@ -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<StreamProfile>()
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()