Compare commits

...

16 Commits

Author SHA1 Message Date
Johann 3dda825490 docs(status): sprint 1 completo — módulo 3 verificado en dispositivo 2026-07-04 17:09:25 -06:00
Johann 0fc8f39a79 docs(status): módulo 3 implementado, plan de prueba en dispositivo 2026-07-04 17:04:26 -06:00
Johann 9bfe43b704 feat(battery): módulo 3 — overlay batería baja + bloqueo nocturno en carga
- Mascota con estados de ánimo: Tired (cejas caídas + gota de sudor 2.4s)
  y Sleep (ojos cerrados + Z flotantes 2.6s), PRD §5.1/§6.4
- LowBatteryPolicy: bandas 20/15/10/5 (BA-03) con 7 tests — al descartar
  en una banda solo reaparece al caer a la siguiente
- BatteryUxViewModel: máquina de estados normal→low→charging; conectar
  el cargador resetea el ciclo de alertas
- LowBatteryOverlay (BA-01/02/04): scrim del prototipo, mascota cansada,
  % real en negritas, barra coral, 'Ahora no' + 'Conectar cargador' con
  coralpulse; bloquea interacción con el fondo
- ChargingLockScreen (CA-01..05): gradiente nocturno + 46 estrellas
  twinkle, cápsula de energía con glow, 'Cargando · N%', botón 'Ya la
  desconecté' que NUNCA desbloquea (el candado cae solo al detectar la
  desconexión real — CA-04 estructural), despertar al 100%, sin back
- Escape 'salir (debug)' solo en builds DEBUG para desarrollo por USB
- Overlays montados sobre el NavHost: cubren cualquier pantalla
2026-07-04 17:03:46 -06:00
Johann 25154af616 docs(status): layout adaptativo portrait registrado 2026-07-03 23:32:03 -06:00
Johann 5ca619b584 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
2026-07-03 23:31:27 -06:00
Johann 1243f6c3b7 docs(status): módulo 2 verificado punta a punta en dispositivo 2026-07-03 23:23:47 -06:00
Johann 0eb4133345 fix(dashboard): render verificado en dispositivo EMUI
- Header reestructurado: en el Huawei de prueba el Row con Alignment.Bottom
  + SpaceBetween medía ~270dp fantasma, aplastando la lista de hábitos a
  altura 0 (las tarjetas ni se componían). Ahora es columna simple con
  chips en fila propia y maxLines acotados.
- LazyColumn con weight(1f) en vez de fillMaxSize dentro de Column sin peso.
- Singular '1 día de racha' / '1 día' (antes '1 días').
2026-07-03 23:22:56 -06:00
Johann 92285bbff2 docs(status): sesión 2 — módulo 2 implementado, verificación visual pendiente 2026-07-03 22:42:42 -06:00
Johann 1d34b78273 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
2026-07-03 22:41:43 -06:00
Johann 33de656931 feat(battery): monitor de batería por ACTION_BATTERY_CHANGED
callbackFlow sobre el sticky broadcast (sin polling, CLAUDE.md §8).
Alimenta el chip DA-08 del dashboard y es la base del módulo 3.
2026-07-03 22:41:43 -06:00
Johann 28f2359dda feat(habits): capa de datos Room + regla de racha v1
- HabitEntity/HabitLogEntity (1 log por hábito por día, fecha ISO)
- Seed de los 3 hábitos del PRD DA-02 en primer arranque
- StreakCalculator puro: día asegurado = todos los hábitos hechos;
  racha = días consecutivos terminando hoy (o ayer si hoy va en progreso);
  fallar un día completo reinicia a 0 (DA-11 v1, sin congelador)
- 6 tests unitarios de racha (incluye reversibilidad del toggle)
2026-07-03 22:41:43 -06:00
Johann a947c174ee docs(status): estado sesión 1 — módulo 1 verificado en dispositivo, pendientes y próximos pasos 2026-07-03 22:16:24 -06:00
Johann 3f7602efb0 fix(nav): placeholder de Dashboard con contenido visible
Un Surface crema vacío tras completar onboarding se confundía con una
pantalla en blanco durante pruebas en dispositivo (Huawei HBP-LX9).
Ahora muestra mascota + mensaje hasta que llegue el módulo 2.
2026-07-03 22:13:39 -06:00
Johann ae8f6b7a65 feat(onboarding): selección de compañero con mascotas Canvas y DataStore
- OnboardingScreen (1280×800 landscape): grid de 4 tarjetas, CTA píldora Continuar
- Mascotas Zorro Rachi / Gata Menta / Conejo Sol dibujadas con Canvas
  (breathe 3.2s + blink 5s del PRD §6.4), marco punteado para 'Mi dibujo'
