feat(ui): layout adaptativo — portrait compacto en teléfonos

- Breakpoint 600dp: debajo, dashboard en una sola columna scrolleable
  (header, perfil+XP+llama compactos, semana, hábitos, mascota, equipo,
  museo); arriba, se mantiene el layout 3 columnas del PRD §5.2
- Onboarding: grid 2×2 en compacto, 4 columnas en tablet
- Orientación desbloqueada en manifest (sensorLandscape fuera)
- WeekRow extraído a componente compartido; TeamAccessCard y
  MuseumMiniCard ahora internal para reuso entre layouts
This commit is contained in:
Johann
2026-07-03 23:31:27 -06:00
parent 1243f6c3b7
commit 5ca619b584
6 changed files with 391 additions and 82 deletions
+2 -1
View File
@@ -20,10 +20,11 @@
android:theme="@style/Theme.Rachita"
tools:targetApi="34">
<!-- Orientation unlocked: tablets use the PRD landscape layout, phones get
the compact portrait layout (width breakpoint in DashboardScreen). -->
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode|density"
android:theme="@style/Theme.Rachita">
<intent-filter>
@@ -3,6 +3,7 @@ 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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
@@ -35,6 +36,7 @@ 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.DashboardCompact
import mx.paputec.rachita.ui.dashboard.components.HabitCard
import mx.paputec.rachita.ui.dashboard.components.RightColumn
import mx.paputec.rachita.ui.dashboard.components.Sidebar
@@ -47,6 +49,10 @@ 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.
// Width breakpoint: below this the single-column phone layout is used; at or
// above it, the PRD §5.2 three-column tablet layout.
private val COMPACT_MAX_WIDTH = 600.dp
@Composable
fun DashboardScreen(
uiState: DashboardUiState,
@@ -58,6 +64,31 @@ fun DashboardScreen(
.background(CreamBackground)
.padding(WindowInsets.safeDrawing.asPaddingValues()),
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
if (maxWidth < COMPACT_MAX_WIDTH) {
DashboardCompact(
uiState = uiState,
onToggleHabit = onToggleHabit,
header = { DashboardHeader(uiState = uiState) },
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
)
} else {
DashboardTablet(uiState = uiState, onToggleHabit = onToggleHabit)
}
}
// Confetti above everything (DA-04); confettiOn toggle arrives with Settings (DA-05).
ConfettiOverlay(trigger = uiState.celebrationCount, enabled = true)
}
}
@Composable
private fun DashboardTablet(
uiState: DashboardUiState,
onToggleHabit: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxSize()
@@ -89,22 +120,14 @@ fun DashboardScreen(
avatar = uiState.avatar,
)
}
// Confetti above everything (DA-04); confettiOn toggle arrives with Settings (DA-05).
ConfettiOverlay(trigger = uiState.celebrationCount, enabled = true)
}
}
// Header shared by both layouts. Laid out as a plain top-anchored column with
// height-bounded rows: on the EMUI test device a Bottom-aligned SpaceBetween
// header measured ~270 dp tall (giant invisible text box), crushing the habit
// list to zero height — see STATUS.md session 2.
@Composable
private fun CenterColumn(
uiState: DashboardUiState,
onToggleHabit: (String) -> Unit,
modifier: Modifier = Modifier,
) {
// Header laid out as a plain top-anchored column with height-bounded rows.
// On the EMUI test device the previous Bottom-aligned SpaceBetween header
// measured ~270 dp tall (giant invisible text box), crushing the habit list
// to zero height — see STATUS.md session 2.
private fun DashboardHeader(uiState: DashboardUiState, modifier: Modifier = Modifier) {
val pendingText = when (uiState.pendingCount) {
0 -> stringResource(R.string.dashboard_pending_zero)
1 -> stringResource(R.string.dashboard_pending_one)
@@ -131,6 +154,17 @@ private fun CenterColumn(
uiState.battery?.let { BatteryChip(it) }
TodayCounterPill(done = uiState.doneCount, total = uiState.totalCount)
}
}
}
@Composable
private fun CenterColumn(
uiState: DashboardUiState,
onToggleHabit: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
DashboardHeader(uiState = uiState)
LazyColumn(
modifier = Modifier
@@ -0,0 +1,226 @@
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
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.DashboardUiState
import mx.paputec.rachita.ui.dashboard.HabitCardUi
import mx.paputec.rachita.ui.dashboard.WeekDayUi
import mx.paputec.rachita.ui.onboarding.components.Mascot
import mx.paputec.rachita.ui.theme.CoralIntense
import mx.paputec.rachita.ui.theme.CreamCardSoft
import mx.paputec.rachita.ui.theme.RachitaTheme
import mx.paputec.rachita.ui.theme.SuccessGreen
import mx.paputec.rachita.ui.theme.Sun
import mx.paputec.rachita.ui.theme.WarmGreyDeep
// Phone-portrait dashboard: everything in one scrollable column, habits first
// (the child's main job), profile/streak as compact strips. The tablet keeps
// the PRD §5.2 three-column layout — see DashboardScreen.
@Composable
fun DashboardCompact(
uiState: DashboardUiState,
onToggleHabit: (String) -> Unit,
header: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(key = "header") { header() }
item(key = "profile") {
CompactProfileStrip(
avatar = uiState.avatar,
childName = uiState.childName,
level = uiState.level,
xpInLevel = uiState.xpInLevel,
xpTarget = uiState.xpTarget,
streak = uiState.streak,
)
}
item(key = "week") {
CompactStreakStrip(week = uiState.week, securedToday = uiState.allDone)
}
items(count = uiState.habits.size, key = { uiState.habits[it].id }) { index ->
val habit = uiState.habits[index]
HabitCard(habit = habit, onToggle = { onToggleHabit(habit.id) })
}
item(key = "mascot") {
MascotMessageCard(
avatar = uiState.avatar,
message = if (uiState.allDone) {
stringResource(R.string.dashboard_mascot_celebrate, uiState.childName)
} else {
stringResource(R.string.dashboard_mascot_normal)
},
)
}
item(key = "team") { TeamAccessCard() }
item(key = "museum") { MuseumMiniCard() }
}
}
// Sidebar essentials condensed into one card: mascot + name/level + XP bar +
// streak flame with count.
@Composable
private fun CompactProfileStrip(
avatar: AvatarKind,
childName: String,
level: Int,
xpInLevel: Int,
xpTarget: Int,
streak: Int,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(Color.White)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Mascot(kind = avatar, modifier = Modifier.size(64.dp))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = childName, style = MaterialTheme.typography.titleMedium)
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Sun.copy(alpha = 0.25f))
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
text = stringResource(R.string.dashboard_level_badge, level),
style = MaterialTheme.typography.labelSmall.copy(color = CoralIntense),
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(999.dp))
.background(CreamCardSoft),
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction = (xpInLevel.toFloat() / xpTarget).coerceIn(0f, 1f))
.height(8.dp)
.clip(RoundedCornerShape(999.dp))
.background(Sun),
)
}
Text(
text = stringResource(R.string.dashboard_xp_progress, xpInLevel, xpTarget),
style = MaterialTheme.typography.labelSmall,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
StreakFlame(modifier = Modifier.size(28.dp, 34.dp))
Text(
text = streak.toString(),
style = MaterialTheme.typography.titleLarge.copy(color = CoralIntense),
)
}
}
}
@Composable
private fun CompactStreakStrip(week: List<WeekDayUi>, securedToday: Boolean) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
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),
)
}
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 412, heightDp = 915)
@Composable
private fun PreviewDashboardCompact() {
RachitaTheme {
DashboardCompact(
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 = {},
header = {
Text(text = "¡Hola, Lucía!", style = MaterialTheme.typography.headlineMedium)
},
)
}
}
@@ -17,11 +17,9 @@ 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
@@ -124,44 +122,8 @@ private fun StreakCard(streak: Int, week: List<WeekDayUi>, securedToday: Boolean
}
}
// 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() {
internal fun TeamAccessCard() {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -198,7 +160,7 @@ fun MascotMessageCard(avatar: AvatarKind, message: String, modifier: Modifier =
}
@Composable
private fun MuseumMiniCard() {
internal fun MuseumMiniCard() {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -0,0 +1,83 @@
package mx.paputec.rachita.ui.dashboard.components
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import mx.paputec.rachita.ui.dashboard.WeekDayUi
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.RachitaTheme
import mx.paputec.rachita.ui.theme.WarmGreyDeep
// DA-07: L M M J V S D circles with checks on secured days. Shared by the
// tablet right column and the compact (portrait) streak strip.
@Composable
fun WeekRow(week: List<WeekDayUi>, modifier: Modifier = Modifier) {
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),
)
}
}
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 320)
@Composable
private fun PreviewWeekRow() {
RachitaTheme {
WeekRow(
week = listOf(
WeekDayUi("L", true, false),
WeekDayUi("M", true, false),
WeekDayUi("M", false, false),
WeekDayUi("J", false, false),
WeekDayUi("V", false, true),
WeekDayUi("S", false, false),
WeekDayUi("D", false, false),
),
)
}
}
@@ -7,6 +7,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.PaddingValues
@@ -76,26 +77,28 @@ fun OnboardingScreen(
val insets = WindowInsets.safeDrawing.asPaddingValues()
Box(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(CreamBackground)
.padding(insets),
) {
// 2×2 grid on phones/portrait, 4-across on the tablet target (PRD layout).
val isCompact = maxWidth < 600.dp
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp, vertical = 24.dp),
.padding(horizontal = if (isCompact) 20.dp else 32.dp, vertical = if (isCompact) 16.dp else 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OnboardingHeader(childName = uiState.childName)
LazyVerticalGrid(
columns = GridCells.Fixed(4),
columns = GridCells.Fixed(if (isCompact) 2 else 4),
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalArrangement = Arrangement.spacedBy(if (isCompact) 14.dp else 20.dp),
verticalArrangement = Arrangement.spacedBy(if (isCompact) 14.dp else 20.dp),
) {
items(items = AvatarOptions, key = { it.kind }) { option ->
AvatarCard(