feat: couple intimacy seed data, "How Well" screen, ThisOrThat + wheel polish
This commit is contained in:
parent
254652cb86
commit
45058fdd5f
|
|
@ -48,6 +48,7 @@ import app.closer.ui.dates.DateBuilderScreen
|
||||||
import app.closer.ui.dates.BucketListScreen
|
import app.closer.ui.dates.BucketListScreen
|
||||||
import app.closer.ui.paywall.PaywallScreen
|
import app.closer.ui.paywall.PaywallScreen
|
||||||
import app.closer.ui.play.PlayHubScreen
|
import app.closer.ui.play.PlayHubScreen
|
||||||
|
import app.closer.ui.howwell.HowWellScreen
|
||||||
import app.closer.ui.thisorthat.ThisOrThatScreen
|
import app.closer.ui.thisorthat.ThisOrThatScreen
|
||||||
import app.closer.ui.questions.DailyQuestionScreen
|
import app.closer.ui.questions.DailyQuestionScreen
|
||||||
import app.closer.ui.questions.QuestionCategoryScreen
|
import app.closer.ui.questions.QuestionCategoryScreen
|
||||||
|
|
@ -302,6 +303,9 @@ fun AppNavigation(
|
||||||
composable(route = AppRoute.THIS_OR_THAT) {
|
composable(route = AppRoute.THIS_OR_THAT) {
|
||||||
ThisOrThatScreen(onNavigate = navigateRoute)
|
ThisOrThatScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
|
composable(route = AppRoute.HOW_WELL) {
|
||||||
|
HowWellScreen(onNavigate = navigateRoute)
|
||||||
|
}
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
composable(route = AppRoute.DATE_MATCH) {
|
composable(route = AppRoute.DATE_MATCH) {
|
||||||
|
|
@ -378,6 +382,7 @@ private val shellBackRoutes = setOf(
|
||||||
AppRoute.DATE_BUILDER,
|
AppRoute.DATE_BUILDER,
|
||||||
AppRoute.BUCKET_LIST,
|
AppRoute.BUCKET_LIST,
|
||||||
AppRoute.THIS_OR_THAT,
|
AppRoute.THIS_OR_THAT,
|
||||||
|
AppRoute.HOW_WELL,
|
||||||
AppRoute.ACCOUNT,
|
AppRoute.ACCOUNT,
|
||||||
AppRoute.SUBSCRIPTION,
|
AppRoute.SUBSCRIPTION,
|
||||||
AppRoute.PAYWALL
|
AppRoute.PAYWALL
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ object AppRoute {
|
||||||
const val DATE_BUILDER = "date_builder"
|
const val DATE_BUILDER = "date_builder"
|
||||||
const val BUCKET_LIST = "bucket_list"
|
const val BUCKET_LIST = "bucket_list"
|
||||||
const val THIS_OR_THAT = "this_or_that"
|
const val THIS_OR_THAT = "this_or_that"
|
||||||
|
const val HOW_WELL = "how_well"
|
||||||
|
|
||||||
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
||||||
const val QUESTION_THREAD =
|
const val QUESTION_THREAD =
|
||||||
|
|
@ -87,7 +88,8 @@ object AppRoute {
|
||||||
Definition(DATE_MATCHES, "Matches", "dates"),
|
Definition(DATE_MATCHES, "Matches", "dates"),
|
||||||
Definition(DATE_BUILDER, "Plan a Date", "dates"),
|
Definition(DATE_BUILDER, "Plan a Date", "dates"),
|
||||||
Definition(BUCKET_LIST, "Our Bucket List", "dates"),
|
Definition(BUCKET_LIST, "Our Bucket List", "dates"),
|
||||||
Definition(THIS_OR_THAT, "This or That", "play")
|
Definition(THIS_OR_THAT, "This or That", "play"),
|
||||||
|
Definition(HOW_WELL, "How Well Do You Know Me", "play")
|
||||||
)
|
)
|
||||||
|
|
||||||
val topLevelRoutes = setOf(
|
val topLevelRoutes = setOf(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ interface QuestionDao {
|
||||||
@Query("SELECT * FROM question WHERE type = :type AND status = 'active'")
|
@Query("SELECT * FROM question WHERE type = :type AND status = 'active'")
|
||||||
suspend fun getQuestionsByType(type: String): List<QuestionEntity>
|
suspend fun getQuestionsByType(type: String): List<QuestionEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND status = 'active'")
|
||||||
|
suspend fun getQuestionsForPrediction(): List<QuestionEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM question WHERE is_premium = 0 AND status = 'active'")
|
@Query("SELECT * FROM question WHERE is_premium = 0 AND status = 'active'")
|
||||||
suspend fun getFreeQuestions(): List<QuestionEntity>
|
suspend fun getFreeQuestions(): List<QuestionEntity>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,6 @@ class FakeQuestionRepository : QuestionRepository {
|
||||||
override suspend fun getQuestionCountByCategory(categoryId: String): Int = 0
|
override suspend fun getQuestionCountByCategory(categoryId: String): Int = 0
|
||||||
|
|
||||||
override suspend fun getQuestionsByType(type: String): List<Question> = emptyList()
|
override suspend fun getQuestionsByType(type: String): List<Question> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getQuestionsForPrediction(): List<Question> = emptyList()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,8 @@ class RoomQuestionRepository @Inject constructor(
|
||||||
override suspend fun getQuestionsByType(type: String): List<Question> {
|
override suspend fun getQuestionsByType(type: String): List<Question> {
|
||||||
return questionDao.getQuestionsByType(type).map { it.toQuestion() }
|
return questionDao.getQuestionsByType(type).map { it.toQuestion() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getQuestionsForPrediction(): List<Question> {
|
||||||
|
return questionDao.getQuestionsForPrediction().map { it.toQuestion() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,5 @@ interface QuestionRepository {
|
||||||
suspend fun getCategoryById(id: String): QuestionCategory?
|
suspend fun getCategoryById(id: String): QuestionCategory?
|
||||||
suspend fun getQuestionCountByCategory(categoryId: String): Int
|
suspend fun getQuestionCountByCategory(categoryId: String): Int
|
||||||
suspend fun getQuestionsByType(type: String): List<Question>
|
suspend fun getQuestionsByType(type: String): List<Question>
|
||||||
|
suspend fun getQuestionsForPrediction(): List<Question>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,918 @@
|
||||||
|
package app.closer.ui.howwell
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.core.navigation.AppRoute
|
||||||
|
import app.closer.domain.model.AnswerConfig
|
||||||
|
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
||||||
|
import app.closer.domain.model.ChoiceOption
|
||||||
|
import app.closer.domain.model.Question
|
||||||
|
import app.closer.domain.model.ScaleAnswerConfigImpl
|
||||||
|
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
|
||||||
|
import app.closer.domain.repository.QuestionRepository
|
||||||
|
import app.closer.ui.theme.CloserPalette
|
||||||
|
import app.closer.ui.theme.closerBackgroundBrush
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// ── Domain ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class HowWellAnswer(
|
||||||
|
val selectedOptionId: String? = null,
|
||||||
|
val scaleValue: Int? = null
|
||||||
|
) {
|
||||||
|
val isEmpty get() = selectedOptionId == null && scaleValue == null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun HowWellAnswer.isMatch(other: HowWellAnswer): Boolean = when {
|
||||||
|
selectedOptionId != null -> selectedOptionId == other.selectedOptionId
|
||||||
|
scaleValue != null && other.scaleValue != null -> scaleValue == other.scaleValue
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun HowWellAnswer.isClose(other: HowWellAnswer): Boolean =
|
||||||
|
scaleValue != null && other.scaleValue != null &&
|
||||||
|
!isMatch(other) && abs(scaleValue - other.scaleValue) == 1
|
||||||
|
|
||||||
|
fun HowWellAnswer.displayText(config: AnswerConfig?): String = when {
|
||||||
|
selectedOptionId != null -> when (config) {
|
||||||
|
is ChoiceAnswerConfigImpl -> config.config.options.find { it.id == selectedOptionId }?.text ?: selectedOptionId
|
||||||
|
is ThisOrThatAnswerConfigImpl -> when (selectedOptionId) {
|
||||||
|
config.config.optionA.id -> config.config.optionA.text
|
||||||
|
config.config.optionB.id -> config.config.optionB.text
|
||||||
|
else -> selectedOptionId
|
||||||
|
}
|
||||||
|
else -> selectedOptionId
|
||||||
|
}
|
||||||
|
scaleValue != null -> "$scaleValue"
|
||||||
|
else -> "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HowWellResult(
|
||||||
|
val question: Question,
|
||||||
|
val playerAAnswer: HowWellAnswer,
|
||||||
|
val playerBAnswer: HowWellAnswer,
|
||||||
|
val isMatch: Boolean,
|
||||||
|
val isClose: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class HowWellPhase {
|
||||||
|
LOADING, PLAYER_A_INTRO, PLAYER_A_TURN, HANDOFF,
|
||||||
|
PLAYER_B_TURN, REVEALING, COMPLETE
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HowWellUiState(
|
||||||
|
val phase: HowWellPhase = HowWellPhase.LOADING,
|
||||||
|
val questions: List<Question> = emptyList(),
|
||||||
|
val currentIndex: Int = 0,
|
||||||
|
val playerAAnswers: List<HowWellAnswer> = emptyList(),
|
||||||
|
val results: List<HowWellResult> = emptyList(),
|
||||||
|
val selectedOptionId: String? = null,
|
||||||
|
val selectedScale: Int? = null,
|
||||||
|
val score: Int = 0,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class HowWellViewModel @Inject constructor(
|
||||||
|
private val repository: QuestionRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(HowWellUiState())
|
||||||
|
val uiState: StateFlow<HowWellUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init { load() }
|
||||||
|
|
||||||
|
private fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val questions = runCatching {
|
||||||
|
repository.getQuestionsForPrediction().shuffled().take(SESSION_SIZE)
|
||||||
|
}
|
||||||
|
.onFailure { Log.w(TAG, "Failed to load prediction questions", it) }
|
||||||
|
.getOrElse { emptyList() }
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
phase = if (questions.isEmpty()) HowWellPhase.LOADING else HowWellPhase.PLAYER_A_INTRO,
|
||||||
|
questions = questions,
|
||||||
|
error = if (questions.isEmpty()) "No questions available." else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectOption(id: String) = _uiState.update { it.copy(selectedOptionId = id, selectedScale = null) }
|
||||||
|
fun selectScale(v: Int) = _uiState.update { it.copy(selectedScale = v, selectedOptionId = null) }
|
||||||
|
|
||||||
|
fun startPlayerA() = _uiState.update { it.copy(phase = HowWellPhase.PLAYER_A_TURN, currentIndex = 0) }
|
||||||
|
|
||||||
|
fun confirmAnswer() {
|
||||||
|
val s = _uiState.value
|
||||||
|
val answer = HowWellAnswer(s.selectedOptionId, s.selectedScale)
|
||||||
|
if (answer.isEmpty) return
|
||||||
|
val newAnswers = s.playerAAnswers + answer
|
||||||
|
val next = s.currentIndex + 1
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
playerAAnswers = newAnswers,
|
||||||
|
selectedOptionId = null,
|
||||||
|
selectedScale = null,
|
||||||
|
currentIndex = if (next < it.questions.size) next else it.currentIndex,
|
||||||
|
phase = if (next >= it.questions.size) HowWellPhase.HANDOFF else HowWellPhase.PLAYER_A_TURN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readyForPlayerB() = _uiState.update {
|
||||||
|
it.copy(phase = HowWellPhase.PLAYER_B_TURN, currentIndex = 0, selectedOptionId = null, selectedScale = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmPrediction() {
|
||||||
|
val s = _uiState.value
|
||||||
|
val prediction = HowWellAnswer(s.selectedOptionId, s.selectedScale)
|
||||||
|
if (prediction.isEmpty) return
|
||||||
|
val actual = s.playerAAnswers.getOrNull(s.currentIndex) ?: return
|
||||||
|
val question = s.questions.getOrNull(s.currentIndex) ?: return
|
||||||
|
val match = prediction.isMatch(actual)
|
||||||
|
val close = prediction.isClose(actual)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
results = it.results + HowWellResult(question, actual, prediction, match, close),
|
||||||
|
selectedOptionId = null,
|
||||||
|
selectedScale = null,
|
||||||
|
score = if (match) it.score + 1 else it.score,
|
||||||
|
phase = HowWellPhase.REVEALING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextQuestion() {
|
||||||
|
val next = _uiState.value.currentIndex + 1
|
||||||
|
val total = _uiState.value.questions.size
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
currentIndex = if (next < total) next else it.currentIndex,
|
||||||
|
phase = if (next >= total) HowWellPhase.COMPLETE else HowWellPhase.PLAYER_B_TURN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restart() {
|
||||||
|
_uiState.value = HowWellUiState()
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SESSION_SIZE = 10
|
||||||
|
private const val TAG = "HowWellViewModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Root screen ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HowWellScreen(
|
||||||
|
onNavigate: (String) -> Unit = {},
|
||||||
|
viewModel: HowWellViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(closerBackgroundBrush())
|
||||||
|
) {
|
||||||
|
when (state.phase) {
|
||||||
|
HowWellPhase.LOADING -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
color = CloserPalette.PurpleDeep
|
||||||
|
)
|
||||||
|
HowWellPhase.PLAYER_A_INTRO -> PlayerIntroScreen(
|
||||||
|
playerNumber = 1,
|
||||||
|
total = state.questions.size,
|
||||||
|
onReady = viewModel::startPlayerA
|
||||||
|
)
|
||||||
|
HowWellPhase.PLAYER_A_TURN -> {
|
||||||
|
val q = state.questions.getOrNull(state.currentIndex) ?: return@Box
|
||||||
|
AnswerScreen(
|
||||||
|
question = q,
|
||||||
|
index = state.currentIndex,
|
||||||
|
total = state.questions.size,
|
||||||
|
isPlayerB = false,
|
||||||
|
selectedOptionId = state.selectedOptionId,
|
||||||
|
selectedScale = state.selectedScale,
|
||||||
|
onSelectOption = viewModel::selectOption,
|
||||||
|
onSelectScale = viewModel::selectScale,
|
||||||
|
onConfirm = viewModel::confirmAnswer,
|
||||||
|
onQuit = { onNavigate(AppRoute.PLAY) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HowWellPhase.HANDOFF -> HandoffScreen(onReady = viewModel::readyForPlayerB)
|
||||||
|
HowWellPhase.PLAYER_B_TURN -> {
|
||||||
|
val q = state.questions.getOrNull(state.currentIndex) ?: return@Box
|
||||||
|
AnswerScreen(
|
||||||
|
question = q,
|
||||||
|
index = state.currentIndex,
|
||||||
|
total = state.questions.size,
|
||||||
|
isPlayerB = true,
|
||||||
|
selectedOptionId = state.selectedOptionId,
|
||||||
|
selectedScale = state.selectedScale,
|
||||||
|
onSelectOption = viewModel::selectOption,
|
||||||
|
onSelectScale = viewModel::selectScale,
|
||||||
|
onConfirm = viewModel::confirmPrediction,
|
||||||
|
onQuit = { onNavigate(AppRoute.PLAY) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HowWellPhase.REVEALING -> {
|
||||||
|
val result = state.results.lastOrNull() ?: return@Box
|
||||||
|
RevealScreen(
|
||||||
|
result = result,
|
||||||
|
questionNumber = state.currentIndex + 1,
|
||||||
|
total = state.questions.size,
|
||||||
|
score = state.score,
|
||||||
|
onNext = viewModel::nextQuestion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HowWellPhase.COMPLETE -> CompleteScreen(
|
||||||
|
score = state.score,
|
||||||
|
total = state.questions.size,
|
||||||
|
results = state.results,
|
||||||
|
onPlayAgain = viewModel::restart,
|
||||||
|
onHome = { onNavigate(AppRoute.PLAY) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase screens ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayerIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 28.dp, vertical = 40.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(text = if (playerNumber == 1) "👤" else "🔮", fontSize = 56.sp, textAlign = TextAlign.Center)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
|
||||||
|
Text(
|
||||||
|
text = "Player $playerNumber",
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = if (playerNumber == 1)
|
||||||
|
"Answer $total questions honestly.\nYour partner will try to predict what you said."
|
||||||
|
else
|
||||||
|
"For each question, guess what your partner answered.\nNo peeking!",
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
lineHeight = MaterialTheme.typography.headlineSmall.lineHeight
|
||||||
|
)
|
||||||
|
if (playerNumber == 1) {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "Ask your partner to look away.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(36.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onReady,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
|
) { Text("I'm ready", color = Color.White) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HandoffScreen(onReady: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 28.dp, vertical = 40.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text("🎉", fontSize = 56.sp, textAlign = TextAlign.Center)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = "Pass the phone!",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "Player 1 is done. Hand the phone to Player 2 — keep your answers secret!",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color(0xFF5A5060),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(36.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onReady,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
|
) { Text("I'm Player 2, let's go!", color = Color.White) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AnswerScreen(
|
||||||
|
question: Question,
|
||||||
|
index: Int,
|
||||||
|
total: Int,
|
||||||
|
isPlayerB: Boolean,
|
||||||
|
selectedOptionId: String?,
|
||||||
|
selectedScale: Int?,
|
||||||
|
onSelectOption: (String) -> Unit,
|
||||||
|
onSelectScale: (Int) -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onQuit: () -> Unit
|
||||||
|
) {
|
||||||
|
val hasSelection = selectedOptionId != null || selectedScale != null
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
|
||||||
|
Text(
|
||||||
|
text = "Player ${if (isPlayerB) 2 else 1} · ${index + 1} / $total",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onQuit) {
|
||||||
|
Text("Quit", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { index.toFloat() / total },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
trackColor = CloserPalette.PurpleMist
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)),
|
||||||
|
elevation = CardDefaults.cardElevation(8.dp)
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
if (isPlayerB) {
|
||||||
|
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
|
||||||
|
Text(
|
||||||
|
text = "What did Player 1 say?",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = question.text,
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 5,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val config = question.answerConfig) {
|
||||||
|
is ChoiceAnswerConfigImpl -> SingleChoiceInput(
|
||||||
|
options = config.config.options,
|
||||||
|
selectedId = selectedOptionId,
|
||||||
|
onSelect = onSelectOption
|
||||||
|
)
|
||||||
|
is ThisOrThatAnswerConfigImpl -> BinaryChoiceInput(
|
||||||
|
optionA = config.config.optionA,
|
||||||
|
optionB = config.config.optionB,
|
||||||
|
selectedId = selectedOptionId,
|
||||||
|
onSelect = onSelectOption
|
||||||
|
)
|
||||||
|
is ScaleAnswerConfigImpl -> ScaleInput(
|
||||||
|
min = config.config.minScale,
|
||||||
|
max = config.config.maxScale,
|
||||||
|
minLabel = config.config.minLabel,
|
||||||
|
maxLabel = config.config.maxLabel,
|
||||||
|
selected = selectedScale,
|
||||||
|
onSelect = onSelectScale
|
||||||
|
)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onConfirm,
|
||||||
|
enabled = hasSelection,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (index + 1 >= total && !isPlayerB) "Done →" else "Confirm →",
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RevealScreen(
|
||||||
|
result: HowWellResult,
|
||||||
|
questionNumber: Int,
|
||||||
|
total: Int,
|
||||||
|
score: Int,
|
||||||
|
onNext: () -> Unit
|
||||||
|
) {
|
||||||
|
val matchColor = Color(0xFF2E7D32)
|
||||||
|
val closeColor = Color(0xFFF57F17)
|
||||||
|
val missColor = Color(0xFFC62828)
|
||||||
|
val accentColor = when {
|
||||||
|
result.isMatch -> matchColor
|
||||||
|
result.isClose -> closeColor
|
||||||
|
else -> missColor
|
||||||
|
}
|
||||||
|
val bgColor = when {
|
||||||
|
result.isMatch -> Color(0xFFE8F5E9)
|
||||||
|
result.isClose -> Color(0xFFFFF8E1)
|
||||||
|
else -> Color(0xFFFCE4EC)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$questionNumber / $total",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.PurpleMist) {
|
||||||
|
Text(
|
||||||
|
text = "Score: $score / $questionNumber",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = bgColor),
|
||||||
|
elevation = CardDefaults.cardElevation(0.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (result.isMatch) "✓" else if (result.isClose) "≈" else "✗",
|
||||||
|
fontSize = 28.sp,
|
||||||
|
color = accentColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (result.isMatch) "Match!" else if (result.isClose) "So close!" else "Not quite",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = accentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = result.question.text,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
AnswerRevealCard(
|
||||||
|
label = "Player 1 said",
|
||||||
|
text = result.playerAAnswer.displayText(result.question.answerConfig),
|
||||||
|
isMatch = result.isMatch,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
AnswerRevealCard(
|
||||||
|
label = "Player 2 guessed",
|
||||||
|
text = result.playerBAnswer.displayText(result.question.answerConfig),
|
||||||
|
isMatch = result.isMatch,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onNext,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (questionNumber >= total) "See results" else "Next →",
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AnswerRevealCard(
|
||||||
|
label: String,
|
||||||
|
text: String,
|
||||||
|
isMatch: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.heightIn(min = 90.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (isMatch) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(4.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (isMatch) Color(0xFF2E7D32) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = if (isMatch) Color(0xFF1B5E20) else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompleteScreen(
|
||||||
|
score: Int,
|
||||||
|
total: Int,
|
||||||
|
results: List<HowWellResult>,
|
||||||
|
onPlayAgain: () -> Unit,
|
||||||
|
onHome: () -> Unit
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 32.dp, bottom = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text("🎯", fontSize = 56.sp, textAlign = TextAlign.Center)
|
||||||
|
Text(
|
||||||
|
text = "$score / $total",
|
||||||
|
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = scoreLabel(score, total),
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Breakdown",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(results) { result -> BreakdownRow(result) }
|
||||||
|
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onPlayAgain,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
|
) { Text("Play again", color = Color.White) }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onHome,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp)
|
||||||
|
) { Text("Back to Play") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BreakdownRow(result: HowWellResult) {
|
||||||
|
val matchColor = Color(0xFF2E7D32)
|
||||||
|
val closeColor = Color(0xFFF57F17)
|
||||||
|
val missColor = Color(0xFFC62828)
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (result.isMatch) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(2.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (result.isMatch) "✓" else if (result.isClose) "≈" else "✗",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = if (result.isMatch) matchColor else if (result.isClose) closeColor else missColor
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(
|
||||||
|
text = result.question.text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
val config = result.question.answerConfig
|
||||||
|
if (result.isMatch) {
|
||||||
|
Text(
|
||||||
|
text = result.playerAAnswer.displayText(config),
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = matchColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = result.playerAAnswer.displayText(config),
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = if (result.isClose) closeColor else missColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "→ ${result.playerBAnswer.displayText(config)}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Answer input widgets ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SingleChoiceInput(
|
||||||
|
options: List<ChoiceOption>,
|
||||||
|
selectedId: String?,
|
||||||
|
onSelect: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
options.forEach { option ->
|
||||||
|
val selected = selectedId == option.id
|
||||||
|
Card(
|
||||||
|
onClick = { onSelect(option.id) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (selected) CloserPalette.PurpleDeep else MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(if (selected) 6.dp else 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = option.text,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
|
||||||
|
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BinaryChoiceInput(
|
||||||
|
optionA: ChoiceOption,
|
||||||
|
optionB: ChoiceOption,
|
||||||
|
selectedId: String?,
|
||||||
|
onSelect: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
listOf(optionA to "A", optionB to "B").forEach { (option, label) ->
|
||||||
|
val selected = selectedId == option.id
|
||||||
|
Card(
|
||||||
|
onClick = { onSelect(option.id) },
|
||||||
|
modifier = Modifier.weight(1f).heightIn(min = 80.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (selected) CloserPalette.PurpleDeep else MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(if (selected) 6.dp else 2.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (selected) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = option.text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScaleInput(
|
||||||
|
min: Int,
|
||||||
|
max: Int,
|
||||||
|
minLabel: String,
|
||||||
|
maxLabel: String,
|
||||||
|
selected: Int?,
|
||||||
|
onSelect: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
(min..max).forEach { value ->
|
||||||
|
val isSelected = selected == value
|
||||||
|
Surface(
|
||||||
|
onClick = { onSelect(value) },
|
||||||
|
shape = CircleShape,
|
||||||
|
color = if (isSelected) CloserPalette.PurpleDeep else MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier.size(52.dp),
|
||||||
|
shadowElevation = if (isSelected) 6.dp else 2.dp
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = "$value",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(minLabel, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(maxLabel, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun scoreLabel(score: Int, total: Int): String = when {
|
||||||
|
score == total -> "Mind reader! 🤯"
|
||||||
|
score >= total * 0.8 -> "You really know each other 💜"
|
||||||
|
score >= total * 0.6 -> "Pretty good!"
|
||||||
|
score >= total * 0.4 -> "Getting there!"
|
||||||
|
else -> "Room to grow 🌱"
|
||||||
|
}
|
||||||
|
|
@ -102,6 +102,12 @@ private fun PlayHubContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
HowWellCard(
|
||||||
|
onClick = { onNavigate(AppRoute.HOW_WELL) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -231,6 +237,84 @@ private fun ThisOrThatCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HowWellCard(
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(18.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = CloserPalette.Romantic.copy(alpha = 0.12f),
|
||||||
|
modifier = Modifier.size(52.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = "💜",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "How Well Do You Know Me",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = CloserPalette.Romantic.copy(alpha = 0.12f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "10 rounds",
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = CloserPalette.Romantic,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "One answers, the other predicts. Find out how well you really know each other.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = CloserPalette.Romantic,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FeaturedPlayCard(
|
private fun FeaturedPlayCard(
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,18 @@ package app.closer.ui.thisorthat
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -18,30 +25,34 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
@ -196,6 +207,12 @@ private fun ThisOrThatContent(
|
||||||
onSelect: (String) -> Unit,
|
onSelect: (String) -> Unit,
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = ((currentIndex + 1).toFloat() / total.coerceAtLeast(1)).coerceIn(0f, 1f),
|
||||||
|
animationSpec = tween(durationMillis = 260),
|
||||||
|
label = "this_or_that_progress"
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -226,6 +243,8 @@ private fun ThisOrThatContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ThisOrThatProgress(progress = animatedProgress)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -240,6 +259,9 @@ private fun ThisOrThatContent(
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
ChoicePromptBackdrop(
|
||||||
|
modifier = Modifier.matchParentSize()
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
|
@ -278,12 +300,8 @@ private fun ThisOrThatContent(
|
||||||
onSelect = onSelect
|
onSelect = onSelect
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
VersusBadge(
|
||||||
text = "or",
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
|
|
||||||
OptionCard(
|
OptionCard(
|
||||||
|
|
@ -298,6 +316,69 @@ private fun ThisOrThatContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThisOrThatProgress(
|
||||||
|
progress: Float
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(8.dp)
|
||||||
|
.clip(RoundedCornerShape(999.dp))
|
||||||
|
.background(CloserPalette.PinkMist)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(progress.coerceIn(0f, 1f))
|
||||||
|
.clip(RoundedCornerShape(999.dp))
|
||||||
|
.background(CloserPalette.PurpleDeep)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChoicePromptBackdrop(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
val minDimension = size.minDimension
|
||||||
|
val radius = minDimension * 0.16f
|
||||||
|
val left = Offset(size.width * 0.18f, size.height * 0.18f)
|
||||||
|
val right = Offset(size.width * 0.82f, size.height * 0.82f)
|
||||||
|
val strokeWidth = 3.dp.toPx()
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
color = CloserPalette.PurpleMist.copy(alpha = 0.9f),
|
||||||
|
start = left,
|
||||||
|
end = right,
|
||||||
|
strokeWidth = strokeWidth
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = CloserPalette.PurpleGlow.copy(alpha = 0.58f),
|
||||||
|
radius = radius,
|
||||||
|
center = left
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = CloserPalette.PinkSoft.copy(alpha = 0.76f),
|
||||||
|
radius = radius * 0.94f,
|
||||||
|
center = right
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = CloserPalette.PurpleDeep.copy(alpha = 0.12f),
|
||||||
|
radius = radius * 0.56f,
|
||||||
|
center = left,
|
||||||
|
style = Stroke(width = strokeWidth)
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = CloserPalette.PinkAccentDeep.copy(alpha = 0.1f),
|
||||||
|
radius = radius * 0.54f,
|
||||||
|
center = right,
|
||||||
|
style = Stroke(width = strokeWidth)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OptionCard(
|
private fun OptionCard(
|
||||||
text: String,
|
text: String,
|
||||||
|
|
@ -310,6 +391,15 @@ private fun OptionCard(
|
||||||
val isSelected = pendingSelection == optionId
|
val isSelected = pendingSelection == optionId
|
||||||
val isOtherSelected = pendingSelection != null && !isSelected
|
val isOtherSelected = pendingSelection != null && !isSelected
|
||||||
|
|
||||||
|
val cardScale by animateFloatAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isSelected -> 1.02f
|
||||||
|
isOtherSelected -> 0.98f
|
||||||
|
else -> 1f
|
||||||
|
},
|
||||||
|
animationSpec = tween(durationMillis = 180),
|
||||||
|
label = "scale_$optionId"
|
||||||
|
)
|
||||||
val background by animateColorAsState(
|
val background by animateColorAsState(
|
||||||
targetValue = when {
|
targetValue = when {
|
||||||
isSelected -> accentColor
|
isSelected -> accentColor
|
||||||
|
|
@ -333,7 +423,8 @@ private fun OptionCard(
|
||||||
onClick = { if (pendingSelection == null) onSelect(optionId) },
|
onClick = { if (pendingSelection == null) onSelect(optionId) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 72.dp),
|
.heightIn(min = 72.dp)
|
||||||
|
.scale(cardScale),
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = background),
|
colors = CardDefaults.cardColors(containerColor = background),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 10.dp else 4.dp)
|
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 10.dp else 4.dp)
|
||||||
|
|
@ -370,6 +461,37 @@ private fun OptionCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VersusBadge(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "versus_badge")
|
||||||
|
val pulse by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.96f,
|
||||||
|
targetValue = 1.04f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1100),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "versus_badge_pulse"
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.scale(pulse),
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = CloserPalette.PurpleMist,
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "OR",
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThisOrThatComplete(
|
private fun ThisOrThatComplete(
|
||||||
aCount: Int,
|
aCount: Int,
|
||||||
|
|
@ -389,7 +511,7 @@ private fun ThisOrThatComplete(
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
Text("✓", fontSize = 64.sp, color = CloserPalette.PurpleDeep)
|
ChoiceCompleteBadge()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
|
@ -424,7 +546,7 @@ private fun ThisOrThatComplete(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
TallyItem(label = "A", count = aCount, color = CloserPalette.PurpleDeep)
|
TallyItem(label = "A", count = aCount, color = CloserPalette.PurpleDeep)
|
||||||
Divider(
|
VerticalDivider(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(48.dp)
|
.height(48.dp)
|
||||||
.width(1.dp),
|
.width(1.dp),
|
||||||
|
|
@ -459,6 +581,41 @@ private fun ThisOrThatComplete(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChoiceCompleteBadge() {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "choice_complete")
|
||||||
|
val pulse by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.96f,
|
||||||
|
targetValue = 1.04f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1300),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "choice_complete_pulse"
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(104.dp)
|
||||||
|
.scale(pulse),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = CloserPalette.PinkMist,
|
||||||
|
shadowElevation = 10.dp
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.background(CloserPalette.PurpleMist.copy(alpha = 0.42f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "A/B",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TallyItem(label: String, count: Int, color: Color) {
|
private fun TallyItem(label: String, count: Int, color: Color) {
|
||||||
Column(
|
Column(
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ package app.closer.ui.wheel
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -34,6 +36,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
|
@ -176,7 +179,11 @@ private fun SpinWheelContent(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 56.dp),
|
.heightIn(min = 56.dp),
|
||||||
shape = RoundedCornerShape(18.dp)
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
border = BorderStroke(1.dp, CloserPalette.PurpleDeep.copy(alpha = 0.44f)),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = CloserPalette.PurpleDeep
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text("Spin again")
|
Text("Spin again")
|
||||||
}
|
}
|
||||||
|
|
@ -214,9 +221,31 @@ private fun WheelSpinner(
|
||||||
rotation: Float
|
rotation: Float
|
||||||
) {
|
) {
|
||||||
val segmentColors = closerWheelSegmentColors()
|
val segmentColors = closerWheelSegmentColors()
|
||||||
|
val idleTransition = rememberInfiniteTransition(label = "wheel_idle")
|
||||||
|
val idlePulse by idleTransition.animateFloat(
|
||||||
|
initialValue = 0.98f,
|
||||||
|
targetValue = 1.02f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1400),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "wheel_idle_pulse"
|
||||||
|
)
|
||||||
|
val centerScale by animateFloatAsState(
|
||||||
|
targetValue = if (spunAndReady) 1.08f else 1f,
|
||||||
|
animationSpec = tween(durationMillis = 220),
|
||||||
|
label = "wheel_center_scale"
|
||||||
|
)
|
||||||
|
val pointerScale by animateFloatAsState(
|
||||||
|
targetValue = if (isSpinning) 1.12f else 1f,
|
||||||
|
animationSpec = tween(durationMillis = 160),
|
||||||
|
label = "wheel_pointer_scale"
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.size(270.dp),
|
modifier = Modifier
|
||||||
|
.size(270.dp)
|
||||||
|
.scale(if (isSpinning) 1f else idlePulse),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Canvas(
|
Canvas(
|
||||||
|
|
@ -247,6 +276,7 @@ private fun WheelSpinner(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.size(width = 34.dp, height = 28.dp)
|
.size(width = 34.dp, height = 28.dp)
|
||||||
|
.scale(pointerScale)
|
||||||
) {
|
) {
|
||||||
val path = Path().apply {
|
val path = Path().apply {
|
||||||
moveTo(size.width / 2f, size.height)
|
moveTo(size.width / 2f, size.height)
|
||||||
|
|
@ -258,9 +288,11 @@ private fun WheelSpinner(
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.size(94.dp),
|
modifier = Modifier
|
||||||
|
.size(94.dp)
|
||||||
|
.scale(centerScale),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = if (spunAndReady) CloserPalette.PinkMist else MaterialTheme.colorScheme.surface,
|
||||||
shadowElevation = 10.dp
|
shadowElevation = 10.dp
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue