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:
@@ -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.1–2.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))
|
||||
}
|
||||
+6
-5
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user