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")) + } +}