Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dda825490 | |||
| 0fc8f39a79 | |||
| 9bfe43b704 | |||
| 25154af616 | |||
| 5ca619b584 | |||
| 1243f6c3b7 | |||
| 0eb4133345 | |||
| 92285bbff2 | |||
| 1d34b78273 | |||
| 33de656931 | |||
| 28f2359dda | |||
| a947c174ee | |||
| 3f7602efb0 | |||
| ae8f6b7a65 | |||
| 1b5a1f76e1 | |||
| 6f5bca6e35 |
+3
-1
@@ -3,11 +3,13 @@ _extracted/
|
|||||||
|
|
||||||
# Android
|
# Android
|
||||||
app/.gradle/
|
app/.gradle/
|
||||||
|
app/.kotlin/
|
||||||
app/local.properties
|
app/local.properties
|
||||||
app/**/build/
|
app/**/build/
|
||||||
app/.idea/
|
app/.idea/
|
||||||
app/*.iml
|
app/**/*.iml
|
||||||
app/captures/
|
app/captures/
|
||||||
|
app/**/.cxx/
|
||||||
|
|
||||||
# .NET
|
# .NET
|
||||||
backend/**/bin/
|
backend/**/bin/
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder ProGuard/R8 rules. Populated when release minification is enabled.
|
||||||
@@ -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.2–3 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.1–2.1 s, fired once per celebration.
|
||||||
|
// `trigger` is the celebration counter; 0 means "never celebrated".
|
||||||
|
private const val PIECE_COUNT = 26
|
||||||
|
private val PALETTE = listOf(Coral, Mint, Sun, Lilac, Pink)
|
||||||
|
|
||||||
|
private data class ConfettiPiece(
|
||||||
|
val xFraction: Float,
|
||||||
|
val color: Color,
|
||||||
|
val width: Float,
|
||||||
|
val height: Float,
|
||||||
|
val durationMs: Int,
|
||||||
|
val delayMs: Int,
|
||||||
|
val rotationSeed: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConfettiOverlay(
|
||||||
|
trigger: Int,
|
||||||
|
enabled: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var pieces by remember { mutableStateOf<List<ConfettiPiece>>(emptyList()) }
|
||||||
|
val progress = remember { Animatable(0f) }
|
||||||
|
|
||||||
|
LaunchedEffect(trigger) {
|
||||||
|
if (trigger <= 0 || !enabled) return@LaunchedEffect
|
||||||
|
// Seeded by trigger: deterministic per celebration, varied across them.
|
||||||
|
val random = Random(trigger)
|
||||||
|
pieces = List(PIECE_COUNT) {
|
||||||
|
ConfettiPiece(
|
||||||
|
xFraction = random.nextFloat(),
|
||||||
|
color = PALETTE[random.nextInt(PALETTE.size)],
|
||||||
|
width = 8f + random.nextFloat() * 8f,
|
||||||
|
height = 12f + random.nextFloat() * 10f,
|
||||||
|
durationMs = 1100 + random.nextInt(1000),
|
||||||
|
delayMs = random.nextInt(350),
|
||||||
|
rotationSeed = random.nextFloat() * 360f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
progress.snapTo(0f)
|
||||||
|
launch {
|
||||||
|
progress.animateTo(1f, animationSpec = tween(durationMillis = 2450, easing = LinearEasing))
|
||||||
|
pieces = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pieces.isEmpty()) return
|
||||||
|
|
||||||
|
Canvas(modifier = modifier.fillMaxSize()) {
|
||||||
|
val t = progress.value
|
||||||
|
pieces.forEach { piece ->
|
||||||
|
val total = piece.delayMs + piece.durationMs
|
||||||
|
val local = ((t * 2450f) - piece.delayMs) / piece.durationMs
|
||||||
|
if (local <= 0f || local > 1f || total <= 0) return@forEach
|
||||||
|
val x = piece.xFraction * size.width
|
||||||
|
val y = local * (size.height + 60.dp.toPx()) - 30.dp.toPx()
|
||||||
|
rotate(degrees = piece.rotationSeed + local * 540f, pivot = Offset(x, y)) {
|
||||||
|
drawRect(
|
||||||
|
color = piece.color,
|
||||||
|
topLeft = Offset(x - piece.width / 2f, y - piece.height / 2f),
|
||||||
|
size = Size(piece.width, piece.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+434
@@ -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: 14–24 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 26–34 sp, streak numbers 42–92 sp,
|
||||||
|
// habit names ~23 sp, body 13–16 sp, captions 11–12 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 />
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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" }
|
||||||
BIN
Binary file not shown.
@@ -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
|
||||||
+248
@@ -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" "$@"
|
||||||
Vendored
+82
@@ -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%
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user