mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+28
-10
@@ -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) {
|
||||
|
||||
+85
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user