diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt index e13767902f..8bff6dff38 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt @@ -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) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt index 468069856b..bb2a22a4c7 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt @@ -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, ) }