refactor(client/android): manage a profile from its own chip, and pick its colour up front

Two things about the profile UI were backwards.

**The manage menu was parked after the last chip.** A single overflow button
at the end of a horizontally-scrolling row, acting on whichever profile
happened to be selected — so reaching it meant scrolling past every profile,
and nothing on screen tied the button to its target. It now lives ON the chip:
the selected profile grows a chevron, tapping it again opens Edit / Duplicate
/ Delete anchored underneath, and the action is attached to the object it acts
on. The wandering ⋮ is gone.

**Colour was an afterthought.** A profile was named at creation and coloured
later, through a menu item most people would never find — so every profile
looked colourless until someone went hunting, and the accent is precisely the
signal that has to be right from the first moment (it is all a bound host
card's chip and a pinned card's tint have to go on). Name and colour are now
decided together, in one dialog that serves both creation and editing, with
the next unused colour pre-selected. "Rename…" and "Change colour…" collapse
into one "Edit…", which is three menu items instead of four and one dialog
instead of two.

The chevron rides inside the chip's label rather than the `trailingIcon` slot,
for the same reason the accent dot does: those slots reserve an 18dp icon and
shrink the chip's padding, which is what made a chip with a dot sit
differently from "Default settings" beside it.

Driven on glass: created a profile with a colour chosen in the dialog, opened
the menu from the selected chip, and edited name and colour together.
This commit is contained in:
2026-07-29 12:17:18 +02:00
parent f2874a5324
commit a3ec30c287
3 changed files with 159 additions and 146 deletions
@@ -19,7 +19,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.AssistChipDefaults
@@ -32,7 +32,6 @@ import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -56,9 +55,13 @@ import androidx.compose.ui.unit.dp
* that drifts from it. "Default settings" is the base layer every profile inherits from. * that drifts from it. "Default settings" is the base layer every profile inherits from.
* *
* A chips row rather than a menu, because on touch the scopes are worth seeing at a glance and * A chips row rather than a menu, because on touch the scopes are worth seeing at a glance and
* there are rarely more than a handful. The overflow beside them manages the SELECTED profile * there are rarely more than a handful. Managing a profile lives ON its chip: the selected one
* (rename / duplicate / delete); with no profiles at all the row is just "Default settings" and a * grows a chevron, and tapping it again opens Edit / Duplicate / Delete anchored under it. That
* "New profile" chip, which is all the clutter a user who never wants this feature ever sees. * replaced a lone overflow button parked after the LAST chip — which meant scrolling past every
* profile to reach an action that applied to one of them, with nothing on screen saying which.
*
* With no profiles at all the row is just "Default settings" and a "New profile" chip, which is
* all the clutter a user who never wants this feature ever sees.
*/ */
@Composable @Composable
internal fun ProfileScopeChips( internal fun ProfileScopeChips(
@@ -66,12 +69,13 @@ internal fun ProfileScopeChips(
selectedId: String?, selectedId: String?,
onSelect: (String?) -> Unit, onSelect: (String?) -> Unit,
onNew: () -> Unit, onNew: () -> Unit,
onRename: () -> Unit, onEdit: (StreamProfile) -> Unit,
onDuplicate: () -> Unit, onDuplicate: (StreamProfile) -> Unit,
onRecolour: () -> Unit, onDelete: (StreamProfile) -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// Which chip's menu is open — one at a time, and it closes itself when the scope changes.
var menuFor by remember { mutableStateOf<String?>(null) }
Row( Row(
modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp), modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -83,24 +87,45 @@ internal fun ProfileScopeChips(
label = { Text("Default settings") }, label = { Text("Default settings") },
) )
profiles.forEach { p -> profiles.forEach { p ->
FilterChip( val isSelected = selectedId == p.id
selected = selectedId == p.id, Box {
onClick = { onSelect(p.id) }, FilterChip(
// The accent dot rides INSIDE the label, not in the `leadingIcon` slot: that slot selected = isSelected,
// reserves an 18dp icon and shrinks the chip's leading padding to suit, so a // Tap to select; tap the selected one — the one wearing the chevron — to manage
// profile with an accent would sit differently from one without and from // it. The action is on the object it acts on, which is the whole point.
// "Default settings" beside it. In the label every chip keeps the same padding and onClick = { if (isSelected) menuFor = p.id else onSelect(p.id) },
// the dot's spacing is ours to set. // The dot and the chevron ride INSIDE the label, not in the `leadingIcon` /
label = { // `trailingIcon` slots: those reserve an 18dp icon and shrink the chip's padding
Row(verticalAlignment = Alignment.CenterVertically) { // to suit, so a chip with an accent (or with the chevron) would sit differently
accentColor(p.accent)?.let { dot -> // from "Default settings" beside it. In the label every chip keeps the same
AccentDot(dot, size = 8) // padding and the spacing is ours to set.
Spacer(Modifier.width(8.dp)) label = {
Row(verticalAlignment = Alignment.CenterVertically) {
accentColor(p.accent)?.let { dot ->
AccentDot(dot, size = 8)
Spacer(Modifier.width(8.dp))
}
Text(p.name)
if (isSelected) {
Spacer(Modifier.width(2.dp))
Icon(
Icons.Filled.ArrowDropDown,
contentDescription = "Manage “${p.name}",
modifier = Modifier.size(18.dp),
)
}
} }
Text(p.name) },
} )
}, DropdownMenu(expanded = menuFor == p.id, onDismissRequest = { menuFor = null }) {
) DropdownMenuItem(text = { Text("Edit…") }, onClick = { menuFor = null; onEdit(p) })
DropdownMenuItem(
text = { Text("Duplicate") },
onClick = { menuFor = null; onDuplicate(p) },
)
DropdownMenuItem(text = { Text("Delete…") }, onClick = { menuFor = null; onDelete(p) })
}
}
} }
AssistChip( AssistChip(
onClick = onNew, onClick = onNew,
@@ -109,49 +134,52 @@ internal fun ProfileScopeChips(
Icon(Icons.Filled.Add, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize)) Icon(Icons.Filled.Add, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize))
}, },
) )
if (selectedId != null) {
var menu by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { menu = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Manage profile")
}
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() })
}
}
}
} }
} }
/** /**
* Name a profile — used by both "New profile…" and "Rename…". Names must be unique * Create or edit a profile: its name and its colour, decided together. They were two flows —
* case-insensitively: two "Work" chips in a menu are ambiguous, and a `punktfunk://…?profile=Work` * a name dialog at creation, "Change colour…" afterwards — which meant every profile started
* link would have to refuse rather than guess. [taken] is the live duplicate check, which lets a * colourless-looking until the user went hunting for a menu item, and the accent is exactly the
* rename keep its own name (and change only its case). * signal that has to be there from the first moment (it is all a bound host card's chip and a
* pinned card's tint have to go on).
*
* Names must be unique case-insensitively: two "Work" chips in a menu are ambiguous, and a
* `punktfunk://…?profile=Work` link would have to refuse rather than guess. [taken] is the live
* duplicate check, which lets an edit keep its own name (and change only its case).
*/ */
@Composable @Composable
internal fun ProfileNameDialog( internal fun ProfileEditorDialog(
title: String, title: String,
initialName: String,
confirmLabel: String, confirmLabel: String,
initialName: String,
initialAccent: String?,
creating: Boolean,
taken: (String) -> Boolean, taken: (String) -> Boolean,
onConfirm: (String) -> Unit, onConfirm: (name: String, accent: String?) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
var name by remember { mutableStateOf(initialName) } var name by remember { mutableStateOf(initialName) }
var accent by remember { mutableStateOf(initialAccent) }
val trimmed = name.trim() val trimmed = name.trim()
val duplicate = trimmed.isNotEmpty() && taken(trimmed) val duplicate = trimmed.isNotEmpty() && taken(trimmed)
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(title) }, title = { Text(title) },
text = { ProfileNameFields(name, duplicate) { name = it } }, text = {
ProfileEditorFields(
name = name,
accent = accent,
duplicate = duplicate,
creating = creating,
onNameChange = { name = it },
onAccentChange = { accent = it },
)
},
confirmButton = { confirmButton = {
TextButton( TextButton(
enabled = trimmed.isNotEmpty() && !duplicate, enabled = trimmed.isNotEmpty() && !duplicate,
onClick = { onConfirm(trimmed) }, onClick = { onConfirm(trimmed, accent) },
) { Text(confirmLabel) } ) { Text(confirmLabel) }
}, },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
@@ -159,13 +187,21 @@ internal fun ProfileNameDialog(
} }
/** /**
* The name field and its caption. Extracted from [ProfileNameDialog] so the screenshot harness can * The editor's body. Extracted from [ProfileEditorDialog] so the screenshot harness can render
* render exactly these — a focused text field inside a Dialog window never reaches idle under * exactly these — a focused text field inside a Dialog window never reaches idle under Robolectric,
* Robolectric, so the dialog itself is uncapturable, and an eyeballed-only layout is how this * so the dialog itself is uncapturable, and an eyeballed-only layout is how this shipped once with
* shipped with the field and its caption touching. * the field and its caption touching.
*/ */
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun ProfileNameFields(name: String, duplicate: Boolean, onNameChange: (String) -> Unit) { internal fun ProfileEditorFields(
name: String,
accent: String?,
duplicate: Boolean,
creating: Boolean,
onNameChange: (String) -> Unit,
onAccentChange: (String?) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField( OutlinedTextField(
value = name, value = name,
@@ -176,12 +212,11 @@ internal fun ProfileNameFields(name: String, duplicate: Boolean, onNameChange: (
isError = duplicate, isError = duplicate,
) )
Text( Text(
if (duplicate) { when {
"A profile called “${name.trim()}” already exists." duplicate -> "A profile called “${name.trim()}” already exists."
} else { creating -> "A profile starts out inheriting every default setting. Whatever you " +
"A profile starts out inheriting every default setting. Whatever you " + "change while it's selected becomes an override."
"change while it's selected becomes an override; everything else " + else -> "The colour marks this profile on host cards, where its name doesn't fit."
"keeps following the defaults."
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (duplicate) { color = if (duplicate) {
@@ -190,53 +225,31 @@ internal fun ProfileNameFields(name: String, duplicate: Boolean, onNameChange: (
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
) )
} FlowRow(
} horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
/** ) {
* Pick a profile's chip colour. The accent is the whole signal on the surfaces where a profile has PROFILE_ACCENTS.forEach { hex ->
* no room for its name — a bound host card's chip, a pinned card's tint — so it is worth being able Swatch(
* to choose rather than take whatever creation handed out. colour = accentColor(hex),
*/ selected = accent?.equals(hex, ignoreCase = true) == true,
@OptIn(ExperimentalLayoutApi::class) onClick = { onAccentChange(hex) },
@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) })
} }
}, // "No colour" is a real choice, not only an initial state: the chip then falls back to
confirmButton = {}, // the theme's own accent, which is what a profile made before colours existed shows.
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, Swatch(colour = null, selected = accent == null, onClick = { onAccentChange(null) })
) }
}
} }
/** One colour choice: a filled disc, ringed when it is the current one. `null` = no colour. */ /** One colour choice: a filled disc, ringed when it is the current one. `null` = no colour. */
@Composable @Composable
private fun Swatch(colour: Color?, selected: Boolean, onClick: () -> Unit) { internal fun Swatch(colour: Color?, selected: Boolean, onClick: () -> Unit) {
val fill = colour ?: MaterialTheme.colorScheme.surfaceVariant val fill = colour ?: MaterialTheme.colorScheme.surfaceVariant
Box( Box(
Modifier Modifier
.size(40.dp) .size(36.dp)
.clip(CircleShape) .clip(CircleShape)
.background(fill) .background(fill)
.then( .then(
@@ -129,9 +129,8 @@ fun SettingsScreen(
var showLicenses by remember { mutableStateOf(false) } var showLicenses by remember { mutableStateOf(false) }
var showControllers by remember { mutableStateOf(false) } var showControllers by remember { mutableStateOf(false) }
var naming by remember { mutableStateOf<NameIntent?>(null) } var editing by remember { mutableStateOf<EditIntent?>(null) }
var deleting by remember { mutableStateOf<StreamProfile?>(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 // 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. // row the profile doesn't override reads as the live global — and keeps following it.
@@ -194,21 +193,16 @@ fun SettingsScreen(
// About has no profileable rows at all; don't strand the pane on it. // About has no profileable rows at all; don't strand the pane on it.
if (id != null && selected?.profileable == false) selectedName = null if (id != null && selected?.profileable == false) selectedName = null
}, },
onNew = { naming = NameIntent.New }, onNew = { editing = EditIntent.New(nextAccent(profiles)) },
onRename = { active?.let { naming = NameIntent.Rename(it) } }, onEdit = { p -> editing = EditIntent.Existing(p) },
onDuplicate = { onDuplicate = { p ->
active?.let { p -> val copy = newProfile(uniqueName(profileStore, p.name), p.accent)
val copy = newProfile(uniqueName(profileStore, p.name)).copy( .copy(overrides = p.overrides)
accent = p.accent, profileStore.save(copy)
overrides = p.overrides, profiles = profileStore.all()
) scopeId = copy.id
profileStore.save(copy)
profiles = profileStore.all()
scopeId = copy.id
}
}, },
onRecolour = { recolouring = active }, onDelete = { p -> deleting = p },
onDelete = { deleting = active },
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),
) )
if (active != null) { if (active != null) {
@@ -303,33 +297,24 @@ fun SettingsScreen(
} }
} }
naming?.let { intent -> editing?.let { intent ->
val editing = (intent as? NameIntent.Rename)?.profile val existing = (intent as? EditIntent.Existing)?.profile
ProfileNameDialog( ProfileEditorDialog(
title = if (editing == null) "New profile" else "Rename profile", title = if (existing == null) "New profile" else "Edit profile",
initialName = editing?.name.orEmpty(), confirmLabel = if (existing == null) "Create" else "Save",
confirmLabel = if (editing == null) "Create" else "Rename", initialName = existing?.name.orEmpty(),
taken = { profileStore.nameTaken(it, except = editing?.id) }, initialAccent = existing?.accent ?: (intent as? EditIntent.New)?.accent,
onConfirm = { name -> creating = existing == null,
val saved = editing?.copy(name = name) ?: newProfile(name, nextAccent(profiles)) taken = { profileStore.nameTaken(it, except = existing?.id) },
onConfirm = { name, accent ->
val saved = existing?.copy(name = name, accent = accent)
?: newProfile(name, accent)
profileStore.save(saved) profileStore.save(saved)
profiles = profileStore.all() profiles = profileStore.all()
scopeId = saved.id scopeId = saved.id
naming = null editing = null
}, },
onDismiss = { naming = null }, onDismiss = { editing = null },
)
}
recolouring?.let { profile ->
ProfileColourDialog(
profile = profile,
onPick = { hex ->
profileStore.save(profile.copy(accent = hex))
profiles = profileStore.all()
recolouring = null
},
onDismiss = { recolouring = null },
) )
} }
@@ -353,10 +338,11 @@ fun SettingsScreen(
} }
} }
/** What the name dialog is for. */ /** What the profile editor is for — a fresh profile (with the colour creation picked out for it),
private sealed interface NameIntent { * or one that already exists. */
data object New : NameIntent private sealed interface EditIntent {
data class Rename(val profile: StreamProfile) : NameIntent data class New(val accent: String) : EditIntent
data class Existing(val profile: StreamProfile) : EditIntent
} }
/** "Work" → "Work copy" → "Work copy 2" — the first name Duplicate can actually save. */ /** "Work" → "Work copy" → "Work copy 2" — the first name Duplicate can actually save. */
@@ -37,7 +37,7 @@ import io.unom.punktfunk.SettingsCategory
import io.unom.punktfunk.SettingsScreen import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.StatsVerbosity import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.ProfileNameFields import io.unom.punktfunk.ProfileEditorFields
import io.unom.punktfunk.ProfileStore import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.SettingsOverlay import io.unom.punktfunk.SettingsOverlay
import io.unom.punktfunk.SpeedTestDialog import io.unom.punktfunk.SpeedTestDialog
@@ -255,10 +255,24 @@ internal fun NewProfileScene() {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("New profile", style = MaterialTheme.typography.headlineSmall) Text("New profile", style = MaterialTheme.typography.headlineSmall)
// The dialog's own body, not a rebuild of it — the spacing under test is the real one. // The dialog's own body, not a rebuild of it — the layout under test is the real one.
ProfileNameFields(name = "Travel", duplicate = false, onNameChange = {}) ProfileEditorFields(
name = "Travel",
accent = "#60A5FA",
duplicate = false,
creating = true,
onNameChange = {},
onAccentChange = {},
)
Text("Duplicate name", style = MaterialTheme.typography.headlineSmall) Text("Duplicate name", style = MaterialTheme.typography.headlineSmall)
ProfileNameFields(name = "Game", duplicate = true, onNameChange = {}) ProfileEditorFields(
name = "Game",
accent = "#FF8A4C",
duplicate = true,
creating = false,
onNameChange = {},
onAccentChange = {},
)
} }
} }
} }