polish the prediction market and ballot cards

Rendered offscreen (ImageComposeScene, phone width, both themes) against
the real production events, the cards read flat. Now:

- a yes/no market draws its two sides full width, green and red; once
  resolved the winner keeps its colour and a check, the loser goes quiet
- Cancelled is a quiet pill, like Closed, and its reason is gray: called
  off is not an error
- the category reads as published ("ai-llm", not "Ai llm")
- blank lines in a description collapse, so a clipped description no
  longer leaves a hole above the outcomes
- a ballot's answers are a small table: one bordered block, a hairline
  between rows, the answer right-aligned, no chevrons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CsFEX4ZT8mL1r8h2M6qfH
This commit is contained in:
Claude
2026-10-01 03:24:59 +00:00
parent f9063c8191
commit c4337ecd7d
2 changed files with 113 additions and 20 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.ui.note.types
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -27,6 +28,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -35,8 +38,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -56,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.experimental.ballots.BallotAnswer
import com.vitorpamplona.quartz.experimental.ballots.BallotEvent
@@ -140,7 +146,17 @@ fun BallotCard(event: BallotEvent) {
if (state.answers.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
state.answers.forEach { AnswerRow(it) }
// The answers as a small table: one bordered block, a hairline between rows.
Column(
Modifier
.clip(AnswersShape)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, AnswersShape),
) {
state.answers.forEachIndexed { i, answer ->
if (i > 0) HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.subtleBorder)
AnswerRow(answer)
}
}
if (state.hiddenAnswers > 0) {
Text(
@@ -167,25 +183,25 @@ private fun AnswerRow(answer: BallotAnswer) {
answer.question
}
Row(verticalAlignment = Alignment.Top) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = question,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.grayText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(0.4f),
)
Icon(
symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(horizontal = 4.dp).size(16.dp),
)
Text(
text = answer.answer,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.End,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(0.6f),
@@ -193,6 +209,8 @@ private fun AnswerRow(answer: BallotAnswer) {
}
}
private val AnswersShape = RoundedCornerShape(10.dp)
@Composable
private fun ProofHash(hash: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -131,18 +131,21 @@ private class PredictionMarketCardState(
) {
fun isWinner(outcome: String) = resolution != null && outcome.equals(resolution, ignoreCase = true)
/** Two outcomes, one a yes and one a no — "YES"/"NO", "Yes (stays online)"/"No (goes offline)". */
val isBinary = outcomes.size == 2 && outcomes.any(::isYes) && outcomes.any(::isNo)
companion object {
fun from(
event: PredictionMarketEvent,
now: Long,
) = PredictionMarketCardState(
title = event.title() ?: socialPostHeadline(event.content),
description = event.description(),
description = event.description()?.let(::collapseBlankLines),
outcomes = event.outcomes().toImmutableList(),
status = event.status(now),
resolution = event.resolution(),
endsAt = event.endsAt(),
category = event.category()?.let(::humanizeCategory),
category = event.category(),
isDemo = event.isDemo(),
minBetSats = event.minBetSats()?.let(::formatGrouped),
maxBetSats = event.maxBetSats()?.let(::formatGrouped),
@@ -150,6 +153,13 @@ private class PredictionMarketCardState(
cancelReason = event.cancelReason(),
)
fun isYes(outcome: String) = YES.containsMatchIn(outcome)
fun isNo(outcome: String) = NO.containsMatchIn(outcome)
private val YES = Regex("""^\s*yes\b""", RegexOption.IGNORE_CASE)
private val NO = Regex("""^\s*no\b""", RegexOption.IGNORE_CASE)
/** The first line of a social-post `content`, for a market with no title anywhere else. */
private fun socialPostHeadline(content: String): String? {
if (content.trimStart().startsWith("{")) return null
@@ -162,11 +172,13 @@ private class PredictionMarketCardState(
return if (value == asLong.toDouble()) "$asLong%" else "$value%"
}
private fun humanizeCategory(category: String): String =
category
.replace('-', ' ')
.replace('_', ' ')
.replaceFirstChar { if (it.isLowerCase()) it.titlecaseChar() else it }
/**
* BAO's descriptions carry paragraph breaks ("…UTC.\n\nSource of Truth: …"); clipped to a
* few lines, a blank line reads as a hole in the card. One line break each, never a gap.
*/
private fun collapseBlankLines(text: String): String = text.trim().replace(BLANK_LINES, "\n")
private val BLANK_LINES = Regex("""\s*\n\s*\n\s*""")
}
}
@@ -274,12 +286,13 @@ private fun MarketStatusPill(status: PredictionMarketStatus) {
)
}
// Called off is not an error: quiet, like Closed.
PredictionMarketStatus.CANCELLED -> {
StatusPill(
label = stringRes(Res.string.prediction_market_status_cancelled),
symbol = MaterialSymbols.Block,
container = Color(0xFFCF222E),
content = Color.White,
container = MaterialTheme.colorScheme.surfaceVariant,
content = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -318,6 +331,11 @@ private fun MarketOutcomes(state: PredictionMarketCardState) {
// A cancelled market has no winner and takes no bets: its outcomes are shown, but muted.
val muted = state.status == PredictionMarketStatus.CANCELLED
if (state.isBinary) {
BinaryOutcomes(state, muted)
return
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
@@ -336,6 +354,63 @@ private fun MarketOutcomes(state: PredictionMarketCardState) {
}
}
/**
* A yes/no market as its two sides, side by side and full width — the way betting apps lay out
* Yes and No: green and red while it is undecided; once it resolves the winner keeps its colour
* and a check, and the loser goes quiet.
*/
@Composable
private fun BinaryOutcomes(
state: PredictionMarketCardState,
muted: Boolean,
) {
val decided = state.resolution != null
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
state.outcomes.forEach { outcome ->
val isWinner = state.isWinner(outcome)
val quiet = muted || (decided && !isWinner)
val side =
if (PredictionMarketCardState.isYes(outcome)) {
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colorScheme.redColorOnSecondSurface
}
val color = if (quiet) MaterialTheme.colorScheme.placeholderText else side
Row(
modifier =
Modifier
.weight(1f)
.clip(SideShape)
.background(if (quiet) Color.Transparent else side.copy(alpha = if (isWinner) 0.20f else 0.12f))
.border(1.dp, if (quiet) MaterialTheme.colorScheme.subtleBorder else side.copy(alpha = 0.45f), SideShape)
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (isWinner) {
Icon(
symbol = MaterialSymbols.CheckCircle,
contentDescription = stringRes(Res.string.prediction_market_winning_outcome),
tint = color,
filled = true,
modifier = Modifier.padding(end = 4.dp).size(16.dp),
)
}
Text(
text = outcome,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
private val SideShape = RoundedCornerShape(10.dp)
@Composable
private fun OutcomeChip(
outcome: String,
@@ -407,7 +482,7 @@ private fun CancelReason(reason: String) {
MarketFootnote(
symbol = MaterialSymbols.Block,
text = stringRes(Res.string.prediction_market_cancel_reason, reason),
color = MaterialTheme.colorScheme.redColorOnSecondSurface,
color = MaterialTheme.colorScheme.placeholderText,
)
}