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 a08cbd77..671fa5be 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 @@ -11,15 +11,20 @@ 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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults @@ -225,47 +230,140 @@ internal fun ProfileEditorFields( MaterialTheme.colorScheme.onSurfaceVariant }, ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + Text( + "Colour", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 4.dp), + ) + // A fixed 4×2 grid rather than a flow: eight colours wrapping to whatever fits the dialog + // landed 6-then-2, which reads as a mistake. Two even rows read as a palette. The order is + // the hue sweep from PROFILE_ACCENTS, so it looks like a spectrum rather than a bag. + // + // Each row FILLS the width, its swatches sharing it equally, so the palette's edges line up + // with the name field above it and every row is the same length. A fixed swatch size left + // the rows short of the dialog's edge and wrapped unevenly, which read as the grid having + // run out rather than as a deliberate block. + Column( + verticalArrangement = Arrangement.spacedBy(SWATCH_GAP), + modifier = Modifier.fillMaxWidth(), ) { - PROFILE_ACCENTS.forEach { hex -> - Swatch( - colour = accentColor(hex), - selected = accent?.equals(hex, ignoreCase = true) == true, - onClick = { onAccentChange(hex) }, - ) + PROFILE_ACCENTS.chunked(SWATCHES_PER_ROW).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(SWATCH_GAP), + ) { + row.forEach { hex -> + Swatch( + colour = accentColor(hex), + selected = accent?.equals(hex, ignoreCase = true) == true, + onClick = { onAccentChange(hex) }, + modifier = Modifier.weight(1f), + ) + } + } } - // "No colour" is a real choice, not only an initial state: the chip then falls back to - // the theme's own accent, which is what a profile made before colours existed shows. + } + // "No colour" is a real choice, not only an initial state — the chip then falls back to the + // theme's own accent, which is what a profile made before colours existed shows. It sits + // apart from the grid and says so in words, rather than hiding as a ninth, colourless + // circle that breaks the palette's rhythm. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { onAccentChange(null) } + .padding(vertical = 4.dp), + ) { Swatch(colour = null, selected = accent == null, onClick = { onAccentChange(null) }) + Text( + "No colour", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp), + ) } } } -/** One colour choice: a filled disc, ringed when it is the current one. `null` = no colour. */ +/** + * One colour choice. The selected one keeps its size and grows a ring OUTSIDE the disc with a gap + * between the two, plus a check — a border drawn on the disc's own edge reads as a heavier circle + * rather than as a selection, and colour-plus-check survives a reader who can't tell two of these + * hues apart. The ring's space is always reserved, so picking never nudges the grid. + * + * `null` is "no colour": the surface's own variant, outlined so it reads as an empty slot rather + * than a dark swatch. + */ @Composable -internal fun Swatch(colour: Color?, selected: Boolean, onClick: () -> Unit) { +internal fun Swatch( + colour: Color?, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier.size(SWATCH_TOTAL), +) { val fill = colour ?: MaterialTheme.colorScheme.surfaceVariant Box( - Modifier - .size(36.dp) + modifier = modifier + .aspectRatio(1f) .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) + Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) } else { Modifier }, ) .clickable(onClick = onClick) .semantics { contentDescription = if (colour == null) "No colour" else "Colour" }, - ) + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + // Padding, not a fixed size: the disc has to scale with a swatch that shares its + // row's width, while the gap that makes the selection ring read stays constant. + .fillMaxSize() + .padding(RING_GAP) + .clip(CircleShape) + .background(fill) + .then( + if (colour == null) { + Modifier.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + // These hues are all light enough that a near-black check is the readable one; + // the empty slot is dark, so it takes the surface's foreground instead. + tint = if (colour == null) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + Color(0xFF1B1633) + }, + ) + } + } + } } +/** + * The palette's geometry. [SWATCHES_PER_ROW] divides [PROFILE_ACCENTS] exactly — that is the whole + * reason the palette has ten colours — so both rows are full. [SWATCH_TOTAL] is only the fallback + * footprint for a swatch outside the grid (the "no colour" one); in the grid a swatch takes an + * equal share of the row instead. + */ +private const val SWATCHES_PER_ROW = 5 +private val SWATCH_TOTAL = 44.dp +private val RING_GAP = 5.dp +private val SWATCH_GAP = 10.dp + /** * 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 d92cc1ee..cf6fb2fb 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 @@ -323,20 +323,28 @@ 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. + * Chip colours a profile can wear. 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 — and held at one saturation and lightness so no single swatch shouts + * over its neighbours. Deliberately NOT the presence green ([HostCard]'s online dot), which means + * something else entirely. + * + * **Ordered by hue**, so the picker reads as one sweep of the colour wheel rather than a bag of + * colours; the degrees are in the comments to keep it that way when one is swapped out. That order + * is also the order [nextAccent] hands them out in, so a user creating profiles one after another + * walks the spectrum instead of getting an arbitrary sequence. */ val PROFILE_ACCENTS = listOf( - "#FF8A4C", // orange - "#60A5FA", // blue - "#F472B6", // pink - "#34D399", // green - "#FBBF24", // amber - "#A78BFA", // violet - "#22D3EE", // cyan - "#FB7185", // rose + "#FF8A4C", // orange 21° + "#FBBF24", // amber 45° + "#A3E635", // lime 82° + "#34D399", // green 160° + "#22D3EE", // cyan 187° + "#60A5FA", // blue 213° + "#818CF8", // indigo 239° + "#A78BFA", // violet 258° + "#F472B6", // pink 330° + "#FB7185", // rose 350° ) /** The first accent no existing profile is using, so two profiles don't look alike by accident. */ diff --git a/clients/android/build.gradle.kts b/clients/android/build.gradle.kts index 4165f6b9..71d5a753 100644 --- a/clients/android/build.gradle.kts +++ b/clients/android/build.gradle.kts @@ -5,7 +5,7 @@ // Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM // 2026.05.01 · compileSdk 37 · targetSdk 37 · minSdk 28. plugins { - id("com.android.application") version "9.2.1" apply false - id("com.android.library") version "9.2.1" apply false + id("com.android.application") version "9.3.1" apply false + id("com.android.library") version "9.3.1" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false } diff --git a/clients/android/gradle/wrapper/gradle-wrapper.properties b/clients/android/gradle/wrapper/gradle-wrapper.properties index ff6c715d..b52fb7e7 100644 --- a/clients/android/gradle/wrapper/gradle-wrapper.properties +++ b/clients/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500