Compare commits
16 Commits
8d583f6e7e
...
3dda825490
| 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
|
||||
app/.gradle/
|
||||
app/.kotlin/
|
||||
app/local.properties
|
||||
app/**/build/
|
||||
app/.idea/
|
||||
app/*.iml
|
||||
app/**/*.iml
|
||||
app/captures/
|
||||
app/**/.cxx/
|
||||
|
||||
# .NET
|
||||
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