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" android:theme="@style/Theme.Rachita"
tools:targetApi="34"> tools:targetApi="34">
<!-- Orientation unlocked: tablets use the PRD landscape layout, phones get
the compact portrait layout (width breakpoint in DashboardScreen). -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode|density" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode|density"
android:theme="@style/Theme.Rachita"> android:theme="@style/Theme.Rachita">
<intent-filter> <intent-filter>
@@ -3,6 +3,7 @@ package mx.paputec.rachita.ui.dashboard
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets 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.HabitIcon
import mx.paputec.rachita.domain.model.HabitPalette import mx.paputec.rachita.domain.model.HabitPalette
import mx.paputec.rachita.ui.dashboard.components.ConfettiOverlay 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.HabitCard
import mx.paputec.rachita.ui.dashboard.components.RightColumn import mx.paputec.rachita.ui.dashboard.components.RightColumn
import mx.paputec.rachita.ui.dashboard.components.Sidebar 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 import mx.paputec.rachita.ui.theme.WarmGreyDeep
// Module 2 — dashboard root (PRD §5.2): sidebar 232 dp · center flex · right 296 dp. // 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 @Composable
fun DashboardScreen( fun DashboardScreen(
uiState: DashboardUiState, uiState: DashboardUiState,
@@ -58,6 +64,31 @@ fun DashboardScreen(
.background(CreamBackground) .background(CreamBackground)
.padding(WindowInsets.safeDrawing.asPaddingValues()), .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( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -89,22 +120,14 @@ fun DashboardScreen(
avatar = uiState.avatar, 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 @Composable
private fun CenterColumn( private fun DashboardHeader(uiState: DashboardUiState, modifier: Modifier = Modifier) {
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.
val pendingText = when (uiState.pendingCount) { val pendingText = when (uiState.pendingCount) {
0 -> stringResource(R.string.dashboard_pending_zero) 0 -> stringResource(R.string.dashboard_pending_zero)
1 -> stringResource(R.string.dashboard_pending_one) 1 -> stringResource(R.string.dashboard_pending_one)
@@ -131,6 +154,17 @@ private fun CenterColumn(
uiState.battery?.let { BatteryChip(it) } uiState.battery?.let { BatteryChip(it) }
TodayCounterPill(done = uiState.doneCount, total = uiState.totalCount) 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( LazyColumn(
modifier = Modifier 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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 @Composable
private fun WeekRow(week: List<WeekDayUi>) { internal fun TeamAccessCard() {
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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -198,7 +160,7 @@ fun MascotMessageCard(avatar: AvatarKind, message: String, modifier: Modifier =
} }
@Composable @Composable
private fun MuseumMiniCard() { internal fun MuseumMiniCard() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -76,26 +77,28 @@ fun OnboardingScreen(
val insets = WindowInsets.safeDrawing.asPaddingValues() val insets = WindowInsets.safeDrawing.asPaddingValues()
Box( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(CreamBackground) .background(CreamBackground)
.padding(insets), .padding(insets),
) { ) {
// 2×2 grid on phones/portrait, 4-across on the tablet target (PRD layout).
val isCompact = maxWidth < 600.dp
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .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), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
OnboardingHeader(childName = uiState.childName) OnboardingHeader(childName = uiState.childName)
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(4), columns = GridCells.Fixed(if (isCompact) 2 else 4),
modifier = Modifier.fillMaxWidth().weight(1f), modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp), horizontalArrangement = Arrangement.spacedBy(if (isCompact) 14.dp else 20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(if (isCompact) 14.dp else 20.dp),
) { ) {
items(items = AvatarOptions, key = { it.kind }) { option -> items(items = AvatarOptions, key = { it.kind }) { option ->
AvatarCard( AvatarCard(