feat(dashboard): módulo 2 — tablero de hábitos y racha

- Layout 3 columnas (sidebar 232dp · centro flex · derecha 296dp)
- Tarjetas de hábito: franja de color, ícono 62dp, check 74dp con
  palomita animada (drawCheck 0.5s + pop) y badge ¡Hecho!, toggle reversible
- Tarjeta Tu racha: llama Canvas con flicker, semana L-D, banner
  '¡Racha asegurada hoy!' al completar todo
- Confeti de 26 piezas con paleta Rachita (una vez por celebración)
- Sidebar: perfil con mascota, barra XP derivada de logs (10 XP/hábito),
  menú de navegación (solo Inicio activo en sprint 1)
- Chip de batería real (gris normal / coral <20% / verde cargando)
- Gate de arranque: onboarding completado → directo al dashboard
- Copy exacto del prototipo; fix orejas recortadas de mascotas
This commit is contained in:
Johann
2026-07-03 22:41:43 -06:00
parent 33de656931
commit 1d34b78273
11 changed files with 1373 additions and 37 deletions
@@ -4,20 +4,29 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.ui.unit.dp
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import mx.paputec.rachita.domain.repository.ProfileRepository
import mx.paputec.rachita.ui.dashboard.DashboardScreen
import mx.paputec.rachita.ui.dashboard.DashboardViewModel
import mx.paputec.rachita.ui.onboarding.OnboardingScreen
import mx.paputec.rachita.ui.onboarding.OnboardingViewModel
import mx.paputec.rachita.ui.theme.CreamBackground
@@ -31,7 +40,7 @@ class MainActivity : ComponentActivity() {
setContent {
RachitaTheme {
Surface(
modifier = Modifier.fillMaxSize().background(CreamBackground),
modifier = Modifier.fillMaxSize(),
color = CreamBackground,
) {
RachitaNavHost()
@@ -46,10 +55,32 @@ private object Routes {
const val DASHBOARD = "dashboard"
}
// Resolves the start destination once per process: children who already chose
// a companion land straight on the dashboard (PRD §8: onboarding is first-run only).
@HiltViewModel
class StartRouteViewModel @Inject constructor(
profileRepository: ProfileRepository,
) : ViewModel() {
private val _startRoute = MutableStateFlow<String?>(null)
val startRoute: StateFlow<String?> = _startRoute.asStateFlow()
init {
viewModelScope.launch {
val profile = profileRepository.profile.first()
_startRoute.value = if (profile.onboardingCompleted) Routes.DASHBOARD else Routes.ONBOARDING
}
}
}
@Composable
private fun RachitaNavHost() {
val startViewModel: StartRouteViewModel = hiltViewModel()
val startRoute by startViewModel.startRoute.collectAsStateWithLifecycle()
// DataStore resolves in milliseconds; the cream Surface behind acts as splash.
val resolved = startRoute ?: return
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Routes.ONBOARDING) {
NavHost(navController = navController, startDestination = resolved) {
composable(Routes.ONBOARDING) {
val viewModel: OnboardingViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -67,32 +98,11 @@ private fun RachitaNavHost() {
)
}
composable(Routes.DASHBOARD) {
// Dashboard placeholder — implemented in the next sub-task of sprint 1.
DashboardPlaceholder()
}
}
}
@Composable
private fun DashboardPlaceholder() {
// Visible stand-in until module 2 lands — an all-cream screen here reads as
// "the app went blank" during on-device testing.
Surface(
modifier = Modifier.fillMaxSize(),
color = CreamBackground,
) {
androidx.compose.foundation.layout.Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
) {
mx.paputec.rachita.ui.onboarding.components.Mascot(
kind = mx.paputec.rachita.domain.model.AvatarKind.Fox,
modifier = Modifier.size(180.dp),
)
androidx.compose.material3.Text(
text = "¡Compañero elegido! El tablero de hábitos llega en el módulo 2.",
style = androidx.compose.material3.MaterialTheme.typography.titleLarge,
val viewModel: DashboardViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DashboardScreen(
uiState = uiState,
onToggleHabit = viewModel::toggleHabit,
)
}
}
@@ -0,0 +1,253 @@
package mx.paputec.rachita.ui.dashboard
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BatteryChargingFull
import androidx.compose.material.icons.rounded.BatteryFull
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import mx.paputec.rachita.R
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.model.BatteryStatus
import mx.paputec.rachita.domain.model.HabitIcon
import mx.paputec.rachita.domain.model.HabitPalette
import mx.paputec.rachita.ui.dashboard.components.ConfettiOverlay
import mx.paputec.rachita.ui.dashboard.components.HabitCard
import mx.paputec.rachita.ui.dashboard.components.RightColumn
import mx.paputec.rachita.ui.dashboard.components.Sidebar
import mx.paputec.rachita.ui.theme.ChargingGreen
import mx.paputec.rachita.ui.theme.CoralIntense
import mx.paputec.rachita.ui.theme.CreamBackground
import mx.paputec.rachita.ui.theme.CreamCard
import mx.paputec.rachita.ui.theme.RachitaTheme
import mx.paputec.rachita.ui.theme.SuccessGreen
import mx.paputec.rachita.ui.theme.WarmGreyDeep
// Module 2 — dashboard root (PRD §5.2): sidebar 232 dp · center flex · right 296 dp.
@Composable
fun DashboardScreen(
uiState: DashboardUiState,
onToggleHabit: (String) -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(CreamBackground)
.padding(WindowInsets.safeDrawing.asPaddingValues()),
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Sidebar(
avatar = uiState.avatar,
childName = uiState.childName,
level = uiState.level,
xpInLevel = uiState.xpInLevel,
xpTarget = uiState.xpTarget,
streak = uiState.streak,
)
CenterColumn(
uiState = uiState,
onToggleHabit = onToggleHabit,
modifier = Modifier.weight(1f),
)
RightColumn(
streak = uiState.streak,
week = uiState.week,
streakSecuredToday = uiState.allDone,
mascotMessage = if (uiState.allDone) {
stringResource(R.string.dashboard_mascot_celebrate, uiState.childName)
} else {
stringResource(R.string.dashboard_mascot_normal)
},
avatar = uiState.avatar,
)
}
// Confetti above everything (DA-04); confettiOn toggle arrives with Settings (DA-05).
ConfettiOverlay(trigger = uiState.celebrationCount, enabled = true)
}
}
@Composable
private fun CenterColumn(
uiState: DashboardUiState,
onToggleHabit: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = stringResource(R.string.dashboard_greeting, uiState.childName),
style = MaterialTheme.typography.headlineMedium,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "${uiState.dateLine} · ${pendingLine(uiState.pendingCount)}",
style = MaterialTheme.typography.bodyMedium.copy(color = WarmGreyDeep),
)
BackgroundChip()
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
uiState.battery?.let { BatteryChip(it) }
TodayCounterPill(done = uiState.doneCount, total = uiState.totalCount)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(count = uiState.habits.size, key = { uiState.habits[it].id }) { index ->
val habit = uiState.habits[index]
HabitCard(habit = habit, onToggle = { onToggleHabit(habit.id) })
}
}
}
}
@Composable
private fun pendingLine(pending: Int): String = when (pending) {
0 -> stringResource(R.string.dashboard_pending_zero)
1 -> stringResource(R.string.dashboard_pending_one)
else -> stringResource(R.string.dashboard_pending_many, pending)
}
@Composable
private fun BackgroundChip() {
Row(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(CreamCard)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(RoundedCornerShape(50))
.background(SuccessGreen),
)
Text(
text = stringResource(R.string.dashboard_background_chip),
style = MaterialTheme.typography.labelSmall.copy(color = CoralIntense),
)
}
}
@Composable
private fun TodayCounterPill(done: Int, total: Int) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Color.White)
.padding(horizontal = 14.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.dashboard_today_counter, done, total),
style = MaterialTheme.typography.labelLarge,
)
}
}
// DA-08: battery indicator reacting to real state — grey normal, coral <20%,
// green while charging.
@Composable
private fun BatteryChip(battery: BatteryStatus) {
val tint = when {
battery.isCharging -> ChargingGreen
battery.percent < 20 -> CoralIntense
else -> WarmGreyDeep
}
Row(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Color.White)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = if (battery.isCharging) Icons.Rounded.BatteryChargingFull else Icons.Rounded.BatteryFull,
contentDescription = stringResource(R.string.cd_battery, battery.percent),
tint = tint,
modifier = Modifier.size(16.dp),
)
Text(
text = "${battery.percent}%",
style = MaterialTheme.typography.labelMedium.copy(color = tint),
)
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 1280, heightDp = 800)
@Composable
private fun PreviewDashboard() {
RachitaTheme {
DashboardScreen(
uiState = DashboardUiState(
childName = "Lucía",
avatar = AvatarKind.Fox,
habits = listOf(
HabitCardUi("banarse", "Bañarse", "Antes de dormir", HabitIcon.Drop, HabitPalette.Mint, true),
HabitCardUi("dormir", "Dormir temprano", "A las 9:00 pm", HabitIcon.Moon, HabitPalette.Lilac, false),
HabitCardUi("escuela", "Ir a la escuela", "Sin faltar nunca", HabitIcon.School, HabitPalette.Coral, false),
),
streak = 12,
week = listOf(
WeekDayUi("L", true, false),
WeekDayUi("M", true, false),
WeekDayUi("M", true, false),
WeekDayUi("J", false, true),
WeekDayUi("V", false, false),
WeekDayUi("S", false, false),
WeekDayUi("D", false, false),
),
level = 7,
xpInLevel = 320,
battery = BatteryStatus(86, isCharging = false),
dateLine = "Lunes 22 de junio",
isLoading = false,
),
onToggleHabit = {},
)
}
}
@@ -0,0 +1,47 @@
package mx.paputec.rachita.ui.dashboard
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.model.BatteryStatus
import mx.paputec.rachita.domain.model.HabitIcon
import mx.paputec.rachita.domain.model.HabitPalette
data class HabitCardUi(
val id: String,
val name: String,
val subtitle: String,
val icon: HabitIcon,
val palette: HabitPalette,
val doneToday: Boolean,
)
data class WeekDayUi(
val label: String,
val secured: Boolean,
val isToday: Boolean,
)
data class DashboardUiState(
val childName: String = "",
val avatar: AvatarKind = AvatarKind.Fox,
val habits: List<HabitCardUi> = emptyList(),
val streak: Int = 0,
val week: List<WeekDayUi> = emptyList(),
val level: Int = 1,
val xpInLevel: Int = 0,
val xpTarget: Int = XP_PER_LEVEL,
val battery: BatteryStatus? = null,
val dateLine: String = "",
/** Increments on each new all-done celebration; UI fires confetti on change. */
val celebrationCount: Int = 0,
val isLoading: Boolean = true,
) {
val doneCount: Int get() = habits.count { it.doneToday }
val totalCount: Int get() = habits.size
val pendingCount: Int get() = totalCount - doneCount
val allDone: Boolean get() = totalCount > 0 && doneCount == totalCount
companion object {
const val XP_PER_LEVEL = 500
const val XP_PER_HABIT = 10
}
}
@@ -0,0 +1,122 @@
package mx.paputec.rachita.ui.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import mx.paputec.rachita.domain.StreakCalculator
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.model.Habit
import mx.paputec.rachita.domain.model.HabitLog
import mx.paputec.rachita.domain.model.Profile
import mx.paputec.rachita.domain.repository.HabitRepository
import mx.paputec.rachita.domain.repository.ProfileRepository
import mx.paputec.rachita.platform.battery.BatteryStatusMonitor
import mx.paputec.rachita.ui.dashboard.DashboardUiState.Companion.XP_PER_HABIT
import mx.paputec.rachita.ui.dashboard.DashboardUiState.Companion.XP_PER_LEVEL
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val habitRepository: HabitRepository,
profileRepository: ProfileRepository,
batteryMonitor: BatteryStatusMonitor,
) : ViewModel() {
// Session-scoped guard so confetti fires once per day (QA #4): re-toggling
// the same all-done state must not repeat the celebration.
private var lastCelebratedDate: LocalDate? = null
private var celebrations = 0
init {
viewModelScope.launch { habitRepository.seedDefaultHabitsIfEmpty() }
}
val uiState: StateFlow<DashboardUiState> = combine(
habitRepository.activeHabits,
habitRepository.doneLogs,
profileRepository.profile,
batteryMonitor.status,
) { habits, doneLogs, profile, battery ->
buildState(habits, doneLogs, profile, battery, LocalDate.now())
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = DashboardUiState(),
)
fun toggleHabit(habitId: String) {
viewModelScope.launch { habitRepository.toggleHabit(habitId, LocalDate.now()) }
}
private fun buildState(
habits: List<Habit>,
doneLogs: List<HabitLog>,
profile: Profile,
battery: mx.paputec.rachita.domain.model.BatteryStatus,
today: LocalDate,
): DashboardUiState {
val activeIds = habits.map { it.id }.toSet()
val doneTodayIds = doneLogs.filter { it.date == today }.map { it.habitId }.toSet()
val secured = StreakCalculator.securedDates(doneLogs, activeIds)
val streak = StreakCalculator.currentStreak(secured, today)
val allDone = habits.isNotEmpty() && doneTodayIds.containsAll(activeIds)
if (allDone && lastCelebratedDate != today) {
lastCelebratedDate = today
celebrations++
}
// XP fully derived from history: reversible and drift-free (like the streak).
val xp = doneLogs.count { it.habitId in activeIds } * XP_PER_HABIT
val monday = today.with(DayOfWeek.MONDAY)
val week = (0..6).map { offset ->
val day = monday.plusDays(offset.toLong())
WeekDayUi(
label = WEEK_LABELS[offset],
secured = day in secured || (day == today && allDone),
isToday = day == today,
)
}
return DashboardUiState(
childName = profile.childName,
avatar = profile.avatar ?: AvatarKind.Fox,
habits = habits.map { habit ->
HabitCardUi(
id = habit.id,
name = habit.name,
subtitle = habit.subtitle,
icon = habit.icon,
palette = habit.palette,
doneToday = habit.id in doneTodayIds,
)
},
streak = streak,
week = week,
level = xp / XP_PER_LEVEL + 1,
xpInLevel = xp % XP_PER_LEVEL,
xpTarget = XP_PER_LEVEL,
battery = battery,
dateLine = today.format(DATE_FORMAT).replaceFirstChar { it.titlecase(LOCALE_MX) },
celebrationCount = celebrations,
isLoading = false,
)
}
private companion object {
// Prototype week row: L M M J V S D.
val WEEK_LABELS = listOf("L", "M", "M", "J", "V", "S", "D")
val LOCALE_MX = Locale("es", "MX")
val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM", LOCALE_MX)
}
}
@@ -0,0 +1,93 @@
package mx.paputec.rachita.ui.dashboard.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.unit.dp
import kotlin.random.Random
import kotlinx.coroutines.launch
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.Lilac
import mx.paputec.rachita.ui.theme.Mint
import mx.paputec.rachita.ui.theme.Pink
import mx.paputec.rachita.ui.theme.Sun
// DA-04: 26 palette-colored pieces falling 1.12.1 s, fired once per celebration.
// `trigger` is the celebration counter; 0 means "never celebrated".
private const val PIECE_COUNT = 26
private val PALETTE = listOf(Coral, Mint, Sun, Lilac, Pink)
private data class ConfettiPiece(
val xFraction: Float,
val color: Color,
val width: Float,
val height: Float,
val durationMs: Int,
val delayMs: Int,
val rotationSeed: Float,
)
@Composable
fun ConfettiOverlay(
trigger: Int,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
var pieces by remember { mutableStateOf<List<ConfettiPiece>>(emptyList()) }
val progress = remember { Animatable(0f) }
LaunchedEffect(trigger) {
if (trigger <= 0 || !enabled) return@LaunchedEffect
// Seeded by trigger: deterministic per celebration, varied across them.
val random = Random(trigger)
pieces = List(PIECE_COUNT) {
ConfettiPiece(
xFraction = random.nextFloat(),
color = PALETTE[random.nextInt(PALETTE.size)],
width = 8f + random.nextFloat() * 8f,
height = 12f + random.nextFloat() * 10f,
durationMs = 1100 + random.nextInt(1000),
delayMs = random.nextInt(350),
rotationSeed = random.nextFloat() * 360f,
)
}
progress.snapTo(0f)
launch {
progress.animateTo(1f, animationSpec = tween(durationMillis = 2450, easing = LinearEasing))
pieces = emptyList()
}
}
if (pieces.isEmpty()) return
Canvas(modifier = modifier.fillMaxSize()) {
val t = progress.value
pieces.forEach { piece ->
val total = piece.delayMs + piece.durationMs
val local = ((t * 2450f) - piece.delayMs) / piece.durationMs
if (local <= 0f || local > 1f || total <= 0) return@forEach
val x = piece.xFraction * size.width
val y = local * (size.height + 60.dp.toPx()) - 30.dp.toPx()
rotate(degrees = piece.rotationSeed + local * 540f, pivot = Offset(x, y)) {
drawRect(
color = piece.color,
topLeft = Offset(x - piece.width / 2f, y - piece.height / 2f),
size = Size(piece.width, piece.height),
)
}
}
}
}
@@ -0,0 +1,239 @@
package mx.paputec.rachita.ui.dashboard.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DarkMode
import androidx.compose.material.icons.rounded.School
import androidx.compose.material.icons.rounded.WaterDrop
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import mx.paputec.rachita.R
import mx.paputec.rachita.domain.model.HabitIcon
import mx.paputec.rachita.domain.model.HabitPalette
import mx.paputec.rachita.ui.dashboard.HabitCardUi
import mx.paputec.rachita.ui.theme.CardBorder
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.CoralSoft
import mx.paputec.rachita.ui.theme.CreamBackground
import mx.paputec.rachita.ui.theme.Lilac
import mx.paputec.rachita.ui.theme.LilacSoft
import mx.paputec.rachita.ui.theme.Mint
import mx.paputec.rachita.ui.theme.MintSoft
import mx.paputec.rachita.ui.theme.RachitaTheme
import mx.paputec.rachita.ui.theme.SuccessGreen
import mx.paputec.rachita.ui.theme.WarmGreyDeep
val HabitPalette.strong: Color
get() = when (this) {
HabitPalette.Mint -> Mint
HabitPalette.Lilac -> Lilac
HabitPalette.Coral -> Coral
}
val HabitPalette.soft: Color
get() = when (this) {
HabitPalette.Mint -> MintSoft
HabitPalette.Lilac -> LilacSoft
HabitPalette.Coral -> CoralSoft
}
val HabitIcon.vector: ImageVector
get() = when (this) {
HabitIcon.Drop -> Icons.Rounded.WaterDrop
HabitIcon.Moon -> Icons.Rounded.DarkMode
HabitIcon.School -> Icons.Rounded.School
}
// DA-01: card ≥90 dp tall, side color stripe, 62 dp icon container, 74 dp check.
@Composable
fun HabitCard(
habit: HabitCardUi,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(96.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.White)
.border(2.dp, CardBorder, RoundedCornerShape(20.dp)),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(6.dp)
.fillMaxHeight()
.background(habit.palette.strong),
)
Box(
modifier = Modifier
.padding(start = 14.dp)
.size(62.dp)
.clip(RoundedCornerShape(16.dp))
.background(habit.palette.soft),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = habit.icon.vector,
contentDescription = null,
tint = habit.palette.strong,
modifier = Modifier.size(32.dp),
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 14.dp),
) {
Text(text = habit.name, style = MaterialTheme.typography.titleLarge)
Text(
text = habit.subtitle,
style = MaterialTheme.typography.bodyMedium.copy(color = WarmGreyDeep),
)
}
Box(
modifier = Modifier.padding(end = 12.dp),
contentAlignment = Alignment.Center,
) {
HabitCheckButton(
done = habit.doneToday,
onToggle = onToggle,
contentDescription = stringResource(R.string.cd_habit_check, habit.name),
)
// Qualified: inside Box-in-Row the RowScope overload shadows this one.
androidx.compose.animation.AnimatedVisibility(
visible = habit.doneToday,
enter = scaleIn(spring(stiffness = 400f)) + fadeIn(),
exit = scaleOut() + fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter),
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(SuccessGreen)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
text = stringResource(R.string.dashboard_done_badge),
style = MaterialTheme.typography.labelSmall.copy(color = Color.White),
)
}
}
}
}
}
// 74 dp touch target (PRD §6.3 hard rule for small hands).
@Composable
private fun HabitCheckButton(
done: Boolean,
onToggle: () -> Unit,
contentDescription: String,
) {
val checkProgress by animateFloatAsState(
targetValue = if (done) 1f else 0f,
animationSpec = tween(durationMillis = 500),
label = "drawCheck",
)
val pop by animateFloatAsState(
targetValue = if (done) 1f else 0.92f,
animationSpec = spring(dampingRatio = 0.45f, stiffness = 500f),
label = "pop",
)
Box(
modifier = Modifier
.size(74.dp)
.scale(pop)
.clip(CircleShape)
.background(if (done) SuccessGreen else Color.White)
.border(3.dp, if (done) SuccessGreen else CardBorder, CircleShape)
.clickable(onClick = onToggle)
.semantics { this.contentDescription = contentDescription },
contentAlignment = Alignment.Center,
) {
if (checkProgress > 0f) {
Canvas(modifier = Modifier.size(34.dp)) {
val path = Path().apply {
moveTo(size.width * 0.08f, size.height * 0.55f)
lineTo(size.width * 0.38f, size.height * 0.85f)
lineTo(size.width * 0.92f, size.height * 0.18f)
}
val measure = PathMeasure().apply { setPath(path, false) }
val partial = Path()
measure.getSegment(0f, measure.length * checkProgress, partial, true)
drawPath(
path = partial,
color = CreamBackground,
style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round),
)
}
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 640)
@Composable
private fun PreviewHabitCardPending() {
RachitaTheme {
HabitCard(
habit = HabitCardUi("banarse", "Bañarse", "Antes de dormir", HabitIcon.Drop, HabitPalette.Mint, doneToday = false),
onToggle = {},
)
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 640)
@Composable
private fun PreviewHabitCardDone() {
RachitaTheme {
HabitCard(
habit = HabitCardUi("dormir", "Dormir temprano", "A las 9:00 pm", HabitIcon.Moon, HabitPalette.Lilac, doneToday = true),
onToggle = {},
)
}
}
@@ -0,0 +1,253 @@
package mx.paputec.rachita.ui.dashboard.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import mx.paputec.rachita.R
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.ui.dashboard.WeekDayUi
import mx.paputec.rachita.ui.onboarding.components.Mascot
import mx.paputec.rachita.ui.theme.CardBorder
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.CoralIntense
import mx.paputec.rachita.ui.theme.CreamCardSoft
import mx.paputec.rachita.ui.theme.Lilac
import mx.paputec.rachita.ui.theme.LilacSoft
import mx.paputec.rachita.ui.theme.RachitaTheme
import mx.paputec.rachita.ui.theme.SuccessGreen
import mx.paputec.rachita.ui.theme.WarmGreyDeep
// Right column (PRD §5.2, 296 dp): streak card + team access + mascot message +
// museum mini-card. Team and museum are visual placeholders until their modules.
@Composable
fun RightColumn(
streak: Int,
week: List<WeekDayUi>,
streakSecuredToday: Boolean,
mascotMessage: String,
avatar: AvatarKind,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.width(296.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
StreakCard(streak = streak, week = week, securedToday = streakSecuredToday)
TeamAccessCard()
MascotMessageCard(avatar = avatar, message = mascotMessage)
MuseumMiniCard()
}
}
@Composable
private fun StreakCard(streak: Int, week: List<WeekDayUi>, securedToday: Boolean) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(Color.White)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.dashboard_streak_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.align(Alignment.Start),
)
StreakFlame(modifier = Modifier.size(64.dp, 78.dp))
Text(
text = streak.toString(),
style = MaterialTheme.typography.displaySmall.copy(color = CoralIntense),
)
Text(
text = stringResource(R.string.dashboard_streak_days),
style = MaterialTheme.typography.bodyMedium.copy(color = WarmGreyDeep),
)
WeekRow(week = week)
AnimatedVisibility(
visible = securedToday,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(SuccessGreen.copy(alpha = 0.15f))
.padding(horizontal = 14.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.dashboard_streak_secured),
style = MaterialTheme.typography.labelMedium.copy(color = SuccessGreen),
)
}
}
}
}
// DA-07: L M M J V S D circles, checked on secured days.
@Composable
private fun WeekRow(week: List<WeekDayUi>) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
week.forEach { day ->
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(if (day.secured) Coral else CreamCardSoft)
.then(
if (day.isToday) Modifier.border(2.dp, CoralIntense, CircleShape) else Modifier,
),
contentAlignment = Alignment.Center,
) {
if (day.secured) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp),
)
} else {
Text(
text = day.label,
style = MaterialTheme.typography.labelSmall.copy(color = WarmGreyDeep),
)
}
}
}
}
}
@Composable
private fun TeamAccessCard() {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.border(2.dp, CardBorder, RoundedCornerShape(16.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(Icons.Rounded.Favorite, contentDescription = null, tint = Coral, modifier = Modifier.size(22.dp))
Text(text = stringResource(R.string.dashboard_team_access), style = MaterialTheme.typography.labelLarge)
}
}
@Composable
fun MascotMessageCard(avatar: AvatarKind, message: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(CreamCardSoft)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Mascot(kind = avatar, modifier = Modifier.size(64.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun MuseumMiniCard() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(LilacSoft)
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.dashboard_museum_title),
style = MaterialTheme.typography.titleMedium.copy(color = Lilac),
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = 0.6f)),
)
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Lilac)
.padding(horizontal = 16.dp, vertical = 8.dp)
.align(Alignment.CenterHorizontally),
) {
Text(
text = stringResource(R.string.dashboard_museum_cta),
style = MaterialTheme.typography.labelMedium.copy(color = Color.White),
textAlign = TextAlign.Center,
)
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 330, heightDp = 900)
@Composable
private fun PreviewRightColumn() {
RachitaTheme {
RightColumn(
streak = 12,
week = listOf(
WeekDayUi("L", true, false),
WeekDayUi("M", true, false),
WeekDayUi("M", true, false),
WeekDayUi("J", false, true),
WeekDayUi("V", false, false),
WeekDayUi("S", false, false),
WeekDayUi("D", false, false),
),
streakSecuredToday = false,
mascotMessage = "Vamos por tus hábitos de hoy, ¡tú puedes!",
avatar = AvatarKind.Fox,
)
}
}
@@ -0,0 +1,221 @@
package mx.paputec.rachita.ui.dashboard.components
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.EmojiEvents
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.PhotoLibrary
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import mx.paputec.rachita.R
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.ui.onboarding.components.Mascot
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.CoralIntense
import mx.paputec.rachita.ui.theme.CreamCardSoft
import mx.paputec.rachita.ui.theme.Pink
import mx.paputec.rachita.ui.theme.RachitaTheme
import mx.paputec.rachita.ui.theme.Sun
import mx.paputec.rachita.ui.theme.WarmGreyDeep
// Sidebar (PRD §5.2, 232 dp): profile card, XP bar, streak, nav menu.
// Only "Inicio" is functional in sprint 1; the rest are muted placeholders.
@Composable
fun Sidebar(
avatar: AvatarKind,
childName: String,
level: Int,
xpInLevel: Int,
xpTarget: Int,
streak: Int,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.width(232.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ProfileCard(avatar = avatar, childName = childName, level = level)
XpBar(level = level, xpInLevel = xpInLevel, xpTarget = xpTarget)
StreakRow(streak = streak)
NavMenu()
}
}
@Composable
private fun ProfileCard(avatar: AvatarKind, childName: String, level: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(Color.White)
.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Mascot(kind = avatar, modifier = Modifier.size(96.dp))
Text(text = childName, style = MaterialTheme.typography.titleLarge)
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Sun.copy(alpha = 0.25f))
.padding(horizontal = 10.dp, vertical = 2.dp),
) {
Text(
text = stringResource(R.string.dashboard_level_badge, level),
style = MaterialTheme.typography.labelMedium.copy(color = CoralIntense),
)
}
}
}
@Composable
private fun XpBar(level: Int, xpInLevel: Int, xpTarget: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.dashboard_level_title, level),
style = MaterialTheme.typography.labelMedium,
)
Text(
text = stringResource(R.string.dashboard_xp_progress, xpInLevel, xpTarget),
style = MaterialTheme.typography.labelSmall,
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(10.dp)
.clip(RoundedCornerShape(999.dp))
.background(CreamCardSoft),
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction = (xpInLevel.toFloat() / xpTarget).coerceIn(0f, 1f))
.height(10.dp)
.clip(RoundedCornerShape(999.dp))
.background(Sun),
)
}
}
}
@Composable
private fun StreakRow(streak: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
StreakFlame(modifier = Modifier.size(26.dp, 32.dp))
Text(
text = stringResource(R.string.dashboard_streak_sidebar, streak),
style = MaterialTheme.typography.labelLarge.copy(color = CoralIntense),
)
}
}
private data class MenuEntry(val icon: ImageVector, val labelRes: Int, val enabled: Boolean)
@Composable
private fun NavMenu() {
val entries = listOf(
MenuEntry(Icons.Rounded.Home, R.string.menu_home, enabled = true),
MenuEntry(Icons.Rounded.Palette, R.string.menu_museum, enabled = false),
MenuEntry(Icons.Rounded.PhotoLibrary, R.string.menu_gallery, enabled = false),
MenuEntry(Icons.Rounded.Favorite, R.string.menu_team, enabled = false),
MenuEntry(Icons.Rounded.Notifications, R.string.menu_alerts, enabled = false),
MenuEntry(Icons.Rounded.EmojiEvents, R.string.menu_achievements, enabled = false),
MenuEntry(Icons.Rounded.Settings, R.string.menu_settings, enabled = false),
)
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
entries.forEach { entry ->
val tint = when {
entry.enabled -> Coral
entry.labelRes == R.string.menu_alerts -> Pink.copy(alpha = 0.55f)
else -> WarmGreyDeep.copy(alpha = 0.55f)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(if (entry.enabled) CreamCardSoft else Color.Transparent)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(entry.icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
Text(
text = stringResource(entry.labelRes),
style = MaterialTheme.typography.labelLarge.copy(
color = if (entry.enabled) MaterialTheme.colorScheme.onBackground else WarmGreyDeep.copy(alpha = 0.7f),
),
)
}
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 260, heightDp = 800)
@Composable
private fun PreviewSidebar() {
RachitaTheme {
Sidebar(
avatar = AvatarKind.Fox,
childName = "Lucía",
level = 7,
xpInLevel = 320,
xpTarget = 500,
streak = 12,
)
}
}
@@ -0,0 +1,67 @@
package mx.paputec.rachita.ui.dashboard.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.CoralIntense
import mx.paputec.rachita.ui.theme.Sun
// Streak flame with the prototype's ~1.5 s flicker (PRD §6.4). Pure Canvas.
@Composable
fun StreakFlame(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "flame")
val flicker by transition.animateFloat(
initialValue = 0.94f,
targetValue = 1.06f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 750),
repeatMode = RepeatMode.Reverse,
),
label = "flicker",
)
Canvas(modifier = modifier) {
scale(scale = flicker, pivot = Offset(size.width / 2f, size.height)) {
drawFlame(scaleFactor = 1f, color = CoralIntense)
drawFlame(scaleFactor = 0.62f, color = Coral)
drawFlame(scaleFactor = 0.32f, color = Sun)
}
}
}
private fun DrawScope.drawFlame(scaleFactor: Float, color: androidx.compose.ui.graphics.Color) {
val w = size.width * scaleFactor
val h = size.height * scaleFactor
val cx = size.width / 2f
val bottom = size.height - (size.height - h) * 0.18f
val path = Path().apply {
moveTo(cx, bottom - h)
cubicTo(cx + w * 0.16f, bottom - h * 0.72f, cx + w * 0.5f, bottom - h * 0.6f, cx + w * 0.5f, bottom - h * 0.32f)
cubicTo(cx + w * 0.5f, bottom - h * 0.08f, cx + w * 0.26f, bottom, cx, bottom)
cubicTo(cx - w * 0.26f, bottom, cx - w * 0.5f, bottom - h * 0.08f, cx - w * 0.5f, bottom - h * 0.32f)
cubicTo(cx - w * 0.5f, bottom - h * 0.52f, cx - w * 0.22f, bottom - h * 0.62f, cx - w * 0.12f, bottom - h * 0.78f)
close()
}
drawPath(path, color = color)
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 120, heightDp = 140)
@Composable
private fun PreviewStreakFlame() {
StreakFlame(modifier = Modifier.size(96.dp, 120.dp))
}
@@ -168,10 +168,11 @@ private fun DrawScope.drawMascotHead(
val cx = size.width / 2f
val cy = size.height / 2f
// Reserve headroom for ears so tall shapes (rabbit) still fit inside the canvas.
// Values tuned on device: smaller reserves clipped fox/cat ear tips.
val topReserve = when (earStyle) {
EarStyle.Long -> 0.32f
EarStyle.Pointy -> 0.16f
EarStyle.Triangular -> 0.14f
EarStyle.Long -> 0.38f
EarStyle.Pointy -> 0.30f
EarStyle.Triangular -> 0.26f
}
val available = size.height * (1f - topReserve)
val headRadius = min(size.width, available) * 0.42f * bodyScale
@@ -325,9 +326,9 @@ private fun DrawScope.drawRabbitEars(
color: Color,
innerColor: Color,
) {
val earHeight = headRadius * 1.6f
val earHeight = headRadius * 1.35f
val earWidth = headRadius * 0.42f
val earBaseY = center.y - headRadius * 0.85f
val earBaseY = center.y - headRadius * 0.8f
listOf(-1f, 1f).forEach { side ->
val baseX = center.x + side * headRadius * 0.35f
val topLeft = Offset(baseX - earWidth / 2f, earBaseY - earHeight)
+31 -1
View File
@@ -4,7 +4,7 @@
<!-- Onboarding / avatar selection (module 1). Spanish MX. -->
<string name="onboarding_title">Elige o dibuja tu compañero</string>
<string name="onboarding_subtitle">Vivirá contigo en la app y crecerá con tu racha</string>
<string name="onboarding_subtitle">Quédate con uno de estos… o dibuja el tuyo en papel e impórtalo. ¡Será solo tuyo!</string>
<string name="onboarding_continue">Continuar</string>
<string name="onboarding_selected_badge">✓ Elegido</string>
<string name="onboarding_avatar_fox">Zorro Rachi</string>
@@ -22,4 +22,34 @@
<string name="cd_avatar_rabbit">Ilustración del conejo Sol</string>
<string name="cd_avatar_custom">Marco para tu dibujo</string>
<string name="cd_selected">Compañero elegido</string>
<!-- Dashboard (module 2). Copy exact from prototype. -->
<string name="dashboard_greeting">¡Hola, %1$s!</string>
<string name="dashboard_pending_zero">¡Todo listo por hoy!</string>
<string name="dashboard_pending_one">te queda 1 hábito</string>
<string name="dashboard_pending_many">te quedan %1$d hábitos</string>
<string name="dashboard_background_chip">Activa en segundo plano</string>
<string name="dashboard_today_counter">%1$d/%2$d hoy</string>
<string name="dashboard_done_badge">¡Hecho!</string>
<string name="dashboard_streak_title">Tu racha</string>
<string name="dashboard_streak_days">días</string>
<string name="dashboard_streak_sidebar">%1$d días de racha</string>
<string name="dashboard_streak_secured">¡Racha asegurada hoy! 🔥</string>
<string name="dashboard_level_badge">Nv. %1$d</string>
<string name="dashboard_level_title">Nivel %1$d</string>
<string name="dashboard_xp_progress">%1$d / %2$d XP</string>
<string name="dashboard_team_access">En equipo</string>
<string name="dashboard_museum_title">Museo Diario</string>
<string name="dashboard_museum_cta">Subir dibujo de hoy</string>
<string name="dashboard_mascot_normal">Vamos por tus hábitos de hoy, ¡tú puedes!</string>
<string name="dashboard_mascot_celebrate">¡Lo lograste todo, %1$s! Estoy súper orgulloso de ti.</string>
<string name="menu_home">Inicio</string>
<string name="menu_museum">Museo Diario</string>
<string name="menu_gallery">Mi Galería</string>
<string name="menu_team">Rachas en equipo</string>
<string name="menu_alerts">Alertas</string>
<string name="menu_achievements">Logros</string>
<string name="menu_settings">Ajustes</string>
<string name="cd_habit_check">Marcar hábito %1$s</string>
<string name="cd_battery">Batería %1$d por ciento</string>
</resources>