diff --git a/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt b/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt index 5481a59..5a7d2d7 100644 --- a/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt +++ b/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt @@ -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(null) + val startRoute: StateFlow = _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, ) } } diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/DashboardScreen.kt b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..4e6817f --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/DashboardScreen.kt @@ -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 = {}, + ) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/DashboardUiState.kt b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/DashboardUiState.kt new file mode 100644 index 0000000..9fb8f58 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/DashboardUiState.kt @@ -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 = emptyList(), + val streak: Int = 0, + val week: List = 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 + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/DashboardViewModel.kt b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..77c483c --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/DashboardViewModel.kt @@ -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 = 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, + doneLogs: List, + 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) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/ConfettiOverlay.kt b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/ConfettiOverlay.kt new file mode 100644 index 0000000..593d648 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/ConfettiOverlay.kt @@ -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>(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), + ) + } + } + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/HabitCard.kt b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/HabitCard.kt new file mode 100644 index 0000000..f9e6b23 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/HabitCard.kt @@ -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 = {}, + ) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/RightColumn.kt b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/RightColumn.kt new file mode 100644 index 0000000..f60af8a --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/RightColumn.kt @@ -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, + 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, 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) { + 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, + ) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/Sidebar.kt b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/Sidebar.kt new file mode 100644 index 0000000..a155f6e --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/Sidebar.kt @@ -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, + ) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/StreakFlame.kt b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/StreakFlame.kt new file mode 100644 index 0000000..878fc35 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/dashboard/components/StreakFlame.kt @@ -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)) +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt index c28c2e1..51cade6 100644 --- a/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt +++ b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt @@ -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) diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml index 1e0aa80..0f4984e 100644 --- a/app/app/src/main/res/values/strings.xml +++ b/app/app/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ Elige o dibuja tu compañero - Vivirá contigo en la app y crecerá con tu racha + Quédate con uno de estos… o dibuja el tuyo en papel e impórtalo. ¡Será solo tuyo! Continuar ✓ Elegido Zorro Rachi @@ -22,4 +22,34 @@ Ilustración del conejo Sol Marco para tu dibujo Compañero elegido + + + ¡Hola, %1$s! + ¡Todo listo por hoy! + te queda 1 hábito + te quedan %1$d hábitos + Activa en segundo plano + %1$d/%2$d hoy + ¡Hecho! + Tu racha + días + %1$d días de racha + ¡Racha asegurada hoy! 🔥 + Nv. %1$d + Nivel %1$d + %1$d / %2$d XP + En equipo + Museo Diario + Subir dibujo de hoy + Vamos por tus hábitos de hoy, ¡tú puedes! + ¡Lo lograste todo, %1$s! Estoy súper orgulloso de ti. + Inicio + Museo Diario + Mi Galería + Rachas en equipo + Alertas + Logros + Ajustes + Marcar hábito %1$s + Batería %1$d por ciento