- 'Mi dibujo' abre Photo Picker (sin permisos de almacenamiento)
- MVVM: OnboardingViewModel con StateFlow<OnboardingUiState>
- Persistencia en DataStore Preferences vía ProfileRepository (Hilt)
- NavHost onboarding→dashboard (placeholder módulo 2) + STATUS.md
2026-07-03 21:26:24 -06:00
Johann 1b5a1f76e1 feat(theme): tema Compose con paleta Rachita y fuentes descargables
- Color.kt: paleta completa del PRD §6.1 (coral/menta/sol/lila/crema/tinta/noche)
- Type.kt: Baloo 2 + Nunito vía Google Fonts provider, escala del PRD §6.2
- Shape.kt: radios 14-24dp + píldora; Theme.kt: MaterialTheme light único
2026-07-03 21:26:13 -06:00
Johann 6f5bca6e35 chore(android): scaffold del proyecto Gradle KTS con version catalog
- Gradle 8.9 wrapper + AGP 8.7.3 + Kotlin 2.0.21 (KSP, Hilt, Compose, serialization)
- minSdk 26 / targetSdk 34 / compileSdk 35, package mx.paputec.rachita
- Manifest landscape-only con permisos reservados para módulo de batería
- Recursos base: strings ES-MX, certs de Downloadable Fonts, ícono adaptativo de llama
2026-07-03 21:26:05 -06:00
63 changed files with 4776 additions and 1 deletions
+3 -1
View File
@@ -3,11 +3,13 @@ _extracted/
# Android
app/.gradle/
app/.kotlin/
app/local.properties
app/**/build/
app/.idea/
app/*.iml
app/**/*.iml
app/captures/
app/**/.cxx/
# .NET
backend/**/bin/
+101
View File
@@ -0,0 +1,101 @@
# STATUS — Rachita
> Estado vivo del desarrollo. **Actualizar al cerrar cada hito o sesión de trabajo.**
> Si retomas en un chat nuevo: lee CLAUDE.md (arquitectura/convenciones), PRD-Rachita.md (requisitos) y este archivo (dónde vamos), en ese orden.
**Última actualización:** 2026-07-04 (sesión 3) — **SPRINT 1 COMPLETO (3/3 módulos verificados en dispositivo)**
---
## Sprint 1 — progreso
| Módulo | Estado | Notas |
|---|---|---|
| 1. Onboarding / Selección de avatar | ✅ **Hecho y verificado en dispositivo real** | Compose + MVVM + DataStore; mascotas 100 % Canvas |
| 2. Dashboard de hábitos + racha | ✅ **Hecho y verificado en dispositivo real, punta a punta** | Toggle → contador → racha → XP → banner → persistencia tras force-stop |
| 3. Monitoreo de batería (overlay <20 % + bloqueo en carga) | ✅ **Hecho y verificado en dispositivo real, punta a punta** | CA-01..05 y BA-01..03 validados con batería simulada (`dumpsys battery`) |
### Verificado en dispositivo (2026-07-03, Huawei HBP-LX9, Android 12)
- Onboarding completo: título, 4 tarjetas, mascotas animadas, badge, CTA, persistencia y navegación.
- Fuentes Baloo 2 / Nunito cargan vía Downloadable Fonts (el equipo tiene Google Play Services).
- Dashboard punta a punta: gate de arranque, seed de 3 hábitos, tarjetas con check animado + "¡Hecho!",
contador "te quedan N", racha 0→1 con V del viernes palomeada, banner "¡Racha asegurada hoy! 🔥",
XP 0→30/500, mensaje celebratorio de la mascota, chip de batería real (verde cargando), singular/plural
de racha, y persistencia completa tras force-stop + relanzamiento.
### 🔎 Lecciones EMUI de esta sesión (para futuras pruebas en este equipo)
- El header original (Row con `Alignment.Bottom` + `SpaceBetween` + texto con composable-en-template)
medía ~270 dp fantasma SOLO en este dispositivo → la lista quedaba en altura 0 y las tarjetas ni se
componían. Diagnóstico vía `uiautomator dump` (bounds de semántica), no vía screenshots.
- Los taps de `adb input tap` a <40 px del borde inferior se los come la zona de gestos de EMUI.
- `adb shell settings put global stay_on_while_plugged_in 7` activado para que no se bloquee la pantalla
durante desarrollo (revertir con valor 0 cuando termine el sprint).
## Decisiones de producto tomadas en sesión 2
- **Racha v1 (DA-11):** se pierde completa al fallar un día; congelador pospuesto a v1.1 (recomendación del PRD §16.1).
- **Horarios por hábito:** el subtítulo ("A las 9:00 pm") es copy descriptivo; todo hábito cuenta antes de medianoche.
- **XP:** derivado 100 % de logs (10 XP por hábito hecho, nivel cada 500) — reversible y sin drift, igual que la racha. Sin tabla de recompensas aún (pregunta abierta §16.5).
- **Racha mostrada:** incluye hoy en cuanto se completa todo (feedback inmediato); desmarcar la revierte.
## Historial de commits relevantes
- `6f5bca6` chore(android): scaffold Gradle KTS + version catalog + manifest + recursos base
- `1b5a1f7` feat(theme): paleta PRD §6 + Baloo 2/Nunito + shapes
- `ae8f6b7` feat(onboarding): pantalla de avatar + mascotas Canvas + DataStore + NavHost
- `3f7602e` fix(nav): placeholder de Dashboard con contenido visible (un Surface vacío parecía "pantalla en blanco" en pruebas)
- `28f2359` feat(habits): Room (Habit/HabitLog) + StreakCalculator + 6 tests
- `33de656` feat(battery): BatteryStatusMonitor (callbackFlow sobre sticky broadcast)
- `1d34b78` feat(dashboard): módulo 2 completo — 3 columnas, checks animados, confeti, gate
- `0eb4133` fix(dashboard): header a prueba de EMUI + weight en lista + singular de racha
- `5ca619b` feat(ui): layout adaptativo — portrait compacto (<600dp) verificado en teléfono; tablet conserva 3 columnas
## Entorno de build (esta Mac)
- **NO hay Android Studio.** JDK: `/opt/homebrew/opt/openjdk@17` (el cask temurin@17 no registró bien; exportar `JAVA_HOME` explícito siempre).
- SDK: `/opt/homebrew/share/android-commandlinetools` (platforms 34+35, build-tools 34, platform-tools). `app/local.properties` ya apunta ahí (no versionado).
- Build: `cd app && JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug`
- Instalar en dispositivo: mismo comando con `installDebug`. adb en `/opt/homebrew/share/android-commandlinetools/platform-tools/adb`.
- ⚠️ `installDebug` falla intermitente si el teléfono bloquea la pantalla → `adb shell input keyevent KEYCODE_WAKEUP` y reintentar.
- `compileSdk 35` / `targetSdk 34` / `minSdk 26` — androidx.core 1.15 exige compilar contra 35; NO "corregirlo" a 34.
## Decisiones tomadas
- Fuentes por Downloadable Fonts (Google Fonts provider), sin .ttf empaquetados. Ojo: `Font(googleFont=...)` se importa de `androidx.compose.ui.text.googlefonts.Font`.
- Mascotas vectoriales por código en `MascotIllustrations.kt`; estados tired/sleep se agregan con el módulo de batería.
- Avatar "Mi dibujo" usa Photo Picker (`PickVisualMedia`) — cero permisos de almacenamiento. Recorte orgánico (AV-02) pendiente de pulido.
- AV-08 (nombre configurable): ViewModel ya persiste `childName`, pero el onboarding no tiene campo de texto (el prototipo lo trata como prop, default "Lucía").
## Deuda / pulido pendiente
1. Avatar personalizado ("Mi dibujo") aún no se muestra como imagen (falta Coil o similar en el catálogo); hoy cae al marco punteado. Recorte orgánico AV-02 pendiente.
2. ~~Layout apretado en teléfono~~ → resuelto con layout adaptativo (breakpoint 600 dp): portrait compacto en una columna, tablet conserva el diseño del PRD. Nota: el PRD pedía landscape-only en tablet; la orientación quedó desbloqueada — si se quiere volver a fijar SOLO en tablets, hacerlo por configuración en runtime, no en manifest. Menú de navegación no aparece en el layout compacto (destinos aún placeholder).
3. `confettiOn` (DA-05) hoy está fijo en true; se conecta cuando exista pantalla de Ajustes con gate parental. Confeti no confirmado visualmente en captura (el trigger sí disparó — banner y mensaje aparecieron); confirmar a ojo en dispositivo.
4. Campo de nombre en onboarding (AV-08): el ViewModel ya lo persiste; falta el input en UI.
## Resultado de la prueba del módulo 3 en dispositivo (2026-07-04)
- CA-01 ✓ candado aparece solo al conectar USB (verificado 2 veces). CA-02 ✓ mascota dormida
(ojos cerrados + Z) en cápsula con glow y estrellas. CA-03 ✓ "Cargando · N%" real.
- CA-04 ✓ "Ya la desconecté" nunca desbloquea con el cable puesto; con `dumpsys battery unplug`
el candado cae solo (la verificación es estructural, no del botón).
- BA-01/02 ✓ overlay al 18 % sobre el dashboard con mascota cansada y % en negritas coral.
- BA-03 ✓ en vivo: descartado en banda 20 → reapareció al caer a 14 % (banda 15).
- CA-05 ✓ al 100 %: mascota despierta + "¡Desperté a tope de energía!".
- Escape "salir (debug)" ✓ (solo builds debug). Rollover de día ✓ ("Sábado 4 de julio", hábitos reseteados).
- Comandos de simulación: `dumpsys battery unplug` / `set level N` / `reset`.
## Próximos pasos (en orden)
1. Pulido menor: texto "Conectar cargador" se parte en 2 líneas apretadas en portrait; sudor de la
mascota cansada poco visible en tamaños chicos.
2. Sprint 2 (orden CLAUDE.md §3.1): Museo (subir dibujo) → Galería → Rachas en equipo → Alertas → Widgets.
3. Backend .NET 10 + deploy PapuTec (CLAUDE.md §4), CI Gitea Actions — necesario para Rachas en equipo.
## Detalles de implementación del módulo 3 (para retomar)
- `ui/battery/`: `BatteryUxViewModel` (estados None/LowBattery/Charging), `LowBatteryOverlay`, `ChargingLockScreen`; host en `MainActivity.BatteryUxHost` sobre el NavHost.
- CA-04 es estructural: la pantalla de bloqueo se quita SOLO cuando `BatteryStatusMonitor` emite desconexión real; el botón nunca desbloquea.
- BA-03: `LowBatteryPolicy` (dominio puro, 7 tests) — descartar en banda 20 silencia hasta caer a 15, etc. Conectar el cargador resetea el ciclo.
- Mascota: `MascotMood.Idle/Tired/Sleep` en `MascotIllustrations.kt` (sudor menta, Z's con stagger de 3).
- Escape de desarrollo: "salir (debug)" solo `BuildConfig.DEBUG` (el equipo de pruebas vive conectado por USB).
+110
View File
@@ -0,0 +1,110 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "mx.paputec.rachita"
// compileSdk 35 is required by androidx.core 1.15+; runtime behavior stays
// pinned to targetSdk 34 (CLAUDE.md §3).
compileSdk = 35
defaultConfig {
applicationId = "mx.paputec.rachita"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
applicationIdSuffix = ".debug"
isDebuggable = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
// AndroidX core + lifecycle
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
// Compose (BOM aligns all versions)
implementation(platform(libs.androidx.compose.bom))
androidTestImplementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.ui.text.google.fonts)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Persistence
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences)
// Networking (reserved for backend integration in later sprints)
implementation(libs.retrofit.core)
implementation(libs.retrofit.converter.kotlinx.serialization)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
// Concurrency
implementation(libs.kotlinx.coroutines.android)
// Testing
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
+1
View File
@@ -0,0 +1 @@
# Placeholder ProGuard/R8 rules. Populated when release minification is enabled.
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Battery / charging monitor: reserved for future foreground service in the battery module. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Reserved for the low-battery overlay outside the app (P0 BA-01). -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" tools:ignore="ProtectedPermissions" />
<application
android:name=".RachitaApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
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:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode|density"
android:theme="@style/Theme.Rachita">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Downloadable Fonts provider (Baloo 2 + Nunito) -->
<meta-data
android:name="preloaded_fonts"
android:resource="@array/preloaded_fonts" />
</application>
</manifest>
@@ -0,0 +1,152 @@
package mx.paputec.rachita
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.repository.ProfileRepository
import mx.paputec.rachita.ui.battery.BatteryUxState
import mx.paputec.rachita.ui.battery.BatteryUxViewModel
import mx.paputec.rachita.ui.battery.ChargingLockScreen
import mx.paputec.rachita.ui.battery.LowBatteryOverlay
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
import mx.paputec.rachita.ui.theme.RachitaTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RachitaTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = CreamBackground,
) {
Box(modifier = Modifier.fillMaxSize()) {
RachitaNavHost()
// Battery states cover any screen (PRD §5.3): the low
// overlay sits above content; the charging lock above all.
BatteryUxHost()
}
}
}
}
}
}
private object Routes {
const val ONBOARDING = "onboarding"
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()
// Chosen companion, consumed by the battery overlays (AV-05: the avatar
// follows the child across every surface of the app).
val avatar: StateFlow<AvatarKind> = profileRepository.profile
.map { it.avatar ?: AvatarKind.Fox }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AvatarKind.Fox)
init {
viewModelScope.launch {
val profile = profileRepository.profile.first()
_startRoute.value = if (profile.onboardingCompleted) Routes.DASHBOARD else Routes.ONBOARDING
}
}
}
@Composable
private fun BatteryUxHost() {
val batteryViewModel: BatteryUxViewModel = hiltViewModel()
val uxState by batteryViewModel.uxState.collectAsStateWithLifecycle()
val profileViewModel: StartRouteViewModel = hiltViewModel()
val avatar by profileViewModel.avatar.collectAsStateWithLifecycle()
when (val state = uxState) {
is BatteryUxState.LowBattery -> LowBatteryOverlay(
percent = state.percent,
avatar = avatar,
onDismiss = batteryViewModel::dismissLowBattery,
)
is BatteryUxState.Charging -> ChargingLockScreen(
percent = state.percent,
isFull = state.isFull,
avatar = avatar,
onDebugExit = batteryViewModel::debugSuppressChargingLock,
)
BatteryUxState.None -> Unit
}
}
@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 = resolved) {
composable(Routes.ONBOARDING) {
val viewModel: OnboardingViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
OnboardingScreen(
uiState = uiState,
onAvatarSelected = viewModel::selectAvatar,
onNameChange = viewModel::updateName,
onCustomAvatarPicked = viewModel::setCustomAvatar,
onContinue = {
viewModel.confirm()
navController.navigate(Routes.DASHBOARD) {
popUpTo(Routes.ONBOARDING) { inclusive = true }
}
},
)
}
composable(Routes.DASHBOARD) {
val viewModel: DashboardViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DashboardScreen(
uiState = uiState,
onToggleHabit = viewModel::toggleHabit,
)
}
}
}
@@ -0,0 +1,7 @@
package mx.paputec.rachita
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class RachitaApplication : Application()
@@ -0,0 +1,56 @@
package mx.paputec.rachita.data
import java.time.LocalDate
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mx.paputec.rachita.data.local.HabitDao
import mx.paputec.rachita.data.local.HabitEntity
import mx.paputec.rachita.data.local.HabitLogEntity
import mx.paputec.rachita.domain.model.Habit
import mx.paputec.rachita.domain.model.HabitIcon
import mx.paputec.rachita.domain.model.HabitLog
import mx.paputec.rachita.domain.model.HabitPalette
import mx.paputec.rachita.domain.repository.HabitRepository
class HabitRepositoryImpl(
private val dao: HabitDao,
) : HabitRepository {
override val activeHabits: Flow<List<Habit>> =
dao.observeActiveHabits().map { entities -> entities.map { it.toDomain() } }
override val doneLogs: Flow<List<HabitLog>> =
dao.observeDoneLogs().map { logs ->
logs.map { HabitLog(habitId = it.habitId, date = LocalDate.parse(it.date)) }
}
override suspend fun toggleHabit(habitId: String, date: LocalDate) {
val iso = date.toString()
val current = dao.getLog(habitId, iso)
dao.upsertLog(
HabitLogEntity(habitId = habitId, date = iso, done = !(current?.done ?: false)),
)
}
override suspend fun seedDefaultHabitsIfEmpty() {
if (dao.countHabits() > 0) return
// PRD DA-02 example habits — copy exact from prototype.
val now = System.currentTimeMillis()
dao.insertHabits(
listOf(
HabitEntity("banarse", "Bañarse", "Antes de dormir", HabitIcon.Drop.name, HabitPalette.Mint.name, 0, true, now),
HabitEntity("dormir", "Dormir temprano", "A las 9:00 pm", HabitIcon.Moon.name, HabitPalette.Lilac.name, 1, true, now),
HabitEntity("escuela", "Ir a la escuela", "Sin faltar nunca", HabitIcon.School.name, HabitPalette.Coral.name, 2, true, now),
),
)
}
}
private fun HabitEntity.toDomain() = Habit(
id = id,
name = name,
subtitle = subtitle,
icon = HabitIcon.entries.firstOrNull { it.name == icon } ?: HabitIcon.Drop,
palette = HabitPalette.entries.firstOrNull { it.name == palette } ?: HabitPalette.Mint,
sortOrder = sortOrder,
)
@@ -0,0 +1,30 @@
package mx.paputec.rachita.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface HabitDao {
@Query("SELECT * FROM habits WHERE active = 1 ORDER BY sortOrder")
fun observeActiveHabits(): Flow<List<HabitEntity>>
@Query("SELECT * FROM habit_logs WHERE done = 1")
fun observeDoneLogs(): Flow<List<HabitLogEntity>>
@Query("SELECT * FROM habit_logs WHERE habitId = :habitId AND date = :date")
suspend fun getLog(habitId: String, date: String): HabitLogEntity?
@Upsert
suspend fun upsertLog(log: HabitLogEntity)
@Query("SELECT COUNT(*) FROM habits")
suspend fun countHabits(): Int
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertHabits(habits: List<HabitEntity>)
}
@@ -0,0 +1,25 @@
package mx.paputec.rachita.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "habits")
data class HabitEntity(
@PrimaryKey val id: String,
val name: String,
val subtitle: String,
val icon: String,
val palette: String,
val sortOrder: Int,
val active: Boolean,
val createdAtEpochMs: Long,
)
// One row per habit per day (CLAUDE.md §7). Date stored as ISO-8601 (yyyy-MM-dd)
// so range queries stay lexicographically correct without converters.
@Entity(tableName = "habit_logs", primaryKeys = ["habitId", "date"])
data class HabitLogEntity(
val habitId: String,
val date: String,
val done: Boolean,
)
@@ -0,0 +1,13 @@
package mx.paputec.rachita.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [HabitEntity::class, HabitLogEntity::class],
version = 1,
exportSchema = false,
)
abstract class RachitaDatabase : RoomDatabase() {
abstract fun habitDao(): HabitDao
}
@@ -0,0 +1,60 @@
package mx.paputec.rachita.data.preferences
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.model.Profile
import mx.paputec.rachita.domain.repository.ProfileRepository
private val Context.profileDataStore by preferencesDataStore(name = "rachita_profile")
class ProfileRepositoryImpl(context: Context) : ProfileRepository {
private val store = context.applicationContext.profileDataStore
override val profile: Flow<Profile> = store.data.map { prefs ->
Profile(
childName = prefs[Keys.ChildName] ?: Profile.DEFAULT_CHILD_NAME,
avatar = prefs[Keys.AvatarKind]?.toAvatarKindOrNull(),
customAvatarUri = prefs[Keys.CustomAvatarUri],
onboardingCompleted = prefs[Keys.OnboardingCompleted] ?: false,
)
}
override suspend fun setAvatar(kind: AvatarKind) {
store.edit { prefs ->
prefs[Keys.AvatarKind] = kind.name
if (kind != AvatarKind.Custom) prefs.remove(Keys.CustomAvatarUri)
}
}
override suspend fun setCustomAvatar(imageUri: String) {
store.edit { prefs ->
prefs[Keys.AvatarKind] = AvatarKind.Custom.name
prefs[Keys.CustomAvatarUri] = imageUri
}
}
override suspend fun setChildName(name: String) {
store.edit { prefs -> prefs[Keys.ChildName] = name.trim().ifEmpty { Profile.DEFAULT_CHILD_NAME } }
}
override suspend fun markOnboardingCompleted() {
store.edit { prefs -> prefs[Keys.OnboardingCompleted] = true }
}
private object Keys {
val ChildName = stringPreferencesKey("child_name")
val AvatarKind = stringPreferencesKey("avatar_kind")
val CustomAvatarUri = stringPreferencesKey("custom_avatar_uri")
val OnboardingCompleted = booleanPreferencesKey("onboarding_completed")
}
}
private fun String.toAvatarKindOrNull(): AvatarKind? =
AvatarKind.entries.firstOrNull { it.name == this }
@@ -0,0 +1,44 @@
package mx.paputec.rachita.di
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import mx.paputec.rachita.data.HabitRepositoryImpl
import mx.paputec.rachita.data.local.HabitDao
import mx.paputec.rachita.data.local.RachitaDatabase
import mx.paputec.rachita.data.preferences.ProfileRepositoryImpl
import mx.paputec.rachita.domain.repository.HabitRepository
import mx.paputec.rachita.domain.repository.ProfileRepository
import mx.paputec.rachita.platform.battery.BatteryStatusMonitor
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideProfileRepository(@ApplicationContext context: Context): ProfileRepository =
ProfileRepositoryImpl(context)
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): RachitaDatabase =
Room.databaseBuilder(context, RachitaDatabase::class.java, "rachita.db").build()
@Provides
fun provideHabitDao(db: RachitaDatabase): HabitDao = db.habitDao()
@Provides
@Singleton
fun provideHabitRepository(dao: HabitDao): HabitRepository = HabitRepositoryImpl(dao)
@Provides
@Singleton
fun provideBatteryStatusMonitor(@ApplicationContext context: Context): BatteryStatusMonitor =
BatteryStatusMonitor(context)
}
@@ -0,0 +1,25 @@
package mx.paputec.rachita.domain
/**
* Low-battery alert bands (PRD BA-03): the overlay shows at <20 % and, once
* dismissed, reappears only when the battery falls into the next band
* (15/10/5) — never spamming within the same band.
*/
object LowBatteryPolicy {
// Descending thresholds; a percent belongs to the tightest band that contains it.
private val BANDS = listOf(20, 15, 10, 5)
/** Band for the percent, or null when battery is above the alert range. */
fun bandFor(percent: Int): Int? = BANDS.filter { percent <= it }.minOrNull()
/**
* True when the overlay should be visible: inside a band, not charging,
* and the band is deeper than the one the child already dismissed.
*/
fun shouldShowOverlay(percent: Int, isCharging: Boolean, dismissedBand: Int?): Boolean {
if (isCharging) return false
val band = bandFor(percent) ?: return false
return dismissedBand == null || band < dismissedBand
}
}
@@ -0,0 +1,35 @@
package mx.paputec.rachita.domain
import java.time.LocalDate
import mx.paputec.rachita.domain.model.HabitLog
/**
* Streak rules from PRD DA-11 (v1, no freezer):
* - A date is "secured" when every active habit has a done log on it.
* - The streak is the consecutive run of secured dates ending today — or ending
* yesterday while today is still in progress. Missing a full day resets to 0.
*
* Everything is computed from logs, never stored, so toggling a habit off
* (DA-03 reversibility) naturally rolls the streak back with no drift.
*/
object StreakCalculator {
fun securedDates(doneLogs: List<HabitLog>, activeHabitIds: Set<String>): Set<LocalDate> {
if (activeHabitIds.isEmpty()) return emptySet()
return doneLogs
.filter { it.habitId in activeHabitIds }
.groupBy({ it.date }, { it.habitId })
.filterValues { it.toSet().containsAll(activeHabitIds) }
.keys
}
fun currentStreak(secured: Set<LocalDate>, today: LocalDate): Int {
var day = if (today in secured) today else today.minusDays(1)
var count = 0
while (day in secured) {
count++
day = day.minusDays(1)
}
return count
}
}
@@ -0,0 +1,10 @@
package mx.paputec.rachita.domain.model
// The four options offered on the onboarding screen (PRD §5.1, AV-01/AV-02).
// `Custom` carries an accompanying image URI in the profile.
enum class AvatarKind {
Fox,
Cat,
Rabbit,
Custom,
}
@@ -0,0 +1,28 @@
package mx.paputec.rachita.domain.model
import java.time.LocalDate
// Icon and palette are semantic keys; the UI layer maps them to vectors/colors
// so the domain stays Android-free (CLAUDE.md §6.2 spirit applied to the client).
enum class HabitIcon { Drop, Moon, School }
enum class HabitPalette { Mint, Lilac, Coral }
data class Habit(
val id: String,
val name: String,
val subtitle: String,
val icon: HabitIcon,
val palette: HabitPalette,
val sortOrder: Int,
)
data class HabitLog(
val habitId: String,
val date: LocalDate,
)
data class BatteryStatus(
val percent: Int,
val isCharging: Boolean,
)
@@ -0,0 +1,21 @@
package mx.paputec.rachita.domain.model
// Local profile snapshot used by the onboarding + dashboard flows. Sprint 1
// stores this in DataStore only; the backend contract lives in a later sprint.
data class Profile(
val childName: String,
val avatar: AvatarKind?,
val customAvatarUri: String?,
val onboardingCompleted: Boolean,
) {
companion object {
const val DEFAULT_CHILD_NAME = "Lucía"
val Empty = Profile(
childName = DEFAULT_CHILD_NAME,
avatar = null,
customAvatarUri = null,
onboardingCompleted = false,
)
}
}
@@ -0,0 +1,20 @@
package mx.paputec.rachita.domain.repository
import java.time.LocalDate
import kotlinx.coroutines.flow.Flow
import mx.paputec.rachita.domain.model.Habit
import mx.paputec.rachita.domain.model.HabitLog
interface HabitRepository {
/** Active habits ordered for display. */
val activeHabits: Flow<List<Habit>>
/** Every log marked done, across all dates (small volume: habits × days). */
val doneLogs: Flow<List<HabitLog>>
/** Flips today's done state for the habit (DA-03: toggle is reversible). */
suspend fun toggleHabit(habitId: String, date: LocalDate)
/** Inserts the three PRD §5.2 example habits on first run (DA-02). */
suspend fun seedDefaultHabitsIfEmpty()
}
@@ -0,0 +1,14 @@
package mx.paputec.rachita.domain.repository
import kotlinx.coroutines.flow.Flow
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.model.Profile
interface ProfileRepository {
val profile: Flow<Profile>
suspend fun setAvatar(kind: AvatarKind)
suspend fun setCustomAvatar(imageUri: String)
suspend fun setChildName(name: String)
suspend fun markOnboardingCompleted()
}
@@ -0,0 +1,43 @@
package mx.paputec.rachita.platform.battery
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import mx.paputec.rachita.domain.model.BatteryStatus
/**
* Real battery level + charging state from ACTION_BATTERY_CHANGED (sticky
* broadcast — no polling, CLAUDE.md §8). Feeds the dashboard chip (DA-08) now
* and the low-battery/charging-lock flows of module 3 later.
*/
@Singleton
class BatteryStatusMonitor @Inject constructor(
private val context: Context,
) {
val status: Flow<BatteryStatus> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(intent.toBatteryStatus())
}
}
val sticky = context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
sticky?.let { trySend(it.toBatteryStatus()) }
awaitClose { context.unregisterReceiver(receiver) }
}.distinctUntilChanged()
}
private fun Intent.toBatteryStatus(): BatteryStatus {
val level = getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = getIntExtra(BatteryManager.EXTRA_SCALE, -1)
val percent = if (level >= 0 && scale > 0) (level * 100) / scale else 100
val plugged = getIntExtra(BatteryManager.EXTRA_PLUGGED, 0)
return BatteryStatus(percent = percent, isCharging = plugged != 0)
}
@@ -0,0 +1,79 @@
package mx.paputec.rachita.ui.battery
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import mx.paputec.rachita.BuildConfig
import mx.paputec.rachita.domain.LowBatteryPolicy
import mx.paputec.rachita.domain.model.BatteryStatus
import mx.paputec.rachita.platform.battery.BatteryStatusMonitor
// PRD §5.3 state machine: normal → low (<20 %) → charging → normal.
sealed interface BatteryUxState {
data object None : BatteryUxState
data class LowBattery(val percent: Int) : BatteryUxState
data class Charging(val percent: Int, val isFull: Boolean) : BatteryUxState
}
@HiltViewModel
class BatteryUxViewModel @Inject constructor(
batteryMonitor: BatteryStatusMonitor,
) : ViewModel() {
private val dismissedBand = MutableStateFlow<Int?>(null)
private val debugUnlocked = MutableStateFlow(false)
private var lastStatus: BatteryStatus? = null
init {
// Plugging in resets the alert cycle (BA-03) and any debug unlock.
batteryMonitor.status
.onEach { status ->
lastStatus = status
if (status.isCharging) dismissedBand.value = null else debugUnlocked.value = false
}
.launchIn(viewModelScope)
}
val uxState: StateFlow<BatteryUxState> = combine(
batteryMonitor.status,
dismissedBand,
debugUnlocked,
) { status, dismissed, debugBypass ->
when {
status.isCharging && !debugBypass ->
BatteryUxState.Charging(percent = status.percent, isFull = status.percent >= 100)
LowBatteryPolicy.shouldShowOverlay(status.percent, status.isCharging, dismissed) ->
BatteryUxState.LowBattery(percent = status.percent)
else -> BatteryUxState.None
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = BatteryUxState.None,
)
/** "Ahora no": silences the overlay until the next deeper band (BA-03). */
fun dismissLowBattery() {
val percent = lastStatus?.percent ?: return
dismissedBand.value = LowBatteryPolicy.bandFor(percent)
}
/**
* Debug-only escape hatch so USB-tethered development is not permanently
* locked behind the charging screen. No-op in release builds (CA-04 stays
* strict for real devices on chargers).
*/
fun debugSuppressChargingLock() {
if (BuildConfig.DEBUG) debugUnlocked.value = true
}
}
@@ -0,0 +1,269 @@
package mx.paputec.rachita.ui.battery
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
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.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.widthIn
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
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 kotlin.math.sin
import kotlin.random.Random
import kotlinx.coroutines.delay
import mx.paputec.rachita.BuildConfig
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.onboarding.components.MascotMood
import mx.paputec.rachita.ui.theme.ChargingGreen
import mx.paputec.rachita.ui.theme.NightGradientEnd
import mx.paputec.rachita.ui.theme.NightGradientMid
import mx.paputec.rachita.ui.theme.NightGradientStart
import mx.paputec.rachita.ui.theme.RachitaTheme
// CA-01..05: full-screen night lock while charging. It cannot be dismissed by
// back or by the escape button — the screen disappears on its own when the
// real battery state says the charger left (BatteryUxViewModel), which is the
// PRD's "verify before unlocking" rule made structural.
@Composable
fun ChargingLockScreen(
percent: Int,
isFull: Boolean,
avatar: AvatarKind,
onDebugExit: () -> Unit,
modifier: Modifier = Modifier,
) {
// CA-01: no back navigation while charging.
BackHandler { }
var showStillPlugged by remember { mutableStateOf(false) }
LaunchedEffect(showStillPlugged) {
if (showStillPlugged) {
delay(2_500)
showStillPlugged = false
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(NightGradientStart, NightGradientMid, NightGradientEnd),
),
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
) {
TwinklingStars()
Column(
modifier = Modifier
.align(Alignment.Center)
.verticalScroll(rememberScrollState())
.padding(24.dp)
.widthIn(max = 460.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
EnergyCapsule(avatar = avatar, awake = isFull)
Text(
text = stringResource(if (isFull) R.string.charging_full_title else R.string.charging_title),
style = MaterialTheme.typography.headlineSmall.copy(color = Color.White),
textAlign = TextAlign.Center,
)
Text(
text = stringResource(if (isFull) R.string.charging_full_subtitle else R.string.charging_subtitle),
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White.copy(alpha = 0.85f)),
textAlign = TextAlign.Center,
)
ChargingBar(percent = percent)
Text(
text = stringResource(R.string.charging_progress, percent),
style = MaterialTheme.typography.labelLarge.copy(color = ChargingGreen),
)
// CA-04: pressing while still plugged never unlocks — it only answers
// gently. Real unplugging dismisses this screen automatically.
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Color.White.copy(alpha = 0.14f))
.clickable { showStillPlugged = true }
.padding(horizontal = 28.dp, vertical = 14.dp),
) {
Text(
text = stringResource(R.string.charging_unplug_button),
style = MaterialTheme.typography.labelLarge.copy(color = Color.White),
)
}
AnimatedVisibility(visible = showStillPlugged, enter = fadeIn(), exit = fadeOut()) {
Text(
text = stringResource(R.string.charging_still_plugged),
style = MaterialTheme.typography.bodyMedium.copy(color = Color.White.copy(alpha = 0.9f)),
)
}
if (BuildConfig.DEBUG) {
TextButton(onClick = onDebugExit) {
Text(
text = stringResource(R.string.charging_debug_exit),
style = MaterialTheme.typography.labelSmall.copy(color = Color.White.copy(alpha = 0.4f)),
)
}
}
}
}
}
// PRD §6.4 "twinkle" (2.23 s): fixed pseudo-random star field, each star with
// its own phase so the sky shimmers instead of blinking in unison.
@Composable
private fun TwinklingStars() {
val stars = remember {
val random = Random(seed = 7)
List(46) {
Triple(random.nextFloat(), random.nextFloat(), random.nextFloat())
}
}
val t by rememberInfiniteTransition(label = "twinkle").animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(animation = tween(2600, easing = LinearEasing)),
label = "phase",
)
Canvas(modifier = Modifier.fillMaxSize()) {
stars.forEach { (fx, fy, phase) ->
val alpha = 0.25f + 0.75f * ((sin((t + phase) * 2f * Math.PI).toFloat() + 1f) / 2f)
drawCircle(
color = Color.White.copy(alpha = alpha * 0.8f),
radius = (1.2f + 1.6f * phase) * density,
center = Offset(fx * size.width, fy * size.height * 0.92f),
)
}
}
}
// CA-02: the companion sleeps inside a translucent "energy capsule" with a
// pulsing glow; at 100 % it wakes up (CA-05).
@Composable
private fun EnergyCapsule(avatar: AvatarKind, awake: Boolean) {
val glow by rememberInfiniteTransition(label = "glow").animateFloat(
initialValue = 0.95f,
targetValue = 1.06f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1400),
repeatMode = RepeatMode.Reverse,
),
label = "capsule",
)
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(210.dp)
.scale(glow)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
ChargingGreen.copy(alpha = 0.28f),
Color.White.copy(alpha = 0.10f),
Color.Transparent,
),
),
),
)
Box(
modifier = Modifier
.size(170.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.10f)),
contentAlignment = Alignment.Center,
) {
Mascot(
kind = avatar,
mood = if (awake) MascotMood.Idle else MascotMood.Sleep,
modifier = Modifier.size(130.dp),
)
}
}
}
@Composable
private fun ChargingBar(percent: Int) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(14.dp)
.clip(RoundedCornerShape(999.dp))
.background(Color.White.copy(alpha = 0.18f)),
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction = (percent / 100f).coerceIn(0f, 1f))
.height(14.dp)
.clip(RoundedCornerShape(999.dp))
.background(ChargingGreen),
)
}
}
@Preview(showBackground = true, widthDp = 800, heightDp = 640)
@Composable
private fun PreviewChargingLock() {
RachitaTheme {
ChargingLockScreen(percent = 64, isFull = false, avatar = AvatarKind.Fox, onDebugExit = {})
}
}
@Preview(showBackground = true, widthDp = 800, heightDp = 640)
@Composable
private fun PreviewChargingLockFull() {
RachitaTheme {
ChargingLockScreen(percent = 100, isFull = true, avatar = AvatarKind.Fox, onDebugExit = {})
}
}
@@ -0,0 +1,195 @@
package mx.paputec.rachita.ui.battery
import androidx.activity.compose.BackHandler
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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.fillMaxSize
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.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
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.onboarding.components.MascotMood
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.CreamBackground
import mx.paputec.rachita.ui.theme.CreamCardSoft
import mx.paputec.rachita.ui.theme.Ink
import mx.paputec.rachita.ui.theme.RachitaTheme
// BA-01/02: modal overlay over any screen — dimmed backdrop, tired mascot,
// real percent, coral battery bar and two CTAs. Charging is framed as caring
// for the companion, never as punishment (BA-04).
@Composable
fun LowBatteryOverlay(
percent: Int,
avatar: AvatarKind,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
// Back behaves like "Ahora no" — the overlay is dismissible (unlike the charging lock).
BackHandler(onBack = onDismiss)
Box(
modifier = modifier
.fillMaxSize()
// rgba(40,30,26,.5) from the prototype backdrop.
.background(Color(0x80281E1A))
// Consume taps so the dashboard behind is not interactive.
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.padding(24.dp)
.widthIn(max = 420.dp)
.clip(RoundedCornerShape(28.dp))
.background(CreamBackground)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Mascot(kind = avatar, mood = MascotMood.Tired, modifier = Modifier.size(130.dp))
Text(
text = stringResource(R.string.battery_low_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = buildAnnotatedString {
val template = stringResource(R.string.battery_low_body, percent)
val bold = "$percent%"
val start = template.indexOf(bold)
if (start >= 0) {
append(template.substring(0, start))
withStyle(SpanStyle(fontWeight = FontWeight.ExtraBold, color = CoralIntense)) {
append(bold)
}
append(template.substring(start + bold.length))
} else {
append(template)
}
},
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
BatteryBar(percent = percent)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(999.dp))
.background(Color.White)
.border(2.dp, CardBorder, RoundedCornerShape(999.dp))
.clickable(onClick = onDismiss)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.battery_low_later),
style = MaterialTheme.typography.labelLarge,
)
}
ConnectChargerButton(onClick = onDismiss, modifier = Modifier.weight(1f))
}
}
}
}
@Composable
private fun BatteryBar(percent: Int) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(14.dp)
.clip(RoundedCornerShape(999.dp))
.background(CreamCardSoft),
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction = (percent / 100f).coerceIn(0f, 1f))
.height(14.dp)
.clip(RoundedCornerShape(999.dp))
.background(CoralIntense),
)
}
}
// PRD §6.4 "coralpulse": the urgent CTA gently pulses (1.6 s loop). Physically
// plugging in is what really matters — tapping just closes the overlay; the
// charging lock then appears on its own when the plug is detected.
@Composable
private fun ConnectChargerButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
val pulse by rememberInfiniteTransition(label = "coralpulse").animateFloat(
initialValue = 1f,
targetValue = 1.045f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800),
repeatMode = RepeatMode.Reverse,
),
label = "pulse",
)
Box(
modifier = modifier
.scale(pulse)
.clip(RoundedCornerShape(999.dp))
.background(Coral)
.clickable(onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.battery_low_connect),
style = MaterialTheme.typography.labelLarge.copy(color = CreamBackground),
)
}
}
@Preview(showBackground = true, widthDp = 800, heightDp = 640)
@Composable
private fun PreviewLowBatteryOverlay() {
RachitaTheme {
LowBatteryOverlay(percent = 18, avatar = AvatarKind.Fox, onDismiss = {})
}
}
@@ -0,0 +1,283 @@
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
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Spacer
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.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
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.
// 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,
onToggleHabit: (String) -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.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()
.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,
)
}
}
// 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 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)
else -> stringResource(R.string.dashboard_pending_many, uiState.pendingCount)
}
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringResource(R.string.dashboard_greeting, uiState.childName),
style = MaterialTheme.typography.headlineMedium,
maxLines = 1,
)
Text(
text = "${uiState.dateLine} · $pendingText",
style = MaterialTheme.typography.bodyMedium.copy(color = WarmGreyDeep),
maxLines = 2,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
BackgroundChip()
Spacer(modifier = Modifier.weight(1f))
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
.fillMaxWidth()
.weight(1f),
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 BackgroundChip() {
Row(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(CreamCard)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(RoundedCornerShape(50))
.background(SuccessGreen),
)
Text(
text = stringResource(R.string.dashboard_background_chip),
style = MaterialTheme.typography.labelSmall.copy(color = CoralIntense),
)
}
}
@Composable
private fun TodayCounterPill(done: Int, total: Int) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Color.White)
.padding(horizontal = 14.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.dashboard_today_counter, done, total),
style = MaterialTheme.typography.labelLarge,
)
}
}
// DA-08: battery indicator reacting to real state — grey normal, coral <20%,
// green while charging.
@Composable
private fun BatteryChip(battery: BatteryStatus) {
val tint = when {
battery.isCharging -> ChargingGreen
battery.percent < 20 -> CoralIntense
else -> WarmGreyDeep
}
Row(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Color.White)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = if (battery.isCharging) Icons.Rounded.BatteryChargingFull else Icons.Rounded.BatteryFull,
contentDescription = stringResource(R.string.cd_battery, battery.percent),
tint = tint,
modifier = Modifier.size(16.dp),
)
Text(
text = "${battery.percent}%",
style = MaterialTheme.typography.labelMedium.copy(color = tint),
)
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 1280, heightDp = 800)
@Composable
private fun PreviewDashboard() {
RachitaTheme {
DashboardScreen(
uiState = DashboardUiState(
childName = "Lucía",
avatar = AvatarKind.Fox,
habits = listOf(
HabitCardUi("banarse", "Bañarse", "Antes de dormir", HabitIcon.Drop, HabitPalette.Mint, true),
HabitCardUi("dormir", "Dormir temprano", "A las 9:00 pm", HabitIcon.Moon, HabitPalette.Lilac, false),
HabitCardUi("escuela", "Ir a la escuela", "Sin faltar nunca", HabitIcon.School, HabitPalette.Coral, false),
),
streak = 12,
week = listOf(
WeekDayUi("L", true, false),
WeekDayUi("M", true, false),
WeekDayUi("M", true, false),
WeekDayUi("J", false, true),
WeekDayUi("V", false, false),
WeekDayUi("S", false, false),
WeekDayUi("D", false, false),
),
level = 7,
xpInLevel = 320,
battery = BatteryStatus(86, isCharging = false),
dateLine = "Lunes 22 de junio",
isLoading = false,
),
onToggleHabit = {},
)
}
}
@@ -0,0 +1,47 @@
package mx.paputec.rachita.ui.dashboard
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.model.BatteryStatus
import mx.paputec.rachita.domain.model.HabitIcon
import mx.paputec.rachita.domain.model.HabitPalette
data class HabitCardUi(
val id: String,
val name: String,
val subtitle: String,
val icon: HabitIcon,
val palette: HabitPalette,
val doneToday: Boolean,
)
data class WeekDayUi(
val label: String,
val secured: Boolean,
val isToday: Boolean,
)
data class DashboardUiState(
val childName: String = "",
val avatar: AvatarKind = AvatarKind.Fox,
val habits: List<HabitCardUi> = emptyList(),
val streak: Int = 0,
val week: List<WeekDayUi> = emptyList(),
val level: Int = 1,
val xpInLevel: Int = 0,
val xpTarget: Int = XP_PER_LEVEL,
val battery: BatteryStatus? = null,
val dateLine: String = "",
/** Increments on each new all-done celebration; UI fires confetti on change. */
val celebrationCount: Int = 0,
val isLoading: Boolean = true,
) {
val doneCount: Int get() = habits.count { it.doneToday }
val totalCount: Int get() = habits.size
val pendingCount: Int get() = totalCount - doneCount
val allDone: Boolean get() = totalCount > 0 && doneCount == totalCount
companion object {
const val XP_PER_LEVEL = 500
const val XP_PER_HABIT = 10
}
}
@@ -0,0 +1,122 @@
package mx.paputec.rachita.ui.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import mx.paputec.rachita.domain.StreakCalculator
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.model.Habit
import mx.paputec.rachita.domain.model.HabitLog
import mx.paputec.rachita.domain.model.Profile
import mx.paputec.rachita.domain.repository.HabitRepository
import mx.paputec.rachita.domain.repository.ProfileRepository
import mx.paputec.rachita.platform.battery.BatteryStatusMonitor
import mx.paputec.rachita.ui.dashboard.DashboardUiState.Companion.XP_PER_HABIT
import mx.paputec.rachita.ui.dashboard.DashboardUiState.Companion.XP_PER_LEVEL
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val habitRepository: HabitRepository,
profileRepository: ProfileRepository,
batteryMonitor: BatteryStatusMonitor,
) : ViewModel() {
// Session-scoped guard so confetti fires once per day (QA #4): re-toggling
// the same all-done state must not repeat the celebration.
private var lastCelebratedDate: LocalDate? = null
private var celebrations = 0
init {
viewModelScope.launch { habitRepository.seedDefaultHabitsIfEmpty() }
}
val uiState: StateFlow<DashboardUiState> = combine(
habitRepository.activeHabits,
habitRepository.doneLogs,
profileRepository.profile,
batteryMonitor.status,
) { habits, doneLogs, profile, battery ->
buildState(habits, doneLogs, profile, battery, LocalDate.now())
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = DashboardUiState(),
)
fun toggleHabit(habitId: String) {
viewModelScope.launch { habitRepository.toggleHabit(habitId, LocalDate.now()) }
}
private fun buildState(
habits: List<Habit>,
doneLogs: List<HabitLog>,
profile: Profile,
battery: mx.paputec.rachita.domain.model.BatteryStatus,
today: LocalDate,
): DashboardUiState {
val activeIds = habits.map { it.id }.toSet()
val doneTodayIds = doneLogs.filter { it.date == today }.map { it.habitId }.toSet()
val secured = StreakCalculator.securedDates(doneLogs, activeIds)
val streak = StreakCalculator.currentStreak(secured, today)
val allDone = habits.isNotEmpty() && doneTodayIds.containsAll(activeIds)
if (allDone && lastCelebratedDate != today) {
lastCelebratedDate = today
celebrations++
}
// XP fully derived from history: reversible and drift-free (like the streak).
val xp = doneLogs.count { it.habitId in activeIds } * XP_PER_HABIT
val monday = today.with(DayOfWeek.MONDAY)
val week = (0..6).map { offset ->
val day = monday.plusDays(offset.toLong())
WeekDayUi(
label = WEEK_LABELS[offset],
secured = day in secured || (day == today && allDone),
isToday = day == today,
)
}
return DashboardUiState(
childName = profile.childName,
avatar = profile.avatar ?: AvatarKind.Fox,
habits = habits.map { habit ->
HabitCardUi(
id = habit.id,
name = habit.name,
subtitle = habit.subtitle,
icon = habit.icon,
palette = habit.palette,
doneToday = habit.id in doneTodayIds,
)
},
streak = streak,
week = week,
level = xp / XP_PER_LEVEL + 1,
xpInLevel = xp % XP_PER_LEVEL,
xpTarget = XP_PER_LEVEL,
battery = battery,
dateLine = today.format(DATE_FORMAT).replaceFirstChar { it.titlecase(LOCALE_MX) },
celebrationCount = celebrations,
isLoading = false,
)
}
private companion object {
// Prototype week row: L M M J V S D.
val WEEK_LABELS = listOf("L", "M", "M", "J", "V", "S", "D")
val LOCALE_MX = Locale("es", "MX")
val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM", LOCALE_MX)
}
}
@@ -0,0 +1,93 @@
package mx.paputec.rachita.ui.dashboard.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.unit.dp
import kotlin.random.Random
import kotlinx.coroutines.launch
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.Lilac
import mx.paputec.rachita.ui.theme.Mint
import mx.paputec.rachita.ui.theme.Pink
import mx.paputec.rachita.ui.theme.Sun
// DA-04: 26 palette-colored pieces falling 1.12.1 s, fired once per celebration.
// `trigger` is the celebration counter; 0 means "never celebrated".
private const val PIECE_COUNT = 26
private val PALETTE = listOf(Coral, Mint, Sun, Lilac, Pink)
private data class ConfettiPiece(
val xFraction: Float,
val color: Color,
val width: Float,
val height: Float,
val durationMs: Int,
val delayMs: Int,
val rotationSeed: Float,
)
@Composable
fun ConfettiOverlay(
trigger: Int,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
var pieces by remember { mutableStateOf<List<ConfettiPiece>>(emptyList()) }
val progress = remember { Animatable(0f) }
LaunchedEffect(trigger) {
if (trigger <= 0 || !enabled) return@LaunchedEffect
// Seeded by trigger: deterministic per celebration, varied across them.
val random = Random(trigger)
pieces = List(PIECE_COUNT) {
ConfettiPiece(
xFraction = random.nextFloat(),
color = PALETTE[random.nextInt(PALETTE.size)],
width = 8f + random.nextFloat() * 8f,
height = 12f + random.nextFloat() * 10f,
durationMs = 1100 + random.nextInt(1000),
delayMs = random.nextInt(350),
rotationSeed = random.nextFloat() * 360f,
)
}
progress.snapTo(0f)
launch {
progress.animateTo(1f, animationSpec = tween(durationMillis = 2450, easing = LinearEasing))
pieces = emptyList()
}
}
if (pieces.isEmpty()) return
Canvas(modifier = modifier.fillMaxSize()) {
val t = progress.value
pieces.forEach { piece ->
val total = piece.delayMs + piece.durationMs
val local = ((t * 2450f) - piece.delayMs) / piece.durationMs
if (local <= 0f || local > 1f || total <= 0) return@forEach
val x = piece.xFraction * size.width
val y = local * (size.height + 60.dp.toPx()) - 30.dp.toPx()
rotate(degrees = piece.rotationSeed + local * 540f, pivot = Offset(x, y)) {
drawRect(
color = piece.color,
topLeft = Offset(x - piece.width / 2f, y - piece.height / 2f),
size = Size(piece.width, piece.height),
)
}
}
}
}
@@ -0,0 +1,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)
},
)
}
}
@@ -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,219 @@
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.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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 = if (streak == 1) {
stringResource(R.string.dashboard_streak_day_singular)
} else {
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),
)
}
}
}
}
@Composable
internal 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
internal 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,225 @@
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 = if (streak == 1) {
stringResource(R.string.dashboard_streak_sidebar_singular)
} else {
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))
}
@@ -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),
),
)
}
}
@@ -0,0 +1,202 @@
package mx.paputec.rachita.ui.onboarding
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
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
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.onboarding.components.AvatarCard
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.CreamBackground
import mx.paputec.rachita.ui.theme.Ink
import mx.paputec.rachita.ui.theme.RachitaTheme
import mx.paputec.rachita.ui.theme.WarmGreyDeep
private data class AvatarOption(
val kind: AvatarKind,
val labelRes: Int,
val hintRes: Int? = null,
)
private val AvatarOptions = listOf(
AvatarOption(AvatarKind.Fox, R.string.onboarding_avatar_fox),
AvatarOption(AvatarKind.Cat, R.string.onboarding_avatar_cat),
AvatarOption(AvatarKind.Rabbit, R.string.onboarding_avatar_rabbit),
AvatarOption(AvatarKind.Custom, R.string.onboarding_avatar_custom, R.string.onboarding_avatar_custom_hint),
)
@Composable
fun OnboardingScreen(
uiState: OnboardingUiState,
onAvatarSelected: (AvatarKind) -> Unit,
onNameChange: (String) -> Unit,
onCustomAvatarPicked: (String) -> Unit,
onContinue: () -> Unit,
) {
val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri: Uri? ->
if (uri != null) onCustomAvatarPicked(uri.toString())
}
val pickerRequest = remember { PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) }
val insets = WindowInsets.safeDrawing.asPaddingValues()
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 = 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(if (isCompact) 2 else 4),
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 8.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(
kind = option.kind,
label = stringResource(id = option.labelRes),
hint = option.hintRes?.let { stringResource(id = it) },
isSelected = uiState.selectedAvatar == option.kind,
onClick = {
if (option.kind == AvatarKind.Custom) {
imagePicker.launch(pickerRequest)
} else {
onAvatarSelected(option.kind)
}
},
modifier = Modifier.fillMaxWidth(),
)
}
}
ContinueButton(
enabled = uiState.canContinue && !uiState.isSaving,
onClick = onContinue,
)
}
}
}
@Composable
private fun OnboardingHeader(childName: String) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.onboarding_title),
style = MaterialTheme.typography.headlineLarge.copy(color = Ink),
textAlign = TextAlign.Center,
)
Text(
text = stringResource(id = R.string.onboarding_subtitle),
style = MaterialTheme.typography.bodyLarge.copy(color = WarmGreyDeep),
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp),
)
}
}
@Composable
private fun ContinueButton(enabled: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Button(
onClick = onClick,
enabled = enabled,
shape = RoundedCornerShape(999.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Coral,
contentColor = CreamBackground,
disabledContainerColor = Coral.copy(alpha = 0.35f),
disabledContentColor = CreamBackground.copy(alpha = 0.75f),
),
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 18.dp),
modifier = Modifier.height(72.dp),
) {
Text(
text = stringResource(id = R.string.onboarding_continue),
style = MaterialTheme.typography.titleLarge.copy(color = CreamBackground),
)
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 1280, heightDp = 800)
@Composable
private fun PreviewOnboardingUnselected() {
RachitaTheme {
OnboardingScreen(
uiState = OnboardingUiState(),
onAvatarSelected = {},
onNameChange = {},
onCustomAvatarPicked = {},
onContinue = {},
)
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 1280, heightDp = 800)
@Composable
private fun PreviewOnboardingSelectedRabbit() {
RachitaTheme {
OnboardingScreen(
uiState = OnboardingUiState(selectedAvatar = AvatarKind.Rabbit),
onAvatarSelected = {},
onNameChange = {},
onCustomAvatarPicked = {},
onContinue = {},
)
}
}
@@ -0,0 +1,15 @@
package mx.paputec.rachita.ui.onboarding
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.model.Profile
data class OnboardingUiState(
val childName: String = Profile.DEFAULT_CHILD_NAME,
val selectedAvatar: AvatarKind? = null,
val customAvatarUri: String? = null,
val isSaving: Boolean = false,
) {
val canContinue: Boolean
get() = selectedAvatar != null &&
(selectedAvatar != AvatarKind.Custom || !customAvatarUri.isNullOrBlank())
}
@@ -0,0 +1,69 @@
package mx.paputec.rachita.ui.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.flow.update
import kotlinx.coroutines.launch
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.repository.ProfileRepository
@HiltViewModel
class OnboardingViewModel @Inject constructor(
private val profileRepository: ProfileRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(OnboardingUiState())
val uiState: StateFlow<OnboardingUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
val stored = profileRepository.profile.first()
_uiState.update {
it.copy(
childName = stored.childName,
selectedAvatar = stored.avatar,
customAvatarUri = stored.customAvatarUri,
)
}
}
}
fun selectAvatar(kind: AvatarKind) {
_uiState.update { current ->
current.copy(
selectedAvatar = kind,
customAvatarUri = if (kind == AvatarKind.Custom) current.customAvatarUri else null,
)
}
}
fun setCustomAvatar(uri: String) {
_uiState.update { it.copy(selectedAvatar = AvatarKind.Custom, customAvatarUri = uri) }
}
fun updateName(name: String) {
_uiState.update { it.copy(childName = name) }
}
fun confirm() {
val state = _uiState.value
val chosen = state.selectedAvatar ?: return
_uiState.update { it.copy(isSaving = true) }
viewModelScope.launch {
profileRepository.setChildName(state.childName)
if (chosen == AvatarKind.Custom && !state.customAvatarUri.isNullOrBlank()) {
profileRepository.setCustomAvatar(state.customAvatarUri)
} else {
profileRepository.setAvatar(chosen)
}
profileRepository.markOnboardingCompleted()
_uiState.update { it.copy(isSaving = false) }
}
}
}
@@ -0,0 +1,143 @@
package mx.paputec.rachita.ui.onboarding.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.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.theme.CardBorder
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.CreamCard
import mx.paputec.rachita.ui.theme.CreamCardSoft
import mx.paputec.rachita.ui.theme.RachitaTheme
import mx.paputec.rachita.ui.theme.SuccessGreen
@Composable
fun AvatarCard(
kind: AvatarKind,
label: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
hint: String? = null,
) {
val borderColor = if (isSelected) Coral else CardBorder
val borderWidth by animateDpAsState(
targetValue = if (isSelected) 3.dp else 2.dp,
animationSpec = spring(stiffness = 300f),
label = "avatar-card-border",
)
val backgroundColor = if (isSelected) CreamCard else CreamCardSoft
Box(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.border(width = borderWidth, color = borderColor, shape = RoundedCornerShape(24.dp))
.clickable(onClick = onClick)
.padding(16.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = Alignment.Center,
) {
Mascot(kind = kind, modifier = Modifier.fillMaxSize())
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = label,
style = MaterialTheme.typography.titleLarge,
)
if (hint != null) {
Text(
text = hint,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
AnimatedVisibility(
visible = isSelected,
enter = scaleIn(spring(stiffness = 350f)) + fadeIn(),
exit = scaleOut() + fadeOut(),
modifier = Modifier
.align(Alignment.TopEnd),
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(SuccessGreen)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
text = stringResource(id = R.string.onboarding_selected_badge),
style = MaterialTheme.typography.labelMedium.copy(color = CreamCard),
)
}
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 260, heightDp = 320)
@Composable
private fun PreviewAvatarCardUnselected() {
RachitaTheme {
AvatarCard(
kind = AvatarKind.Fox,
label = "Zorro Rachi",
isSelected = false,
onClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 260, heightDp = 320)
@Composable
private fun PreviewAvatarCardSelected() {
RachitaTheme {
AvatarCard(
kind = AvatarKind.Cat,
label = "Gata Menta",
isSelected = true,
onClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -0,0 +1,434 @@
package mx.paputec.rachita.ui.onboarding.components
import androidx.compose.animation.core.LinearEasing
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.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlin.math.max
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.CreamBackground
import mx.paputec.rachita.ui.theme.Ink
import mx.paputec.rachita.ui.theme.Mint
import mx.paputec.rachita.ui.theme.Pink
import mx.paputec.rachita.ui.theme.SunDeep
import mx.paputec.rachita.ui.theme.WarmGreyDeep
// PRD §5.1: idle (blink + breathe) · tired (droopy brows + sweat, battery low)
// · sleep (closed eyes + floating Z's, charging capsule).
enum class MascotMood { Idle, Tired, Sleep }
// A mascot head drawn with Compose Canvas — no bitmap assets, so the same
// drawing is retinted and re-expressed across moods (PRD §6.4 timings).
@Composable
fun Mascot(
kind: AvatarKind,
modifier: Modifier = Modifier,
mood: MascotMood = MascotMood.Idle,
) {
val transition = rememberInfiniteTransition(label = "mascot-$kind-$mood")
val breathe by transition.animateFloat(
initialValue = 1f,
targetValue = if (mood == MascotMood.Sleep) 1.045f else 1.03f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = if (mood == MascotMood.Sleep) 3600 else 3200, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "breathe",
)
// Blink cycles every ~5 s (idle only); ~180 ms of closed eye.
val blink by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 5000, easing = LinearEasing),
),
label = "blink",
)
// Shared 0..1 phase for the sweat drop (2.4 s) and floating Z's (2.6 s).
val moodPhase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = if (mood == MascotMood.Sleep) 2600 else 2400,
easing = LinearEasing,
),
),
label = "moodPhase",
)
val eyeOpen = when (mood) {
MascotMood.Idle -> if (blink > 0.96f) 0.2f else 1f
MascotMood.Tired -> 0.45f
MascotMood.Sleep -> 0f
}
Canvas(modifier = modifier) {
when (kind) {
AvatarKind.Fox -> drawMascotHead(
headColor = Coral, earColor = Coral, innerEarColor = CreamBackground,
snoutColor = CreamBackground, bodyScale = breathe, eyeOpen = eyeOpen,
earStyle = EarStyle.Pointy, mood = mood, moodPhase = moodPhase,
)
AvatarKind.Cat -> drawMascotHead(
headColor = Mint, earColor = Mint, innerEarColor = Pink.copy(alpha = 0.5f),
snoutColor = CreamBackground, bodyScale = breathe, eyeOpen = eyeOpen,
earStyle = EarStyle.Triangular, mood = mood, moodPhase = moodPhase,
)
AvatarKind.Rabbit -> drawMascotHead(
headColor = SunDeep, earColor = SunDeep, innerEarColor = Pink.copy(alpha = 0.4f),
snoutColor = CreamBackground, bodyScale = breathe, eyeOpen = eyeOpen,
earStyle = EarStyle.Long, mood = mood, moodPhase = moodPhase,
)
AvatarKind.Custom -> drawCustomFrame()
}
}
}
private fun DrawScope.drawCustomFrame() {
val cx = size.width / 2f
val cy = size.height / 2f
val radius = max(size.width, size.height) * 0.32f
// Dashed frame — reads as "your drawing goes here" until an image is picked.
val dashCount = 24
val strokeColor = WarmGreyDeep.copy(alpha = 0.7f)
for (i in 0 until dashCount) {
val angle = (2f * Math.PI * i / dashCount).toFloat()
val px = cx + radius * kotlin.math.cos(angle)
val py = cy + radius * kotlin.math.sin(angle)
drawCircle(color = strokeColor, radius = 4.dp.toPx(), center = Offset(px, py), style = Fill)
}
val stroke = Stroke(width = 6.dp.toPx())
val path = Path().apply {
moveTo(cx - radius * 0.5f, cy - radius * 0.1f)
cubicTo(
cx - radius * 0.2f, cy - radius * 0.5f,
cx + radius * 0.2f, cy + radius * 0.4f,
cx + radius * 0.5f, cy - radius * 0.1f,
)
}
drawPath(path, color = Coral.copy(alpha = 0.35f), style = stroke)
val path2 = Path().apply {
moveTo(cx - radius * 0.4f, cy + radius * 0.25f)
cubicTo(
cx - radius * 0.1f, cy + radius * 0.0f,
cx + radius * 0.3f, cy + radius * 0.5f,
cx + radius * 0.5f, cy + radius * 0.15f,
)
}
drawPath(path2, color = Mint.copy(alpha = 0.35f), style = stroke)
}
private enum class EarStyle { Pointy, Triangular, Long }
private fun DrawScope.drawMascotHead(
headColor: Color,
earColor: Color,
innerEarColor: Color,
snoutColor: Color,
bodyScale: Float,
eyeOpen: Float,
earStyle: EarStyle,
mood: MascotMood,
moodPhase: Float,
) {
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.38f
EarStyle.Pointy -> 0.30f
EarStyle.Triangular -> 0.26f
}
val available = size.height * (1f - topReserve)
val headRadius = min(size.width, available) * 0.42f * bodyScale
val headCenter = Offset(cx, cy + size.height * (topReserve / 2f) * 0.4f)
when (earStyle) {
EarStyle.Pointy -> drawFoxEars(headCenter, headRadius, earColor, innerEarColor)
EarStyle.Triangular -> drawCatEars(headCenter, headRadius, earColor, innerEarColor)
EarStyle.Long -> drawRabbitEars(headCenter, headRadius, earColor, innerEarColor)
}
drawCircle(color = headColor, radius = headRadius, center = headCenter)
val snoutTop = headCenter.y + headRadius * 0.05f
val snoutSize = Size(headRadius * 1.05f, headRadius * 0.75f)
drawOval(
color = snoutColor,
topLeft = Offset(headCenter.x - snoutSize.width / 2f, snoutTop),
size = snoutSize,
)
val cheekOffset = headRadius * 0.55f
val cheekRadius = headRadius * 0.13f
drawCircle(
color = Pink.copy(alpha = 0.45f),
radius = cheekRadius,
center = Offset(headCenter.x - cheekOffset, headCenter.y + headRadius * 0.15f),
)
drawCircle(
color = Pink.copy(alpha = 0.45f),
radius = cheekRadius,
center = Offset(headCenter.x + cheekOffset, headCenter.y + headRadius * 0.15f),
)
val eyeOffsetX = headRadius * 0.32f
val eyeCenterY = headCenter.y - headRadius * 0.1f
val eyeWidth = headRadius * 0.18f
if (mood == MascotMood.Sleep) {
// Closed eyes: downward arcs (peaceful sleep).
listOf(-1f, 1f).forEach { side ->
val eyeCx = headCenter.x + side * eyeOffsetX
val arc = Path().apply {
moveTo(eyeCx - eyeWidth, eyeCenterY)
quadraticBezierTo(eyeCx, eyeCenterY + eyeWidth * 0.9f, eyeCx + eyeWidth, eyeCenterY)
}
drawPath(arc, color = Ink, style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round))
}
} else {
val eyeHeight = headRadius * 0.28f * eyeOpen
listOf(-1f, 1f).forEach { side ->
drawOval(
color = Ink,
topLeft = Offset(headCenter.x + side * eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f),
size = Size(eyeWidth, eyeHeight),
)
}
if (eyeOpen > 0.7f) {
val shineRadius = eyeWidth * 0.22f
listOf(-1f, 1f).forEach { side ->
drawCircle(
color = CreamBackground,
radius = shineRadius,
center = Offset(
headCenter.x + side * eyeOffsetX + eyeWidth * 0.18f,
eyeCenterY - eyeHeight * 0.2f,
),
)
}
}
}
if (mood == MascotMood.Tired) {
// Droopy eyebrows: short strokes slanting down toward the outer face.
listOf(-1f, 1f).forEach { side ->
val browInnerX = headCenter.x + side * (eyeOffsetX - eyeWidth * 0.6f)
val browOuterX = headCenter.x + side * (eyeOffsetX + eyeWidth * 0.7f)
val browY = eyeCenterY - headRadius * 0.24f
val brow = Path().apply {
moveTo(browInnerX, browY)
lineTo(browOuterX, browY + headRadius * 0.09f)
}
drawPath(brow, color = Ink, style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round))
}
}
val nose = Path().apply {
val noseTop = headCenter.y + headRadius * 0.15f
val noseWidth = headRadius * 0.16f
moveTo(headCenter.x - noseWidth / 2f, noseTop)
lineTo(headCenter.x + noseWidth / 2f, noseTop)
lineTo(headCenter.x, noseTop + headRadius * 0.12f)
close()
}
drawPath(nose, color = Ink)
// Mouth: smile when idle, small flat/open when tired, tiny relaxed when asleep.
val mouthPath = Path().apply {
val mouthTop = headCenter.y + headRadius * 0.35f
val mouthWidth = when (mood) {
MascotMood.Idle -> headRadius * 0.35f
MascotMood.Tired -> headRadius * 0.22f
MascotMood.Sleep -> headRadius * 0.14f
}
val curve = when (mood) {
MascotMood.Idle -> headRadius * 0.18f
MascotMood.Tired -> headRadius * 0.02f
MascotMood.Sleep -> headRadius * 0.06f
}
moveTo(headCenter.x - mouthWidth, mouthTop)
cubicTo(
headCenter.x - mouthWidth * 0.4f, mouthTop + curve,
headCenter.x + mouthWidth * 0.4f, mouthTop + curve,
headCenter.x + mouthWidth, mouthTop,
)
}
drawPath(mouthPath, color = Ink, style = Stroke(width = 5.dp.toPx()))
when (mood) {
MascotMood.Tired -> drawSweatDrop(headCenter, headRadius, moodPhase)
MascotMood.Sleep -> drawFloatingZs(headCenter, headRadius, moodPhase)
MascotMood.Idle -> Unit
}
}
// PRD §6.4 "sweat": a drop sliding down the temple, fading as it falls (2.4 s loop).
private fun DrawScope.drawSweatDrop(headCenter: Offset, headRadius: Float, phase: Float) {
val dropX = headCenter.x + headRadius * 0.82f
val dropY = headCenter.y - headRadius * 0.45f + headRadius * 0.5f * phase
val alpha = (1f - phase) * 0.9f
val r = headRadius * 0.11f
val color = Mint.copy(alpha = alpha)
val tear = Path().apply {
moveTo(dropX, dropY - r * 1.5f)
cubicTo(dropX + r, dropY - r * 0.3f, dropX + r, dropY + r * 0.6f, dropX, dropY + r * 0.8f)
cubicTo(dropX - r, dropY + r * 0.6f, dropX - r, dropY - r * 0.3f, dropX, dropY - r * 1.5f)
close()
}
drawPath(tear, color = color)
}
// PRD §6.4 "floatZ": Z letters rising from the head and fading (2.6 s loop).
private fun DrawScope.drawFloatingZs(headCenter: Offset, headRadius: Float, phase: Float) {
val baseX = headCenter.x + headRadius * 0.85f
val baseY = headCenter.y - headRadius * 0.9f
// Three Z's at staggered phases so there is always one visible.
for (i in 0 until 3) {
val local = (phase + i / 3f) % 1f
val zSize = headRadius * (0.16f + 0.08f * i)
val x = baseX + headRadius * 0.22f * i + headRadius * 0.1f * local
val y = baseY - headRadius * 0.85f * local
val alpha = ((1f - local) * 0.85f).coerceIn(0f, 1f)
drawZ(Offset(x, y), zSize, Ink.copy(alpha = alpha))
}
}
private fun DrawScope.drawZ(topLeft: Offset, sizePx: Float, color: Color) {
val z = Path().apply {
moveTo(topLeft.x, topLeft.y)
lineTo(topLeft.x + sizePx, topLeft.y)
lineTo(topLeft.x, topLeft.y + sizePx)
lineTo(topLeft.x + sizePx, topLeft.y + sizePx)
}
drawPath(z, color = color, style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round))
}
private fun DrawScope.drawFoxEars(center: Offset, headRadius: Float, color: Color, innerColor: Color) {
val earHeight = headRadius * 0.9f
val earWidth = headRadius * 0.55f
val earBaseY = center.y - headRadius * 0.7f
listOf(-1f, 1f).forEach { side ->
val baseX = center.x + side * headRadius * 0.6f
val outer = Path().apply {
moveTo(baseX - earWidth / 2f, earBaseY)
lineTo(baseX + earWidth / 2f, earBaseY)
lineTo(baseX + side * earWidth * 0.1f, earBaseY - earHeight)
close()
}
drawPath(outer, color = color)
val inner = Path().apply {
moveTo(baseX - earWidth * 0.25f, earBaseY - earHeight * 0.05f)
lineTo(baseX + earWidth * 0.25f, earBaseY - earHeight * 0.05f)
lineTo(baseX + side * earWidth * 0.08f, earBaseY - earHeight * 0.7f)
close()
}
drawPath(inner, color = innerColor)
}
}
private fun DrawScope.drawCatEars(center: Offset, headRadius: Float, color: Color, innerColor: Color) {
val earHeight = headRadius * 0.7f
val earWidth = headRadius * 0.7f
val earBaseY = center.y - headRadius * 0.75f
listOf(-1f, 1f).forEach { side ->
val baseX = center.x + side * headRadius * 0.55f
val outer = Path().apply {
moveTo(baseX - earWidth / 2f, earBaseY + earHeight * 0.15f)
lineTo(baseX + earWidth / 2f, earBaseY + earHeight * 0.15f)
lineTo(baseX + side * earWidth * 0.05f, earBaseY - earHeight)
close()
}
drawPath(outer, color = color)
val inner = Path().apply {
moveTo(baseX - earWidth * 0.2f, earBaseY)
lineTo(baseX + earWidth * 0.2f, earBaseY)
lineTo(baseX + side * earWidth * 0.03f, earBaseY - earHeight * 0.7f)
close()
}
drawPath(inner, color = innerColor)
}
}
private fun DrawScope.drawRabbitEars(center: Offset, headRadius: Float, color: Color, innerColor: Color) {
val earHeight = headRadius * 1.35f
val earWidth = headRadius * 0.42f
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)
drawOval(color = color, topLeft = topLeft, size = Size(earWidth, earHeight))
val innerWidth = earWidth * 0.55f
val innerHeight = earHeight * 0.75f
drawOval(
color = innerColor,
topLeft = Offset(baseX - innerWidth / 2f, earBaseY - earHeight + (earHeight - innerHeight) / 2f),
size = Size(innerWidth, innerHeight),
)
}
}
private fun min(a: Float, b: Float): Float = if (a < b) a else b
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
@Composable
private fun PreviewFoxIdle() {
Mascot(kind = AvatarKind.Fox, modifier = Modifier.size(200.dp))
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
@Composable
private fun PreviewFoxTired() {
Mascot(kind = AvatarKind.Fox, modifier = Modifier.size(200.dp), mood = MascotMood.Tired)
}
@Preview(showBackground = true, backgroundColor = 0xFF2A2350, widthDp = 220, heightDp = 220)
@Composable
private fun PreviewFoxSleep() {
Mascot(kind = AvatarKind.Fox, modifier = Modifier.size(200.dp), mood = MascotMood.Sleep)
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
@Composable
private fun PreviewCatIdle() {
Mascot(kind = AvatarKind.Cat, modifier = Modifier.size(200.dp))
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 260)
@Composable
private fun PreviewRabbitIdle() {
Mascot(kind = AvatarKind.Rabbit, modifier = Modifier.size(200.dp, 240.dp))
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
@Composable
private fun PreviewCustomFrame() {
Mascot(kind = AvatarKind.Custom, modifier = Modifier.size(200.dp))
}
@@ -0,0 +1,46 @@
package mx.paputec.rachita.ui.theme
import androidx.compose.ui.graphics.Color
// Primary brand colors (PRD §6.1) — used by CTAs, mascot, streak flame.
val Coral = Color(0xFFFF8A66)
val CoralIntense = Color(0xFFFF6B3D)
val CoralSoft = Color(0xFFFFE0D5)
// Secondary — habits, cat mascot, gallery CTA.
val Mint = Color(0xFF3FB8AE)
val MintSoft = Color(0xFFD6F3F0)
// Sun / XP / rabbit mascot.
val Sun = Color(0xFFFBC24E)
val SunDeep = Color(0xFFF4B63E)
// Museum, charging notifications.
val Lilac = Color(0xFF8C7CE8)
val LilacSoft = Color(0xFFE7E3FB)
// Success + charging bar.
val SuccessGreen = Color(0xFF46C98B)
val ChargingGreen = Color(0xFF9BE36D)
// Alerts item.
val Pink = Color(0xFFFF6B8A)
// Backgrounds.
val CreamBackground = Color(0xFFFFF6EE)
val CreamCard = Color(0xFFFFE9DF)
val CreamCardSoft = Color(0xFFFFF1E8)
// Text.
val Ink = Color(0xFF43352F)
val WarmGrey = Color(0xFFA4938A)
val WarmGreyDeep = Color(0xFF8A7A72)
// Card border for secondary CTAs.
val CardBorder = Color(0xFFEBDFD7)
// Night palette for charging lock screen (module 3, later step).
val Night = Color(0xFF2A2350)
val NightGradientStart = Color(0xFF39306E)
val NightGradientMid = Color(0xFF5A4E97)
val NightGradientEnd = Color(0xFFB98FC4)
@@ -0,0 +1,16 @@
package mx.paputec.rachita.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
// Radii scale from PRD §6.3: 1424 dp on cards, 999 dp on pills.
val RachitaShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(14.dp),
medium = RoundedCornerShape(18.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp),
)
val PillShape = RoundedCornerShape(999.dp)
@@ -0,0 +1,48 @@
package mx.paputec.rachita.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
// Rachita is landscape, single light theme in v1 — no dark mode yet (out of scope
// per sprint 1). Colors resolved via the palette in Color.kt.
private val LightColors = lightColorScheme(
primary = Coral,
onPrimary = CreamBackground,
primaryContainer = CoralSoft,
onPrimaryContainer = Ink,
secondary = Mint,
onSecondary = CreamBackground,
secondaryContainer = MintSoft,
onSecondaryContainer = Ink,
tertiary = Lilac,
onTertiary = CreamBackground,
tertiaryContainer = LilacSoft,
onTertiaryContainer = Ink,
background = CreamBackground,
onBackground = Ink,
surface = CreamBackground,
onSurface = Ink,
surfaceVariant = CreamCardSoft,
onSurfaceVariant = WarmGreyDeep,
outline = CardBorder,
outlineVariant = CardBorder,
error = CoralIntense,
onError = CreamBackground,
)
@Composable
fun RachitaTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = LightColors,
typography = RachitaTypography,
shapes = RachitaShapes,
content = content,
)
}
@@ -0,0 +1,58 @@
package mx.paputec.rachita.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.Font
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.unit.sp
import mx.paputec.rachita.R
// Downloadable Fonts via Google Fonts provider (androidx.compose.ui:ui-text-google-fonts).
// The provider certs live in res/values/font_certs.xml. Baloo 2 for display / headings,
// Nunito for body copy — matches PRD §6.2.
private val GoogleFontsProvider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs,
)
private val Baloo2 = FontFamily(
Font(googleFont = GoogleFont("Baloo 2"), fontProvider = GoogleFontsProvider, weight = FontWeight.Medium),
Font(googleFont = GoogleFont("Baloo 2"), fontProvider = GoogleFontsProvider, weight = FontWeight.SemiBold),
Font(googleFont = GoogleFont("Baloo 2"), fontProvider = GoogleFontsProvider, weight = FontWeight.Bold),
Font(googleFont = GoogleFont("Baloo 2"), fontProvider = GoogleFontsProvider, weight = FontWeight.ExtraBold),
)
private val Nunito = FontFamily(
Font(googleFont = GoogleFont("Nunito"), fontProvider = GoogleFontsProvider, weight = FontWeight.Normal),
Font(googleFont = GoogleFont("Nunito"), fontProvider = GoogleFontsProvider, weight = FontWeight.Medium),
Font(googleFont = GoogleFont("Nunito"), fontProvider = GoogleFontsProvider, weight = FontWeight.SemiBold),
Font(googleFont = GoogleFont("Nunito"), fontProvider = GoogleFontsProvider, weight = FontWeight.Bold),
Font(googleFont = GoogleFont("Nunito"), fontProvider = GoogleFontsProvider, weight = FontWeight.ExtraBold),
)
// Type scale from PRD §6.2: display/title 2634 sp, streak numbers 4292 sp,
// habit names ~23 sp, body 1316 sp, captions 1112 sp.
val RachitaTypography = Typography(
displayLarge = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.ExtraBold, fontSize = 92.sp, color = Ink),
displayMedium = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.ExtraBold, fontSize = 64.sp, color = Ink),
displaySmall = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.Bold, fontSize = 42.sp, color = Ink),
headlineLarge = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.Bold, fontSize = 34.sp, color = Ink),
headlineMedium = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.Bold, fontSize = 30.sp, color = Ink),
headlineSmall = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.Bold, fontSize = 26.sp, color = Ink),
titleLarge = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.SemiBold, fontSize = 23.sp, color = Ink),
titleMedium = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.SemiBold, fontSize = 20.sp, color = Ink),
titleSmall = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, color = Ink),
bodyLarge = TextStyle(fontFamily = Nunito, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, color = Ink),
bodyMedium = TextStyle(fontFamily = Nunito, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = Ink),
bodySmall = TextStyle(fontFamily = Nunito, fontWeight = FontWeight.Medium, fontSize = 13.sp, color = WarmGreyDeep),
labelLarge = TextStyle(fontFamily = Baloo2, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = Ink),
labelMedium = TextStyle(fontFamily = Nunito, fontWeight = FontWeight.Bold, fontSize = 12.sp, color = Ink),
labelSmall = TextStyle(fontFamily = Nunito, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, color = WarmGreyDeep),
)
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Placeholder foreground: a stylized flame in cream, centered on the 108×108
adaptive-icon canvas. Sized inside the 66×66 safe zone.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFF6EE"
android:pathData="M54,26
C 58,34 68,42 68,54
C 68,64 62,72 54,72
C 46,72 40,64 40,54
C 40,48 44,44 46,42
C 46,48 50,52 54,48
C 54,42 50,36 54,26 Z" />
<path
android:fillColor="#FFB79C"
android:pathData="M54,44
C 56,48 62,52 62,58
C 62,64 58,68 54,68
C 50,68 46,64 46,58
C 46,55 48,52 50,52
C 50,54 52,56 54,54
C 54,50 52,48 54,44 Z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The `values` bucket is already Spanish MX (see §6.3 of CLAUDE.md).
This locale-specific bucket exists so that new locales (English, Portuguese)
can be added later by mirroring this file — until then it inherits values/.
-->
<resources />
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Only colors that leave the Compose theme (launcher window + adaptive icon
background) live here. Every runtime color lives in ui/theme/Color.kt. -->
<color name="cream_background">#FFF6EE</color>
<color name="ic_launcher_background">#FF8A66</color>
</resources>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Certificates for the Google Play Services Fonts provider (Downloadable Fonts).
Referenced by ui/theme/Type.kt. See:
https://developer.android.com/develop/ui/views/text-and-emoji/downloadable-fonts
-->
<resources>
<string-array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>
MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP4DHioyXtB0Le58Xnbf3QOxpO2u6EIXhBGgFdEHVJIuoIMBHDbmyTa+yTsUt+xjd85XjeLoWtfNs2X0GADnu9j0oiXCMoTNwUR/j+v/L4nRnV7FdA0dHfKY6cO8oNmyu6VtLmRYxrRJIVXjKtYwHwf83fJt5rLnnLnDS1UdvdlHDPQxG3UjhOCrA/K3XWzS0tYyxpFAeu/Xj/A8bhqQ1cWLGnSnMOL/QJT0dpJmZupJmVZ8LSp3S+K+5rTA+cP7ry8+n5PJhqSmzQKuXhFWQ/DIjD4fFtEHTPzhTpiT2wA7fWrPZKzTKzYSjJqhoMHQIBA6NCMEAwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMB8GA1UdIwQYMBaAFI0cxb6VTEM8YYY6FbBMvAPyT+CyMA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPizot4hunt8w20DZ5ge4gFH4B4+dqxs8vpZBc/x4nkbFYWvT4omJk7kwr1o+ZC1w2/2sk3AAKz6ax5CJQ+lTdNMEChXNEBNsRDcNC3aYVfV/wR/nHZKIiE+BvblkE
</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>
MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZDBppjsbjqQBiu2FGewGmY9uYRSqE7EJ0lyJl9Y6Fi9lS9BJgWFXfx7WCPBOpJVAaSKA/j0KMd8h3ptx4LFq5FLONbfeJmpx55SFriM90DHFo6NR3xnkQ1Kfej0Nq2LR2Uj4SRQ7BvSuxymH0PGSl7Xf/vRgmwHRy5cHDdyCzL3IY4dPFvIddQiZmyoBg37aOJTUsxYkGZTPSk0z0GIrHwWfhCLp4WemBl2VC+ldECAwEAAaOB1jCB0zAdBgNVHQ4EFgQUhtaWZDCnw6DGnQDXcMlwjNoIC/QwgaMGA1UdIwSBmzCBmIAUhtaWZDCnw6DGnQDXcMlwjNoIC/ShIKR+MHwwCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZIIJAMLgh0ZkSjCNMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBAJ5hihuGDrK9AqLxdlUzZLLqSGjkyJXVKQxq2yQGZLoZLD1MvOG0PBnT78I+YKA1FLyWiKKtN0FUB6O/tX8YWZAvR2W1E+PWyxfP+z2vLd+eb7lLpXfxvYyeAWSAr0lVNBrhcemF0Vp1oe2Ge1wPX2fmH6MavR3lYbc46j5s07pgvBftUnpTz0LC8h+RksSbUmB+jsdxiBRxa2XhP0Ol5lZP+FA57vZBmoO2q/nn8H1nR3g89iZTC5UbLDNUxOFR6vGZoK4pE1WhI62pyKG9YcSVy4CkkFmC/Otx9mvv9CN31zxHzcOfy00LtQ9DrNCXR8Cn/VPuKB
</item>
</string-array>
<!-- Referenced by AndroidManifest.xml; empty because we ask the OS to fetch
each font family lazily instead of preloading it at install time. -->
<array name="preloaded_fonts" />
</resources>
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Rachita</string>
<!-- Onboarding / avatar selection (module 1). Spanish MX. -->
<string name="onboarding_title">Elige o dibuja tu compañero</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>
<string name="onboarding_avatar_cat">Gata Menta</string>
<string name="onboarding_avatar_rabbit">Conejo Sol</string>
<string name="onboarding_avatar_custom">Mi dibujo</string>
<string name="onboarding_avatar_custom_hint">Toma foto o abre galería</string>
<string name="onboarding_change_drawing">Cambiar dibujo</string>
<string name="onboarding_child_name_label">Tu nombre</string>
<string name="onboarding_child_name_default">Lucía</string>
<!-- Content descriptions for accessibility -->
<string name="cd_avatar_fox">Ilustración del zorro Rachi</string>
<string name="cd_avatar_cat">Ilustración de la gata Menta</string>
<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_day_singular">día</string>
<string name="dashboard_streak_sidebar">%1$d días de racha</string>
<string name="dashboard_streak_sidebar_singular">1 día 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>
<!-- Battery module (module 3). Copy exact from prototype (PRD §5.3). -->
<string name="battery_low_title">¡Me estoy quedando sin energía!</string>
<string name="battery_low_body">Solo me queda %1$d%% de batería. ¿Me conectas al cargador para seguir entrenando contigo?</string>
<string name="battery_low_later">Ahora no</string>
<string name="battery_low_connect">Conectar cargador</string>
<string name="charging_title">Entrenando en mi cápsula de energía…</string>
<string name="charging_subtitle">Guarda la tablet mientras se carga. Cuando despierte, ¡estaremos a tope de batería para jugar y dibujar!</string>
<string name="charging_progress">Cargando · %1$d%%</string>
<string name="charging_unplug_button">Ya la desconecté</string>
<string name="charging_still_plugged">Mmm… sigo conectada al cargador 🔌</string>
<!-- CA-05 (P1): wake copy not present in prototype; proposed here. -->
<string name="charging_full_title">¡Desperté a tope de energía!</string>
<string name="charging_full_subtitle">Ya puedes desconectarme. ¡Vamos a jugar y dibujar!</string>
<string name="charging_debug_exit">salir (debug)</string>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose owns the visual theme via ui.theme.RachitaTheme.
This style only bootstraps the window: crema background and a fully
transparent status bar so Compose can draw edge-to-edge. -->
<style name="Theme.Rachita" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/cream_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Rachita only stores the child's local profile in DataStore during sprint 1.
Nothing to include or exclude explicitly yet. -->
</full-backup-content>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup />
<device-transfer />
</data-extraction-rules>
@@ -0,0 +1,53 @@
package mx.paputec.rachita.domain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LowBatteryPolicyTest {
@Test
fun `no band above twenty percent`() {
assertEquals(null, LowBatteryPolicy.bandFor(21))
assertEquals(null, LowBatteryPolicy.bandFor(100))
}
@Test
fun `bands map to the tightest threshold`() {
assertEquals(20, LowBatteryPolicy.bandFor(20))
assertEquals(20, LowBatteryPolicy.bandFor(18))
assertEquals(15, LowBatteryPolicy.bandFor(15))
assertEquals(15, LowBatteryPolicy.bandFor(12))
assertEquals(10, LowBatteryPolicy.bandFor(10))
assertEquals(10, LowBatteryPolicy.bandFor(7))
assertEquals(5, LowBatteryPolicy.bandFor(5))
assertEquals(5, LowBatteryPolicy.bandFor(1))
}
@Test
fun `overlay shows on first entry into a band`() {
assertTrue(LowBatteryPolicy.shouldShowOverlay(percent = 18, isCharging = false, dismissedBand = null))
}
@Test
fun `overlay hides after dismissal within the same band`() {
assertFalse(LowBatteryPolicy.shouldShowOverlay(percent = 17, isCharging = false, dismissedBand = 20))
}
@Test
fun `overlay reappears when falling into a deeper band`() {
assertTrue(LowBatteryPolicy.shouldShowOverlay(percent = 14, isCharging = false, dismissedBand = 20))
assertTrue(LowBatteryPolicy.shouldShowOverlay(percent = 4, isCharging = false, dismissedBand = 10))
}
@Test
fun `overlay never shows while charging`() {
assertFalse(LowBatteryPolicy.shouldShowOverlay(percent = 8, isCharging = true, dismissedBand = null))
}
@Test
fun `overlay never shows above the alert range`() {
assertFalse(LowBatteryPolicy.shouldShowOverlay(percent = 55, isCharging = false, dismissedBand = null))
}
}
@@ -0,0 +1,55 @@
package mx.paputec.rachita.domain
import java.time.LocalDate
import mx.paputec.rachita.domain.model.HabitLog
import org.junit.Assert.assertEquals
import org.junit.Test
class StreakCalculatorTest {
private val habits = setOf("banarse", "dormir", "escuela")
private val today: LocalDate = LocalDate.of(2026, 7, 3)
private fun securedDay(date: LocalDate): List<HabitLog> = habits.map { HabitLog(it, date) }
@Test
fun `day counts as secured only when every active habit is done`() {
val logs = securedDay(today) + listOf(HabitLog("banarse", today.minusDays(1)))
val secured = StreakCalculator.securedDates(logs, habits)
assertEquals(setOf(today), secured)
}
@Test
fun `streak counts consecutive secured days including today`() {
val logs = securedDay(today) + securedDay(today.minusDays(1)) + securedDay(today.minusDays(2))
val secured = StreakCalculator.securedDates(logs, habits)
assertEquals(3, StreakCalculator.currentStreak(secured, today))
}
@Test
fun `streak survives while today is still in progress`() {
val logs = securedDay(today.minusDays(1)) + securedDay(today.minusDays(2))
val secured = StreakCalculator.securedDates(logs, habits)
assertEquals(2, StreakCalculator.currentStreak(secured, today))
}
@Test
fun `a missed full day resets the streak to zero`() {
val logs = securedDay(today.minusDays(2)) + securedDay(today.minusDays(3))
val secured = StreakCalculator.securedDates(logs, habits)
assertEquals(0, StreakCalculator.currentStreak(secured, today))
}
@Test
fun `empty history yields zero streak`() {
assertEquals(0, StreakCalculator.currentStreak(emptySet(), today))
}
@Test
fun `unchecking a habit today rolls the streak back`() {
val fullYesterday = securedDay(today.minusDays(1))
val partialToday = listOf(HabitLog("banarse", today), HabitLog("dormir", today))
val secured = StreakCalculator.securedDates(fullYesterday + partialToday, habits)
assertEquals(1, StreakCalculator.currentStreak(secured, today))
}
}
+9
View File
@@ -0,0 +1,9 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}
+9
View File
@@ -0,0 +1,9 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseParallelGC
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+92
View File
@@ -0,0 +1,92 @@
[versions]
# Kotlin / Android toolchain
agp = "8.7.3"
kotlin = "2.0.21"
ksp = "2.0.21-1.0.28"
# AndroidX core
core-ktx = "1.15.0"
lifecycle = "2.8.7"
activity-compose = "1.9.3"
# Jetpack Compose
compose-bom = "2024.12.01"
compose-navigation = "2.8.5"
# Hilt
hilt = "2.52"
hilt-navigation-compose = "1.2.0"
# Persistence
room = "2.6.1"
datastore = "1.1.1"
# Networking
retrofit = "2.11.0"
okhttp = "4.12.0"
kotlinx-serialization = "1.7.3"
retrofit-kotlinx-serialization-converter = "1.0.0"
# Concurrency
coroutines = "1.9.0"
# Testing
junit = "4.13.2"
junit-ext = "1.2.1"
espresso = "3.6.1"
[libraries]
# AndroidX core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
# Jetpack Compose (BOM aligns versions)
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "compose-navigation" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
# Persistence
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# Networking
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofit-kotlinx-serialization-converter" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
# Concurrency
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
# Testing
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit-ext" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+24
View File
@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Rachita"
include(":app")