From 8afe55c6da7df642ed5e5c7b8fe4d0a80cb62174 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:25:17 -0400 Subject: [PATCH] feat(fields): slider custom fields, and stop calling an unknown type an error The server and the web app gained a slider field type; this is the Android side of it. Two kinds, as the server defines them: labeled, a fixed 0 to 100 between two words that reads as a percentage, and numeric, a quantity in the owner's own range with a step and a unit. Either can paint a gradient across the track. Settings gets the full setup with a live preview, the member editor gets the control, and a member's profile shows the reading. The reading is produced the same way the server produces it for a public profile, so a slider reads the same string wherever it appears. Three things worth saying about the shape of it: A slider's options are the one body this client sends that the server REPLACES rather than merges. A key left out is a key reset to its default, which is how a label, a unit or a gradient is cleared, since there is no explicit null to send. That makes Moshi's omit-nulls exactly right here and makes it the opposite of the PATCH bodies next door, so the wire shape is pinned by tests at the JSON rather than at the data class: a stray `choices` riding along on a slider would 422 the whole save. The track is drawn rather than left to Material. A gradient cannot be expressed as a track colour, so a gradient slider would otherwise be a different shape from one without, and the two sit next to each other in the same member's fields. Material's slider is Float-valued, and dragging to the 85 stop on a 0 to 100 track stored 85.00000762939453 while the reading said "85%". Values now snap to the step grid, or to the two decimals the reading shows, so the number stored is the number the user was shown. Separately, a field whose type the server knows and this build does not showed "Unknown field type" in red where its value should be, hiding what a member had put there behind something that looked like a fault. The API asks clients to treat an unknown type as text, so it now shows the value with a note to update the app. That is also why a slider field on an older build looks broken rather than merely unsupported. Verified against a server with the type: creating a labeled slider from the phone stores the canonical options, renaming the field leaves its shape intact, clearing a label and the gradient clears both and leaves the other label alone, and a dragged value lands as a clean number. --- CHANGELOG.md | 21 + .../systems/lupine/sheaf/data/model/Models.kt | 33 +- .../lupine/sheaf/ui/components/SliderField.kt | 410 +++++++++++++++ .../sheaf/ui/fields/CustomFieldsScreen.kt | 497 +++++++++++++++++- .../sheaf/ui/fields/CustomFieldsViewModel.kt | 54 +- .../sheaf/ui/members/CustomFieldEditor.kt | 52 +- .../sheaf/data/model/SliderOptionsWireTest.kt | 149 ++++++ .../sheaf/ui/components/SliderFieldTest.kt | 165 ++++++ .../ui/fields/SliderFieldDefinitionTest.kt | 116 ++++ .../ui/members/CustomFieldValueDisplayTest.kt | 73 +++ 10 files changed, 1525 insertions(+), 45 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/SliderField.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/data/model/SliderOptionsWireTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/SliderFieldTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/fields/SliderFieldDefinitionTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/members/CustomFieldValueDisplayTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f82cd3..e9238fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). ### Added +- **Slider custom fields.** A field can now be a slider, matching the type the + server and the web app gained: either a labelled one from 0 to 100 between + two words of your choosing ("Drained" to "Wired", read as a percentage), or a + number with its own range, step and unit ("172.5 cm"). Either can paint a + colour gradient across the track, picked from nine named ones or built from + your own colours with the colour picker the app already uses for members and + tags. Settings gets the whole setup with a live preview; the member editor + gets a draggable track with the reading beside it and a tap to clear; a + member's profile shows the reading. The reading is the same string the server + puts on a public profile, so a slider reads the same everywhere. + - **Announcements can be aimed at particular clients.** Admin only. An announcement can now be shown only on Web, Android, iOS or the watch, with a "Show on" section in the editor and a marker in the list; leaving it unticked @@ -15,6 +26,16 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). announcement aimed at Android reaches it. Needs a server that supports targeting; on an older one everything carries on as it was. +### Fixed + +- **A field type this build has not heard of no longer reads as an error.** A + custom field whose type the server knows and the app does not showed "Unknown + field type" in red where its value should be, so the value a member had put + there was hidden by a message that looked like a fault. It now shows the value + as text with a note to update the app, which is what the API asks clients to + do, and is why a slider field on an older build will have looked broken rather + than merely unsupported. + ## [1.5.0] - 2026-10-12 ### Added diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index a00ee6b..f21bfa1 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -804,15 +804,43 @@ data class TagDeletePending( // ── Custom Fields ───────────────────────────────────────────────────────────── +/** The two ends of a slider, and optionally its middle. Each up to 50 chars. */ +@JsonClass(generateAdapter = true) +data class SliderLabels( + val left: String? = null, + val center: String? = null, + val right: String? = null, +) + /** * Per-field-type options. For SELECT / MULTISELECT, [choices] carries * the predefined values the user can pick from; null = freeform tag - * mode (any string accepted server-side). Other field types don't - * carry options today. + * mode (any string accepted server-side). + * + * The rest belong to SLIDER, which the server always stores in full canonical + * shape, so every key below comes back populated on a read. On a write the + * rules are the opposite of the PATCH bodies next door: the server rebuilds a + * slider's options from scratch rather than merging, so a key left out means + * "the default for that key", not "leave it alone". Moshi omitting nulls is + * therefore exactly right here, and it is what clears a label, a unit or a + * gradient. Do not reach for a hand-written adapter: it would send explicit + * nulls, which say the same thing, less clearly. + * + * [mode] is "labeled" (fixed 0..100, read as a percentage) or "numeric" + * ([min]/[max] are the field's own, [step] may be null for any value, [unit] + * is shown after the number). [gradient] is null or 2 to 8 #RRGGBB colours + * painted left to right across the track. */ @JsonClass(generateAdapter = true) data class CustomFieldOptions( val choices: List? = null, + val mode: String? = null, + val min: Double? = null, + val max: Double? = null, + val step: Double? = null, + val unit: String? = null, + val labels: SliderLabels? = null, + val gradient: List? = null, ) @JsonClass(generateAdapter = true) @@ -868,6 +896,7 @@ data class CustomFieldUpdate( // boolean -> Boolean // select -> String (must be in choices when choices are set) // multiselect -> List (each must be in choices when set) +// slider -> Number, inside [options.min, options.max], never a string // // Members the viewer isn't permitted to see fields for return an // empty list; the per-field privacy gate is enforced server-side. diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/SliderField.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/SliderField.kt new file mode 100644 index 0000000..3abe139 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/SliderField.kt @@ -0,0 +1,410 @@ +package systems.lupine.sheaf.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import systems.lupine.sheaf.data.model.CustomFieldOptions +import systems.lupine.sheaf.data.model.SliderLabels +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * The slider custom field type: the control for editing a value and the + * read-only bar for showing one. + * + * Two modes, as the server defines them (sheaf/services/slider_fields.py): + * `labeled` is 0..100 between a left and a right label and reads as a + * percentage; `numeric` is a quantity between min and max with an optional + * unit. Either can paint a gradient across the track. + * + * The reading this produces is the same string the server puts in `value` on + * a public profile, so a slider reads identically wherever it is shown. + */ + +const val SLIDER_MODE_LABELED = "labeled" +const val SLIDER_MODE_NUMERIC = "numeric" + +/** A slider's shape, every key resolved. */ +data class SliderSpec( + val mode: String, + val min: Double, + val max: Double, + val step: Double?, + val unit: String?, + val labels: SliderLabels, + val gradient: List?, +) { + val isNumeric: Boolean get() = mode == SLIDER_MODE_NUMERIC + val span: Double get() = max - min + val midpoint: Double get() = min + span / 2 + val hasAnyLabel: Boolean + get() = !labels.left.isNullOrBlank() || + !labels.center.isNullOrBlank() || + !labels.right.isNullOrBlank() +} + +/** + * Resolve a field's options into a complete spec. + * + * The server fills every key in, but a slider is also the one field type + * whose control cannot be drawn at all from a partial answer, so this reads + * defensively: a hand-written options object, or a row from an instance older + * than some key, still yields a track rather than a crash or a zero-width + * bar. The defaults are the server's own. + */ +fun sliderSpec(options: CustomFieldOptions?): SliderSpec { + val numeric = options?.mode == SLIDER_MODE_NUMERIC + val min = if (numeric) options?.min ?: 0.0 else 0.0 + val max = if (numeric) options?.max ?: 10.0 else 100.0 + return SliderSpec( + mode = if (numeric) SLIDER_MODE_NUMERIC else SLIDER_MODE_LABELED, + // A min at or above max would give the control an empty or inverted + // range to divide by, so fall back to the mode's own bounds. + min = if (max > min) min else 0.0, + max = if (max > min) max else if (numeric) 10.0 else 100.0, + step = options?.step?.takeIf { it > 0 }, + unit = if (numeric) options?.unit?.takeIf { it.isNotBlank() } else null, + labels = options?.labels ?: SliderLabels(), + gradient = options?.gradient?.takeIf { it.size >= 2 }, + ) +} + +/** A new slider before anybody has configured it: the server's own defaults. */ +fun defaultSliderSpec(): SliderSpec = SliderSpec( + mode = SLIDER_MODE_LABELED, + min = 0.0, + max = 100.0, + step = null, + unit = null, + labels = SliderLabels(), + gradient = null, +) + +/** + * The options to send for this slider. + * + * Only the keys that mean something for the mode: a labeled slider's bounds + * are the definition of the mode, so sending min/max there would be stating + * something the server is going to ignore. Everything left out takes the + * server's default, which is how a label, a unit or a gradient is cleared, + * because a slider's options are replaced wholesale rather than merged. + */ +fun SliderSpec.toOptions(): CustomFieldOptions { + val trimmed = SliderLabels( + left = labels.left?.trim()?.ifBlank { null }, + center = labels.center?.trim()?.ifBlank { null }, + right = labels.right?.trim()?.ifBlank { null }, + ) + val stops = gradient?.takeIf { it.size >= 2 } + return if (isNumeric) { + CustomFieldOptions( + mode = SLIDER_MODE_NUMERIC, + min = min, + max = max, + step = step, + unit = unit?.trim()?.ifBlank { null }, + labels = trimmed, + gradient = stops, + ) + } else { + CustomFieldOptions( + mode = SLIDER_MODE_LABELED, + labels = trimmed, + gradient = stops, + ) + } +} + +/** Trailing zeros are noise on a reading: 3.0 shows as 3, 2.5 stays 2.5. */ +private fun tidyNumber(value: Double): String { + val rounded = (value * 100).roundToInt() / 100.0 + return if (abs(rounded - rounded.toLong()) < 1e-9) { + rounded.toLong().toString() + } else { + rounded.toString() + } +} + +/** "62%" or "172.5 cm", the same reading the public profile serves. */ +fun formatSliderValue(value: Any?, spec: SliderSpec): String { + val number = (value as? Number)?.toDouble()?.takeIf { it.isFinite() } ?: return "" + val shown = tidyNumber(number) + if (!spec.isNumeric) return "$shown%" + return spec.unit?.let { "$shown $it" } ?: shown +} + +/** Where a value sits on the track, 0..1. */ +fun sliderFraction(value: Double, spec: SliderSpec): Float { + if (spec.span <= 0) return 0f + return ((value - spec.min) / spec.span).coerceIn(0.0, 1.0).toFloat() +} + +/** + * A slider's value as the control needs it: a number, or null when the field + * is unset or holds something that is not a number (a value carried over from + * when the field was text, say). Null reads as "not set" rather than as a + * confident zero, which would be a value the member never chose. + */ +fun sliderValueOrNull(value: Any?): Double? = + (value as? Number)?.toDouble()?.takeIf { it.isFinite() } + +/** + * The value to actually store for a position on the track. + * + * Material's slider works in `Float`, and dragging to the 85 stop on a 0..100 + * track yields 85.00000762939453 once widened to a `Double`. The server takes + * it, the reading still says "85%", and the number sitting in the database and + * in every export is noise, where the web app stores a clean 85. So: snap to + * the step grid when the field has one (which a labeled slider always does), + * and otherwise round to the two decimals the reading shows, so the stored + * number is the number the user was shown. + */ +fun snapSliderValue(value: Double, spec: SliderSpec): Double { + val step = spec.step + val snapped = if (step != null && step > 0) { + spec.min + ((value - spec.min) / step).roundToInt() * step + } else { + (value * 100).roundToInt() / 100.0 + } + return snapped.coerceIn(spec.min, spec.max) +} + +/** The gradient as a brush, or null when the field has no gradient. */ +private fun SliderSpec.trackBrush(): Brush? { + val colours = gradient?.mapNotNull { parseColor(it) }?.takeIf { it.size >= 2 } ?: return null + return Brush.horizontalGradient(colours) +} + +private val TRACK_HEIGHT = 8.dp +private val READING_WIDTH = 72.dp + +/** + * The ends of the track in words. A numeric slider with no labels of its own + * shows its bounds instead, so the numbers a value sits between are always + * visible somewhere; a labeled one with no labels shows nothing, because + * "0" and "100" say less than the reading beside the track already does. + */ +@Composable +private fun EndLabels(spec: SliderSpec) { + val (left, right) = if (spec.isNumeric && !spec.hasAnyLabel) { + val unit = spec.unit?.let { " $it" } ?: "" + tidyNumber(spec.min) + unit to tidyNumber(spec.max) + unit + } else { + if (!spec.hasAnyLabel) return + spec.labels.left.orEmpty() to spec.labels.right.orEmpty() + } + Row(modifier = Modifier.fillMaxWidth()) { + EndLabel(left, Modifier.weight(1f), TextAlign.Start) + spec.labels.center?.takeIf { it.isNotBlank() }?.let { + EndLabel(it, Modifier.weight(1f), TextAlign.Center) + } + EndLabel(right, Modifier.weight(1f), TextAlign.End) + } +} + +@Composable +private fun EndLabel(text: String, modifier: Modifier, align: TextAlign) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = align, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + +/** + * Editor. [value] is the current number or null for unset; [onChange] gets the + * next number, or null when the value is cleared. + * + * An unset slider parks its handle at the midpoint and dims the control, the + * same way the web one does: the handle has to be somewhere, and the middle is + * the one position that does not read as an answer. The reading beside it says + * "not set" until the user moves it, so parking is never mistaken for a value. + */ +@Composable +fun SliderInput( + label: String, + value: Double?, + spec: SliderSpec, + onChange: (Double?) -> Unit, + modifier: Modifier = Modifier, +) { + val current = value ?: spec.midpoint + val brush = spec.trackBrush() + // Material's Slider counts the gaps between stops, not the stops. A step + // that does not divide the range leaves the last stop short of max, so an + // unrepresentable step is better dropped than silently capping the range. + val steps = spec.step + ?.takeIf { it > 0 && spec.span > 0 } + ?.let { step -> + val gaps = spec.span / step + if (abs(gaps - gaps.roundToInt()) < 1e-9) (gaps.roundToInt() - 1).coerceAtLeast(0) else 0 + } + ?: 0 + val reading = if (value != null) formatSliderValue(value, spec) else "not set" + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.labelLarge) + Row(verticalAlignment = Alignment.CenterVertically) { + Slider( + value = current.toFloat(), + onValueChange = { onChange(snapSliderValue(it.toDouble(), spec)) }, + valueRange = spec.min.toFloat()..spec.max.toFloat(), + steps = steps, + // The track is drawn here rather than left to Material for one + // reason: a gradient cannot be expressed as a track colour, so + // a gradient slider would otherwise be a different SHAPE from + // one without, and the two sit next to each other in the same + // member's fields. Same bar either way, and the same bar the + // read-only readout and the settings preview draw. + track = { + SliderTrack( + fraction = sliderFraction(current, spec), + brush = brush, + dimmed = value == null, + ) + }, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "$label, $reading" }, + ) + Text( + reading, + style = MaterialTheme.typography.bodyMedium, + color = if (value != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(READING_WIDTH), + ) + if (value != null) { + IconButton(onClick = { onChange(null) }) { + Icon( + Icons.Default.Close, + contentDescription = "Clear $label", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + EndLabels(spec) + } +} + +/** + * The bar itself: the gradient when the field has one, otherwise the usual + * filled-to-here track. Dimmed while the value is unset, so a parked handle + * does not read as an answer. + */ +@Composable +private fun SliderTrack(fraction: Float, brush: Brush?, dimmed: Boolean) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(TRACK_HEIGHT) + .alpha(if (dimmed) 0.6f else 1f) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + if (brush != null) { + Box(Modifier.matchParentSize().background(brush)) + } else { + Box( + Modifier + .fillMaxWidth(fraction) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.primary), + ) + } + } +} + +/** Read-only: the bar with a marker at the value, and the reading beside it. */ +@Composable +fun SliderReadout( + value: Any?, + spec: SliderSpec, + modifier: Modifier = Modifier, +) { + val number = sliderValueOrNull(value) ?: return + val fraction = sliderFraction(number, spec) + val brush = spec.trackBrush() + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val markerSize = 14.dp + Box( + modifier = Modifier + .fillMaxWidth() + .height(TRACK_HEIGHT) + .clip(CircleShape) + .then( + if (brush != null) { + Modifier.background(brush) + } else { + Modifier.background(MaterialTheme.colorScheme.surfaceVariant) + }, + ), + ) + Box( + modifier = Modifier + .offset( + // Inset by half the marker so it stays on the bar + // at both ends rather than hanging off them. + x = (maxWidth - markerSize) * fraction, + y = (TRACK_HEIGHT - markerSize) / 2, + ) + .size(markerSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface), + ) + } + Spacer(Modifier.width(8.dp)) + Text( + formatSliderValue(number, spec), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(READING_WIDTH), + ) + } + EndLabels(spec) + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt index 24b6bfd..14ff7c8 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt @@ -2,8 +2,11 @@ package systems.lupine.sheaf.ui.fields +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -22,7 +25,10 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -40,11 +46,27 @@ private fun fieldTypeIcon(fieldType: String): ImageVector = when (fieldType) { "boolean" -> Icons.Outlined.ToggleOn "select" -> Icons.AutoMirrored.Outlined.List "multiselect" -> Icons.Outlined.Checklist + "slider" -> Icons.Outlined.Tune else -> Icons.AutoMirrored.Outlined.List } private val privacyOptions = listOf("private", "friends", "public") -private val fieldTypeOptions = listOf("text", "number", "date", "boolean", "select", "multiselect") +private val fieldTypeOptions = + listOf("text", "number", "date", "boolean", "select", "multiselect", "slider") + +/** + * The type names as the web card labels them, so the two screens do not call + * the same field two different things. + */ +private fun fieldTypeLabel(fieldType: String): String = when (fieldType) { + "boolean" -> "Yes/No" + "select" -> "Select (single)" + "multiselect" -> "Multi-select" + else -> fieldType.replaceFirstChar { it.uppercase() } +} + +private fun isChoiceType(fieldType: String?) = + fieldType == "select" || fieldType == "multiselect" // ── Screen ──────────────────────────────────────────────────────────────────── @@ -148,8 +170,8 @@ fun CustomFieldsScreen( // nothing, which the server refuses on create too. offersPublic = state.raiseGate.offersPublic(null), onDismiss = { showAddSheet = false }, - onSave = { name, fieldType, privacy, choices -> - viewModel.createField(name, fieldType, privacy, choices) + onSave = { name, fieldType, privacy, choices, slider -> + viewModel.createField(name, fieldType, privacy, choices, slider) showAddSheet = false }, ) @@ -163,13 +185,14 @@ fun CustomFieldsScreen( isSaving = state.isSaving, offersPublic = state.raiseGate.offersPublic(field.privacy), onDismiss = { editingField = null }, - onSave = { name, privacy, choices -> + onSave = { name, privacy, choices, slider -> viewModel.updateField( id = field.id, name = name, privacy = privacy, choices = choices, fieldType = field.fieldType, + slider = slider, ) editingField = null }, @@ -239,7 +262,7 @@ private fun FieldListItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(field.fieldTypeDisplay, style = MaterialTheme.typography.bodySmall) + Text(fieldTypeLabel(field.fieldType), style = MaterialTheme.typography.bodySmall) // The same tag the member editor and the member profile // show, so the three places a field's level appears cannot // disagree about what it is or what it is called. @@ -252,7 +275,7 @@ private fun FieldListItem( leadingContent = { Icon( imageVector = fieldTypeIcon(field.fieldType), - contentDescription = field.fieldTypeDisplay, + contentDescription = fieldTypeLabel(field.fieldType), tint = MaterialTheme.colorScheme.primary, ) }, @@ -357,6 +380,415 @@ private fun ChoicesEditor( } } +// ── Slider options editor ───────────────────────────────────────────────────── + +/** + * Gradients offered by name, the same ids, labels and stops the web card + * offers. The id is not stored anywhere (the options carry the colours + * themselves), but keeping the lists identical means a gradient picked on one + * client still shows as its name on the other instead of as "Custom colours". + */ +private val gradientPresets: List>> = listOf( + Triple("none", "No gradient", emptyList()), + Triple("cool-warm", "Cool to warm", listOf("#5B92C9", "#E08D5E")), + Triple("calm-intense", "Calm to intense", listOf("#7BAA7E", "#D6534D")), + Triple("sad-happy", "Low to high", listOf("#5479AE", "#E4C44F")), + Triple("soft-hard", "Soft to hard", listOf("#F4EBD8", "#A89B8C", "#3A2E4D")), + Triple("day-night", "Day to night", listOf("#F4D86E", "#1E2444")), + Triple("rose-dusk", "Rose dusk", listOf("#F4B89E", "#D17A8E", "#5B4378")), + Triple("sage-meadow", "Sage meadow", listOf("#C5D9B5", "#4A6B47")), + Triple("monochrome", "Monochrome", listOf("#E4DEEC", "#3A2E4D")), + Triple("custom", "Custom colours", emptyList()), +) + +private const val CUSTOM_GRADIENT_ID = "custom" +private val defaultCustomGradient = listOf("#5B92C9", "#E08D5E") +private const val MAX_GRADIENT_STOPS = 8 + +/** + * What the server would refuse about this slider's bounds, said in the words + * the person setting it up would use, or null when it is fine. + * + * Only the numeric kind can be wrong this way: a labeled slider's range is + * fixed at 0 to 100 and is not the owner's to get wrong. Checked here so the + * Save button can wait instead of sending a request whose 422 would surface as + * a generic "Couldn't save this field". + */ +internal fun sliderProblem(spec: SliderSpec?): String? { + if (spec == null || !spec.isNumeric) return null + if (spec.max <= spec.min) return "Max has to be more than min." + val step = spec.step ?: return null + if (step <= 0) return "Step has to be more than zero." + if (step > spec.max - spec.min) return "Step cannot be bigger than the range." + return null +} + +/** Whether a field is safe to save, as far as its slider bounds go. */ +internal fun sliderBoundsOk(fieldType: String?, slider: SliderSpec?): Boolean = + fieldType != "slider" || sliderProblem(slider) == null + +/** Which preset a set of stops is, or "custom" when it matches none. */ +internal fun gradientPresetId(gradient: List?): String { + if (gradient.isNullOrEmpty()) return "none" + val normalised = gradient.map { it.uppercase() } + return gradientPresets + .firstOrNull { (id, _, stops) -> + id != CUSTOM_GRADIENT_ID && stops.isNotEmpty() && stops == normalised + } + ?.first + ?: CUSTOM_GRADIENT_ID +} + +/** A gradient as a swatch, for the preset rows. */ +@Composable +private fun GradientSwatch(stops: List, modifier: Modifier = Modifier) { + val colours = stops.mapNotNull { parseColor(it) } + if (colours.size < 2) return + Box( + modifier = modifier + .width(40.dp) + .height(12.dp) + .clip(CircleShape) + .background(Brush.horizontalGradient(colours)), + ) +} + +/** + * Everything a slider field needs: kind, bounds, labels, track colours, and a + * live preview of the result. + * + * Laid out down the screen rather than across it. The web card puts the three + * label fields and the four numeric fields in grids, which a phone does not + * have the width for: three text fields across a phone is how the field list + * ended up spelling names vertically. Same controls, same order, one per line. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SliderOptionsEditor( + value: SliderSpec, + onChange: (SliderSpec) -> Unit, +) { + // "Custom colours" starts from whatever stops are showing, so without this + // flag the picker would snap straight back to the preset those stops match + // and the swatches would never appear. + var customColours by remember { mutableStateOf(false) } + var gradientMenuOpen by remember { mutableStateOf(false) } + var editingColourIndex by remember { mutableStateOf(null) } + + val preset = if (customColours && value.gradient != null) { + CUSTOM_GRADIENT_ID + } else { + gradientPresetId(value.gradient) + } + val presetLabel = gradientPresets.firstOrNull { it.first == preset }?.second.orEmpty() + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "Kind", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // Switching kind resets the bounds that belong to the other one, + // the same way the web card does: a numeric slider that kept a + // labeled one's 0..100 would claim an operator chose that range. + KindRow( + label = "Between two labels (0 to 100)", + selected = !value.isNumeric, + onClick = { + onChange( + value.copy( + mode = SLIDER_MODE_LABELED, + min = 0.0, + max = 100.0, + step = null, + unit = null, + ), + ) + }, + ) + KindRow( + label = "A number with a range", + selected = value.isNumeric, + onClick = { + onChange( + value.copy( + mode = SLIDER_MODE_NUMERIC, + min = 0.0, + max = 10.0, + step = null, + ), + ) + }, + ) + } + + if (value.isNumeric) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + NumberBox( + label = "Min", + value = value.min, + onChange = { onChange(value.copy(min = it ?: 0.0)) }, + modifier = Modifier.weight(1f), + ) + NumberBox( + label = "Max", + value = value.max, + onChange = { onChange(value.copy(max = it ?: 0.0)) }, + modifier = Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + NumberBox( + label = "Step", + value = value.step, + placeholder = "any", + onChange = { onChange(value.copy(step = it)) }, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = value.unit.orEmpty(), + onValueChange = { + onChange(value.copy(unit = it.ifBlank { null })) + }, + label = { Text("Unit") }, + placeholder = { Text("e.g. cm") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + sliderProblem(value)?.let { problem -> + Text( + problem, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + listOf( + Triple("Left label", value.labels.left, if (value.isNumeric) "optional" else "e.g. Calm"), + Triple("Centre label", value.labels.center, "optional"), + Triple("Right label", value.labels.right, if (value.isNumeric) "optional" else "e.g. Intense"), + ).forEachIndexed { index, (label, current, hint) -> + OutlinedTextField( + value = current.orEmpty(), + onValueChange = { text -> + val next = text.take(50).ifBlank { null } + onChange( + value.copy( + labels = when (index) { + 0 -> value.labels.copy(left = next) + 1 -> value.labels.copy(center = next) + else -> value.labels.copy(right = next) + }, + ), + ) + }, + label = { Text(label) }, + placeholder = { Text(hint) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "Track colours", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ExposedDropdownMenuBox( + expanded = gradientMenuOpen, + onExpandedChange = { gradientMenuOpen = it }, + ) { + OutlinedTextField( + value = presetLabel, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = gradientMenuOpen) + }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = gradientMenuOpen, + onDismissRequest = { gradientMenuOpen = false }, + ) { + gradientPresets.forEach { (id, label, stops) -> + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + GradientSwatch(stops) + Text(label) + } + }, + onClick = { + customColours = id == CUSTOM_GRADIENT_ID + onChange( + when (id) { + "none" -> value.copy(gradient = null) + CUSTOM_GRADIENT_ID -> value.copy( + gradient = value.gradient ?: defaultCustomGradient, + ) + else -> value.copy(gradient = stops) + }, + ) + gradientMenuOpen = false + }, + ) + } + } + } + + if (preset == CUSTOM_GRADIENT_ID) { + val stops = value.gradient ?: defaultCustomGradient + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + stops.forEachIndexed { index, colour -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Surface( + onClick = { editingColourIndex = index }, + color = MaterialTheme.colorScheme.surface, + ) { + Box(Modifier.padding(2.dp)) { + ColorSwatch(colour, size = 32.dp) + } + } + // Two is the floor the server enforces, so the + // remove button only exists above it. + if (stops.size > 2) { + IconButton( + onClick = { + onChange( + value.copy( + gradient = stops.filterIndexed { i, _ -> i != index }, + ), + ) + }, + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove colour ${index + 1}", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + if (stops.size < MAX_GRADIENT_STOPS) { + TextButton(onClick = { onChange(value.copy(gradient = stops + "#888888")) }) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text("Add colour") + } + } + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "Preview", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // The same 62% of the way along the web card previews at, so a + // gradient looks the same on both while it is being picked. + val previewSpec = sliderSpec(value.toOptions()) + SliderReadout( + value = previewSpec.min + previewSpec.span * 0.62, + spec = previewSpec, + ) + } + } + + editingColourIndex?.let { index -> + val stops = value.gradient ?: defaultCustomGradient + val current = stops.getOrNull(index) ?: return@let + AlertDialog( + onDismissRequest = { editingColourIndex = null }, + title = { Text("Colour ${index + 1}") }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + ColorPicker( + hex = current, + onColorChange = { hex -> + onChange( + value.copy( + gradient = stops.toMutableList().also { + it[index] = hex.uppercase() + }, + ), + ) + }, + ) + } + }, + confirmButton = { + TextButton(onClick = { editingColourIndex = null }) { Text("Done") } + }, + ) + } +} + +@Composable +private fun KindRow(label: String, selected: Boolean, onClick: () -> Unit) { + Surface(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + RadioButton(selected = selected, onClick = onClick) + Text(label, style = MaterialTheme.typography.bodyMedium) + } + } +} + +/** + * A number the user types. Kept as text while editing so a half-typed "-" or + * "1." is not thrown away mid-keystroke, and blank means null, which is how + * the step field says "any value". + */ +@Composable +private fun NumberBox( + label: String, + value: Double?, + onChange: (Double?) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, +) { + var text by remember(value) { + mutableStateOf( + value?.let { if (it == it.toLong().toDouble()) it.toLong().toString() else it.toString() } + .orEmpty(), + ) + } + OutlinedTextField( + value = text, + onValueChange = { raw -> + text = raw + onChange(raw.trim().toDoubleOrNull()) + }, + label = { Text(label) }, + placeholder = placeholder?.let { { Text(it) } }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = modifier, + ) +} + // ── Add field bottom sheet ──────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @@ -365,7 +797,13 @@ private fun AddFieldSheet( isSaving: Boolean, offersPublic: Boolean, onDismiss: () -> Unit, - onSave: (name: String, fieldType: String, privacy: String, choices: List?) -> Unit, + onSave: ( + name: String, + fieldType: String, + privacy: String, + choices: List?, + slider: SliderSpec?, + ) -> Unit, ) { var name by remember { mutableStateOf("") } var fieldType by remember { mutableStateOf(fieldTypeOptions.first()) } @@ -374,6 +812,7 @@ private fun AddFieldSheet( // Choices for select / multiselect. Empty list = freeform tag mode // (server-side any string accepted). Hidden for non-choice types. var choices by remember { mutableStateOf(listOf()) } + var slider by remember { mutableStateOf(defaultSliderSpec()) } ModalBottomSheet(onDismissRequest = onDismiss) { Column( @@ -400,7 +839,7 @@ private fun AddFieldSheet( onExpandedChange = { expanded = it }, ) { OutlinedTextField( - value = fieldType.replaceFirstChar { it.uppercase() }, + value = fieldTypeLabel(fieldType), onValueChange = {}, readOnly = true, label = { Text("Field type") }, @@ -415,20 +854,24 @@ private fun AddFieldSheet( ) { fieldTypeOptions.forEach { option -> DropdownMenuItem( - text = { Text(option.replaceFirstChar { it.uppercase() }) }, + text = { Text(fieldTypeLabel(option)) }, onClick = { fieldType = option; expanded = false }, ) } } } - if (fieldType == "select" || fieldType == "multiselect") { + if (isChoiceType(fieldType)) { ChoicesEditor( choices = choices, onChange = { choices = it }, ) } + if (fieldType == "slider") { + SliderOptionsEditor(value = slider, onChange = { slider = it }) + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( "Privacy", @@ -461,11 +904,14 @@ private fun AddFieldSheet( Button( onClick = { - val sendChoices = if (fieldType == "select" || fieldType == "multiselect") choices - else null - onSave(name.trim(), fieldType, privacy, sendChoices) + val sendChoices = if (isChoiceType(fieldType)) choices else null + val sendSlider = slider.takeIf { fieldType == "slider" } + onSave(name.trim(), fieldType, privacy, sendChoices, sendSlider) }, - enabled = !isSaving && name.isNotBlank(), + // A numeric slider whose max is not above its min is the one + // shape the server refuses outright, so the button waits + // rather than sending a 422 back as "Couldn't save". + enabled = !isSaving && name.isNotBlank() && sliderBoundsOk(fieldType, slider), modifier = Modifier.fillMaxWidth().height(52.dp), ) { if (isSaving) { @@ -491,14 +937,22 @@ private fun EditFieldDialog( isSaving: Boolean, offersPublic: Boolean, onDismiss: () -> Unit, - onSave: (name: String, privacy: String, choices: List?) -> Unit, + onSave: (name: String, privacy: String, choices: List?, slider: SliderSpec?) -> Unit, ) { var name by remember(field.id) { mutableStateOf(field.name) } var privacy by remember(field.id) { mutableStateOf(field.privacy) } var choices by remember(field.id) { mutableStateOf(field.options?.choices ?: emptyList()) } - val isChoiceField = field.fieldType == "select" || field.fieldType == "multiselect" + // Seeded from what the server holds, so saving after changing only the + // name sends the shape back unchanged rather than resetting it. + var slider by remember(field.id) { + mutableStateOf( + if (field.fieldType == "slider") sliderSpec(field.options) else defaultSliderSpec(), + ) + } + val isChoiceField = isChoiceType(field.fieldType) + val isSliderField = field.fieldType == "slider" AlertDialog( onDismissRequest = onDismiss, @@ -517,7 +971,7 @@ private fun EditFieldDialog( ) Text( - "Type: ${field.fieldTypeDisplay}", + "Type: ${fieldTypeLabel(field.fieldType)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -529,6 +983,10 @@ private fun EditFieldDialog( ) } + if (isSliderField) { + SliderOptionsEditor(value = slider, onChange = { slider = it }) + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( "Privacy", @@ -564,9 +1022,10 @@ private fun EditFieldDialog( TextButton( onClick = { val sendChoices = if (isChoiceField) choices else null - onSave(name.trim(), privacy, sendChoices) + onSave(name.trim(), privacy, sendChoices, slider.takeIf { isSliderField }) }, - enabled = !isSaving && name.isNotBlank(), + enabled = !isSaving && name.isNotBlank() && + sliderBoundsOk(field.fieldType, slider), ) { if (isSaving) { CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsViewModel.kt index 8485377..2fa542d 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsViewModel.kt @@ -12,6 +12,8 @@ import systems.lupine.sheaf.data.model.CustomFieldOptions import systems.lupine.sheaf.data.model.CustomFieldRead import systems.lupine.sheaf.data.model.CustomFieldReorder import systems.lupine.sheaf.data.model.CustomFieldUpdate +import systems.lupine.sheaf.ui.components.SliderSpec +import systems.lupine.sheaf.ui.components.toOptions import retrofit2.HttpException import systems.lupine.sheaf.util.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -39,6 +41,8 @@ data class PendingFieldEdit( val privacy: String, val choices: List?, val fieldType: String?, + /** Set for a slider, so a step-up replay carries its shape too. */ + val slider: SliderSpec? = null, ) /** @@ -62,6 +66,32 @@ internal fun reorderedFieldIds( return moved } +/** + * The options dict to send, given what the editor collected and the field's + * type. Rules mirror what the server will accept: only SELECT/MULTISELECT and + * SLIDER carry options, a null or empty choices list signals "freeform tag + * mode" (options omitted), and every other type must send nothing at all or + * the server answers 422. + * + * Returning null leaves the key out of the PATCH entirely, which the server + * reads as "leave the stored options alone". That is the right answer for a + * type that has none, and it is also what keeps a slider's shape intact when + * something other than this screen is doing the saving. + */ +internal fun fieldOptions( + fieldType: String?, + choices: List?, + slider: SliderSpec?, +): CustomFieldOptions? = when (fieldType) { + "select", "multiselect" -> choices + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.takeIf { it.isNotEmpty() } + ?.let { CustomFieldOptions(choices = it) } + "slider" -> slider?.toOptions() + else -> null +} + @HiltViewModel class CustomFieldsViewModel @Inject constructor( private val api: SheafApiService, @@ -119,6 +149,7 @@ class CustomFieldsViewModel @Inject constructor( fieldType: String, privacy: String, choices: List? = null, + slider: SliderSpec? = null, ) { viewModelScope.launch { _state.update { it.copy(isSaving = true, error = null) } @@ -126,7 +157,7 @@ class CustomFieldsViewModel @Inject constructor( api.createField(CustomFieldCreate( name = name, fieldType = fieldType, - options = choices.toOptionsOrNull(fieldType), + options = fieldOptions(fieldType, choices, slider), order = _state.value.fields.size, privacy = privacy, )) @@ -144,9 +175,10 @@ class CustomFieldsViewModel @Inject constructor( privacy: String, choices: List? = null, fieldType: String? = null, + slider: SliderSpec? = null, ) { val current = _state.value.fields.find { it.id == id }?.privacy - val edit = PendingFieldEdit(id, name, privacy, choices, fieldType) + val edit = PendingFieldEdit(id, name, privacy, choices, fieldType, slider) if (systems.lupine.sheaf.ui.sharing.isRaiseToPublic(current, privacy) && _state.value.raiseGate.stepUpNeeded ) { @@ -173,10 +205,7 @@ class CustomFieldsViewModel @Inject constructor( CustomFieldUpdate( name = edit.name, privacy = edit.privacy, - // Carry options only for SELECT/MULTISELECT. - // Non-choice field types: backend rejects a - // non-null options dict with 422. - options = edit.choices.toOptionsOrNull(edit.fieldType), + options = fieldOptions(edit.fieldType, edit.choices, edit.slider), password = password?.ifBlank { null }, totpCode = totpCode?.ifBlank { null }, ), @@ -212,19 +241,6 @@ class CustomFieldsViewModel @Inject constructor( } } - /** - * Build the options dict to send, given the user-edited list of - * choices and the field's type. Rules mirror what backend will - * accept: only SELECT/MULTISELECT carry options, and a null - * choices list signals "freeform tag mode" (server omits options). - */ - private fun List?.toOptionsOrNull(fieldType: String?): CustomFieldOptions? { - if (fieldType != "select" && fieldType != "multiselect") return null - val cleaned = this?.map { it.trim() }?.filter { it.isNotEmpty() } ?: return null - if (cleaned.isEmpty()) return null - return CustomFieldOptions(choices = cleaned) - } - fun deleteField(id: String) { viewModelScope.launch { runCatching { api.deleteField(id) } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/CustomFieldEditor.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/CustomFieldEditor.kt index c89db59..4cf9274 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/CustomFieldEditor.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/CustomFieldEditor.kt @@ -43,9 +43,13 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.ExperimentalLayoutApi import systems.lupine.sheaf.data.model.CustomFieldRead import systems.lupine.sheaf.ui.components.PrivacyTag +import systems.lupine.sheaf.ui.components.SliderInput import systems.lupine.sheaf.ui.components.StagedPrivacyNote import systems.lupine.sheaf.ui.components.datePickerDate import systems.lupine.sheaf.ui.components.datePickerMillis +import systems.lupine.sheaf.ui.components.formatSliderValue +import systems.lupine.sheaf.ui.components.sliderSpec +import systems.lupine.sheaf.ui.components.sliderValueOrNull import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -56,7 +60,7 @@ import java.time.format.DateTimeFormatter * typed [Any?] on the wire so this dispatcher picks the right input * widget from [CustomFieldRead.fieldType] + [CustomFieldRead.options]. * - * Eight variants: + * Nine variants: * - text : single-line free text * - number : numeric keyboard, accepts the empty string for clear * - date : picker dialog, stores ISO yyyy-MM-dd @@ -65,9 +69,18 @@ import java.time.format.DateTimeFormatter * - select freeform : single-line free text * - multiselect with choices : FilterChip group * - multiselect freeform : chip input + add-by-text affordance + * - slider : draggable track with the reading beside it * * Empty / blank string -> null. Server treats null as "clear this * field" so toggling a value out then saving removes it cleanly. + * + * Anything else is a type the server has and this build does not. The API + * contract for that case (docs/CLIENT_DESIGN.md) is to treat the field as + * text and show the value's string form without offering an editor, which is + * what [UnknownTypeDisplay] does. Refusing to render it, or guessing at an + * editor, would be worse than showing it plainly: one hides a value the + * member put there, the other writes a string into a field the server + * validates as something else. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -100,17 +113,41 @@ internal fun CustomFieldEditor( } else { MultiselectChoicesEditor(label = field.name, choices = choices, value = value.coerceToStringList(), onChange = onChange) } - else -> Text( - "Unknown field type: ${field.fieldType}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + "slider" -> SliderInput( + label = field.name, + value = sliderValueOrNull(value), + spec = sliderSpec(field.options), + onChange = onChange, ) + else -> UnknownTypeDisplay(label = field.name, value = value) } PrivacyTag(field.privacy) StagedPrivacyNote(field.pendingPrivacy, field.privacyActivatesAt) } } +/** + * A field whose type this build does not know: its value, shown as text and + * not editable. The note says why the input is missing, so it reads as "this + * app is behind" rather than as a broken field. + */ +@Composable +private fun UnknownTypeDisplay(label: String, value: Any?) { + Column { + Text(label, style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.height(4.dp)) + Text( + value?.toString()?.takeIf { it.isNotBlank() } ?: "Not set", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "Update the app to edit this field.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + /** Coerce a multiselect server value into List. Defensively * handles the case where Moshi reflects a List rather than the * concrete List — values inside are still strings, we just @@ -422,6 +459,11 @@ internal fun customFieldValueDisplay(field: CustomFieldRead, value: Any?): Strin .joinToString(", ") .ifEmpty { "—" } "number" -> (value as? Number)?.toString() ?: value.toString() + // The reading, not the raw number: "62%" or "172.5 cm", the same + // string the server puts on a public profile. A value that is not a + // number falls back to its own string form, as an unknown type does. + "slider" -> formatSliderValue(value, sliderSpec(field.options)) + .ifBlank { value.toString() } else -> value.toString().ifBlank { "—" } } } diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/SliderOptionsWireTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/SliderOptionsWireTest.kt new file mode 100644 index 0000000..c9a8db9 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/SliderOptionsWireTest.kt @@ -0,0 +1,149 @@ +package systems.lupine.sheaf.data.model + +import systems.lupine.sheaf.di.NetworkModule +import systems.lupine.sheaf.ui.components.SLIDER_MODE_NUMERIC +import systems.lupine.sheaf.ui.components.defaultSliderSpec +import systems.lupine.sheaf.ui.components.sliderSpec +import systems.lupine.sheaf.ui.components.toOptions +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a slider's options look like on the wire, in both directions. + * + * Worth pinning at the JSON rather than at the data class, for two reasons. + * A slider's options are the one body on this client the server REPLACES + * rather than merges, so a key this builds and leaves out is a key the server + * resets to its default: omission is how a label, a unit or a gradient gets + * cleared, and it is equally how one would get silently wiped. And the server + * refuses an options object with a key it does not recognise for the type, so + * a stray `choices` riding along on a slider would 422 the whole save. + */ +class SliderOptionsWireTest { + + private val adapter = NetworkModule.provideMoshi().adapter(CustomFieldOptions::class.java) + + private fun json(options: CustomFieldOptions) = adapter.toJson(options) + + @Test fun `a labeled slider sends only what its mode means`() { + val wire = json(defaultSliderSpec().toOptions()) + assertTrue(wire.contains("\"mode\":\"labeled\""), wire) + // The range IS the mode in labeled form, so stating it would be + // telling the server something it is about to overrule anyway. + assertFalse(wire.contains("\"min\""), wire) + assertFalse(wire.contains("\"max\""), wire) + assertFalse(wire.contains("\"unit\""), wire) + assertFalse(wire.contains("\"step\""), wire) + } + + @Test fun `no slider ever sends choices`() { + // `choices` shares the options object with the slider keys, and the + // server rejects it outright for a slider: options may only contain + // the keys its own type uses. + val numeric = defaultSliderSpec().copy(mode = SLIDER_MODE_NUMERIC, unit = "kg") + assertFalse(json(numeric.toOptions()).contains("choices"), json(numeric.toOptions())) + assertFalse(json(defaultSliderSpec().toOptions()).contains("choices")) + } + + @Test fun `a numeric slider sends its own range and unit`() { + val spec = defaultSliderSpec().copy( + mode = SLIDER_MODE_NUMERIC, + min = 120.0, + max = 220.0, + step = 0.5, + unit = "cm", + ) + val wire = json(spec.toOptions()) + assertTrue(wire.contains("\"mode\":\"numeric\""), wire) + assertTrue(wire.contains("\"min\":120"), wire) + assertTrue(wire.contains("\"max\":220"), wire) + assertTrue(wire.contains("\"step\":0.5"), wire) + assertTrue(wire.contains("\"unit\":\"cm\""), wire) + } + + @Test fun `a step left blank means any value, and is left out`() { + val spec = defaultSliderSpec().copy(mode = SLIDER_MODE_NUMERIC, step = null) + assertFalse(json(spec.toOptions()).contains("\"step\""), json(spec.toOptions())) + } + + @Test fun `emptying a label clears it rather than keeping the old one`() { + // The server rebuilds the options from what arrives, so a label left + // out comes back null. That is the clear, and it is the only one + // available: there is no explicit null to send. + val spec = defaultSliderSpec().copy( + labels = SliderLabels(left = "Calm", center = " ", right = ""), + ) + val wire = json(spec.toOptions()) + assertTrue(wire.contains("\"left\":\"Calm\""), wire) + assertFalse(wire.contains("\"center\""), wire) + assertFalse(wire.contains("\"right\""), wire) + } + + @Test fun `a label keeps its surrounding text but loses the padding`() { + val spec = defaultSliderSpec().copy(labels = SliderLabels(left = " Calm ")) + assertTrue(json(spec.toOptions()).contains("\"left\":\"Calm\"")) + } + + @Test fun `a gradient of one colour is not sent as a gradient`() { + // The server needs two to eight stops; one would be a 422, and the + // field would refuse to save for a reason nobody asked for. + val spec = defaultSliderSpec().copy(gradient = listOf("#FF0000")) + assertFalse(json(spec.toOptions()).contains("gradient"), json(spec.toOptions())) + } + + @Test fun `a gradient of two colours is sent in order`() { + val spec = defaultSliderSpec().copy(gradient = listOf("#5B92C9", "#E08D5E")) + assertTrue( + json(spec.toOptions()).contains("\"gradient\":[\"#5B92C9\",\"#E08D5E\"]"), + json(spec.toOptions()), + ) + } + + @Test fun `the server's own options decode whole`() { + val fromServer = """ + {"mode":"numeric","min":0,"max":10,"step":1,"unit":"kg", + "labels":{"left":"Light","center":null,"right":"Heavy"}, + "gradient":["#5B92C9","#E08D5E"]} + """.trimIndent() + val spec = sliderSpec(adapter.fromJson(fromServer)) + assertEquals(SLIDER_MODE_NUMERIC, spec.mode) + assertEquals(0.0, spec.min) + assertEquals(10.0, spec.max) + assertEquals(1.0, spec.step) + assertEquals("kg", spec.unit) + assertEquals("Light", spec.labels.left) + assertNull(spec.labels.center) + assertEquals("Heavy", spec.labels.right) + assertEquals(listOf("#5B92C9", "#E08D5E"), spec.gradient) + } + + @Test fun `a key this build has never heard of does not break the decode`() { + // The server may add to a slider's options the way it added the type. + // A field list that failed to decode over one unknown key would take + // every other field down with it. + val spec = sliderSpec( + adapter.fromJson("""{"mode":"labeled","labels":{"left":"Calm"},"snap":true}"""), + ) + assertEquals("Calm", spec.labels.left) + } + + @Test fun `a slider's options round-trip without losing its shape`() { + // The edit dialog seeds itself from the server's answer and sends it + // back when only the name changed, so this is the path that would + // quietly flatten a gradient or a unit. + val original = defaultSliderSpec().copy( + mode = SLIDER_MODE_NUMERIC, + min = 1.0, + max = 7.0, + step = 0.5, + unit = "h", + labels = SliderLabels(left = "Short", right = "Long"), + gradient = listOf("#111111", "#EEEEEE"), + ) + val returned = sliderSpec(adapter.fromJson(json(original.toOptions()))) + assertEquals(original, returned) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/SliderFieldTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/SliderFieldTest.kt new file mode 100644 index 0000000..9adbbc4 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/SliderFieldTest.kt @@ -0,0 +1,165 @@ +package systems.lupine.sheaf.ui.components + +import systems.lupine.sheaf.data.model.CustomFieldOptions +import systems.lupine.sheaf.data.model.SliderLabels +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The slider field type's pure parts: resolving a definition's options into a + * drawable shape, and the reading a value produces. + * + * The reading matters beyond this screen. The server puts the same string in + * `value` on a public profile, so if these two drifted, the same slider would + * read one way in the app and another on the web page it is published to. + */ +class SliderFieldTest { + + @Test fun `a labeled slider runs 0 to 100 and reads as a percentage`() { + val spec = sliderSpec(CustomFieldOptions(mode = "labeled")) + assertEquals(0.0, spec.min) + assertEquals(100.0, spec.max) + assertEquals("62%", formatSliderValue(62, spec)) + } + + @Test fun `a numeric slider reads as its quantity and unit`() { + val spec = sliderSpec( + CustomFieldOptions(mode = "numeric", min = 120.0, max = 220.0, unit = "cm"), + ) + assertEquals("172.5 cm", formatSliderValue(172.5, spec)) + } + + @Test fun `a numeric slider with no unit is just the number`() { + val spec = sliderSpec(CustomFieldOptions(mode = "numeric", min = 1.0, max = 5.0)) + assertEquals("4", formatSliderValue(4.0, spec)) + } + + @Test fun `a whole number loses its trailing zero`() { + val spec = sliderSpec(CustomFieldOptions(mode = "labeled")) + assertEquals("50%", formatSliderValue(50.0, spec)) + } + + @Test fun `a reading rounds to two places`() { + val spec = sliderSpec(CustomFieldOptions(mode = "numeric", min = 0.0, max = 10.0)) + assertEquals("3.33", formatSliderValue(3.33333, spec)) + } + + @Test fun `a value that is not a number has no reading`() { + val spec = sliderSpec(CustomFieldOptions(mode = "labeled")) + assertEquals("", formatSliderValue("sixty-two", spec)) + assertEquals("", formatSliderValue(null, spec)) + // A boolean is a Number to nobody, but it is worth pinning: Kotlin + // will not smart-cast it, and a `true` rendering as "1%" would be a + // reading the member never gave. + assertEquals("", formatSliderValue(true, spec)) + } + + @Test fun `an unset value is null rather than a confident zero`() { + assertNull(sliderValueOrNull(null)) + assertNull(sliderValueOrNull("not a number")) + assertEquals(7.0, sliderValueOrNull(7)) + } + + @Test fun `no options at all still gives a drawable track`() { + // The server fills every key in, so this is the hand-written or + // older-row case. A spec whose span is zero would divide by nothing. + val spec = sliderSpec(null) + assertEquals(100.0, spec.span) + assertEquals(SLIDER_MODE_LABELED, spec.mode) + } + + @Test fun `bounds that are inverted or equal fall back to the mode's own`() { + val spec = sliderSpec(CustomFieldOptions(mode = "numeric", min = 80.0, max = 20.0)) + assertTrue(spec.span > 0, "an inverted range would collapse the track") + assertEquals(0.0, spec.min) + assertEquals(10.0, spec.max) + } + + @Test fun `a gradient needs two colours to be one`() { + assertNull(sliderSpec(CustomFieldOptions(gradient = listOf("#FF0000"))).gradient) + assertNull(sliderSpec(CustomFieldOptions(gradient = emptyList())).gradient) + assertEquals( + listOf("#FF0000", "#00FF00"), + sliderSpec(CustomFieldOptions(gradient = listOf("#FF0000", "#00FF00"))).gradient, + ) + } + + @Test fun `a labeled slider ignores a unit it has no business carrying`() { + // The server drops the unit in labeled mode, so holding on to it here + // would show "62 cm" for a field the server reads as "62%". + val spec = sliderSpec(CustomFieldOptions(mode = "labeled", unit = "cm")) + assertNull(spec.unit) + assertEquals("62%", formatSliderValue(62, spec)) + } + + @Test fun `a step of zero or less is no step at all`() { + assertNull(sliderSpec(CustomFieldOptions(mode = "numeric", step = 0.0)).step) + assertNull(sliderSpec(CustomFieldOptions(mode = "numeric", step = -1.0)).step) + } + + @Test fun `a fraction says where a value sits and never leaves the track`() { + val spec = sliderSpec(CustomFieldOptions(mode = "numeric", min = 0.0, max = 50.0)) + assertEquals(0.5f, sliderFraction(25.0, spec)) + assertEquals(0f, sliderFraction(-10.0, spec), "below min must pin to the left end") + assertEquals(1f, sliderFraction(999.0, spec), "above max must pin to the right end") + } + + // ── What gets stored ───────────────────────────────────────────────────── + + @Test fun `the control's float noise never reaches the stored value`() { + // Observed on device: dragging a 0..100 labeled slider to the 85 stop + // produced exactly this, because Material's slider is Float-valued and + // widening to Double exposes the error. The reading said "85%" while + // the database held 85.00000762939453. + val spec = sliderSpec(CustomFieldOptions(mode = "labeled", step = 1.0)) + assertEquals(85.0, snapSliderValue(85.00000762939453, spec)) + } + + @Test fun `a value snaps to the step grid`() { + val spec = sliderSpec( + CustomFieldOptions(mode = "numeric", min = 120.0, max = 220.0, step = 0.5), + ) + assertEquals(153.5, snapSliderValue(153.5000038, spec)) + assertEquals(153.5, snapSliderValue(153.62, spec)) + assertEquals(154.0, snapSliderValue(153.9, spec)) + } + + @Test fun `a grid offset from zero snaps to the field's own bounds`() { + // The stops are min, min+step, min+2*step ... not multiples of step, + // so a min that is not on the step grid must not pull values off it. + val spec = sliderSpec( + CustomFieldOptions(mode = "numeric", min = 1.5, max = 11.5, step = 2.0), + ) + assertEquals(5.5, snapSliderValue(5.4, spec)) + assertEquals(1.5, snapSliderValue(1.9, spec)) + } + + @Test fun `with no step the stored value matches the reading`() { + val spec = sliderSpec(CustomFieldOptions(mode = "numeric", min = 0.0, max = 10.0)) + assertEquals(3.33, snapSliderValue(3.333333, spec)) + assertEquals("3.33", formatSliderValue(snapSliderValue(3.333333, spec), spec)) + } + + @Test fun `a stored value never leaves the field's range`() { + // Rounding up at the top of the track could otherwise put the value + // one step past max, which the server refuses outright. + val spec = sliderSpec( + CustomFieldOptions(mode = "numeric", min = 0.0, max = 5.0, step = 2.0), + ) + assertEquals(5.0, snapSliderValue(5.0, spec)) + assertTrue(snapSliderValue(4.9, spec) <= 5.0) + assertTrue(snapSliderValue(-1.0, spec) >= 0.0) + } + + @Test fun `labels come back as given`() { + val spec = sliderSpec( + CustomFieldOptions(labels = SliderLabels(left = "Calm", right = "Intense")), + ) + assertEquals("Calm", spec.labels.left) + assertEquals("Intense", spec.labels.right) + assertTrue(spec.hasAnyLabel) + assertTrue(!sliderSpec(CustomFieldOptions()).hasAnyLabel) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/fields/SliderFieldDefinitionTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/fields/SliderFieldDefinitionTest.kt new file mode 100644 index 0000000..9a401ee --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/fields/SliderFieldDefinitionTest.kt @@ -0,0 +1,116 @@ +package systems.lupine.sheaf.ui.fields + +import systems.lupine.sheaf.ui.components.SLIDER_MODE_NUMERIC +import systems.lupine.sheaf.ui.components.defaultSliderSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Setting a slider field up: which types carry options, which bounds the + * server would refuse, and whether a set of colours is one of the named + * gradients. + */ +class SliderFieldDefinitionTest { + + // ── Which types carry options ───────────────────────────────────────────── + + @Test fun `a slider sends its shape`() { + val options = fieldOptions("slider", choices = null, slider = defaultSliderSpec()) + assertNotNull(options) + assertEquals("labeled", options.mode) + } + + @Test fun `a type with no options sends none`() { + // The server refuses a non-null options dict on these outright, so a + // slider spec left lying around in the sheet's state must not ride + // along when the type was switched back to text. + for (type in listOf("text", "number", "date", "boolean")) { + assertNull( + fieldOptions(type, choices = listOf("a", "b"), slider = defaultSliderSpec()), + "$type must not carry options", + ) + } + } + + @Test fun `a choice type still sends only its choices`() { + val options = fieldOptions("select", choices = listOf(" a ", "", "b"), slider = null) + assertEquals(listOf("a", "b"), options?.choices) + assertNull(options?.mode, "a select has no slider shape to send") + } + + @Test fun `an empty choices list is freeform rather than an empty list`() { + assertNull(fieldOptions("multiselect", choices = emptyList(), slider = null)) + assertNull(fieldOptions("multiselect", choices = listOf(" "), slider = null)) + } + + @Test fun `a slider with no spec collected sends nothing`() { + // Which is what keeps a slider's options intact when the save came + // from somewhere that never showed the shape editor. + assertNull(fieldOptions("slider", choices = null, slider = null)) + } + + // ── Bounds the server would refuse ──────────────────────────────────────── + + @Test fun `a labeled slider cannot have bad bounds`() { + assertNull(sliderProblem(defaultSliderSpec())) + assertTrue(sliderBoundsOk("slider", defaultSliderSpec())) + } + + @Test fun `max must be above min`() { + val spec = defaultSliderSpec().copy(mode = SLIDER_MODE_NUMERIC, min = 10.0, max = 10.0) + assertNotNull(sliderProblem(spec)) + assertFalse(sliderBoundsOk("slider", spec)) + assertFalse(sliderBoundsOk("slider", spec.copy(max = 2.0))) + } + + @Test fun `a step cannot be bigger than the range`() { + val spec = defaultSliderSpec().copy( + mode = SLIDER_MODE_NUMERIC, + min = 0.0, + max = 5.0, + step = 10.0, + ) + assertNotNull(sliderProblem(spec)) + assertFalse(sliderBoundsOk("slider", spec)) + } + + @Test fun `a step inside the range is fine, and no step at all is too`() { + val spec = defaultSliderSpec().copy(mode = SLIDER_MODE_NUMERIC, min = 0.0, max = 5.0) + assertNull(sliderProblem(spec.copy(step = 0.5))) + assertNull(sliderProblem(spec.copy(step = null))) + } + + @Test fun `another type's bad numbers never block its save`() { + // sliderBoundsOk gates the Save button for every type, so a spec left + // in a bad state must not strand a text field. + val bad = defaultSliderSpec().copy(mode = SLIDER_MODE_NUMERIC, min = 5.0, max = 1.0) + assertTrue(sliderBoundsOk("text", bad)) + assertTrue(sliderBoundsOk(null, bad)) + } + + // ── Named gradients ────────────────────────────────────────────────────── + + @Test fun `no colours is no gradient`() { + assertEquals("none", gradientPresetId(null)) + assertEquals("none", gradientPresetId(emptyList())) + } + + @Test fun `a preset's own colours are recognised whatever their case`() { + assertEquals("cool-warm", gradientPresetId(listOf("#5B92C9", "#E08D5E"))) + assertEquals("cool-warm", gradientPresetId(listOf("#5b92c9", "#e08d5e"))) + } + + @Test fun `a three stop preset is recognised too`() { + assertEquals("soft-hard", gradientPresetId(listOf("#F4EBD8", "#A89B8C", "#3A2E4D"))) + } + + @Test fun `colours matching nothing named are custom`() { + assertEquals("custom", gradientPresetId(listOf("#123456", "#654321"))) + // Right colours, wrong order: a different gradient, so not the preset. + assertEquals("custom", gradientPresetId(listOf("#E08D5E", "#5B92C9"))) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/members/CustomFieldValueDisplayTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/members/CustomFieldValueDisplayTest.kt new file mode 100644 index 0000000..841cabb --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/members/CustomFieldValueDisplayTest.kt @@ -0,0 +1,73 @@ +package systems.lupine.sheaf.ui.members + +import systems.lupine.sheaf.data.model.CustomFieldOptions +import systems.lupine.sheaf.data.model.CustomFieldRead +import systems.lupine.sheaf.data.model.SliderLabels +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * How a custom field's value reads on a member's profile, where there is no + * room for a control and the value has to speak for itself. + */ +class CustomFieldValueDisplayTest { + + private fun field( + type: String, + options: CustomFieldOptions? = null, + ) = CustomFieldRead( + id = "f1", + systemId = "s1", + name = "Field", + fieldType = type, + options = options, + order = 0, + privacy = "private", + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-01-01T00:00:00Z", + ) + + @Test fun `a slider shows its reading, not its number`() { + val labeled = field( + "slider", + CustomFieldOptions( + mode = "labeled", + labels = SliderLabels(left = "Calm", right = "Intense"), + ), + ) + assertEquals("62%", customFieldValueDisplay(labeled, 62)) + } + + @Test fun `a numeric slider shows its unit`() { + val numeric = field( + "slider", + CustomFieldOptions(mode = "numeric", min = 120.0, max = 220.0, unit = "cm"), + ) + assertEquals("172.5 cm", customFieldValueDisplay(numeric, 172.5)) + } + + @Test fun `a slider holding something other than a number shows it plainly`() { + // A value carried over from when the field was text. Showing it is + // better than showing nothing: it is what the member put there. + assertEquals( + "about sixty", + customFieldValueDisplay(field("slider", CustomFieldOptions(mode = "labeled")), "about sixty"), + ) + } + + @Test fun `a type this build does not know shows its value as text`() { + // The contract in docs/CLIENT_DESIGN.md: an unknown field_type is + // treated as text rather than failing. A field added to the server + // after this build shipped still shows what it holds. + assertEquals("whatever", customFieldValueDisplay(field("colour-wheel"), "whatever")) + assertEquals("41", customFieldValueDisplay(field("colour-wheel"), 41)) + } + + @Test fun `the types that already worked still do`() { + assertEquals("Yes", customFieldValueDisplay(field("boolean"), true)) + assertEquals("No", customFieldValueDisplay(field("boolean"), false)) + assertEquals("Jan 2, 2026", customFieldValueDisplay(field("date"), "2026-01-02")) + assertEquals("a, b", customFieldValueDisplay(field("multiselect"), listOf("a", "b"))) + assertEquals("plain", customFieldValueDisplay(field("text"), "plain")) + } +}