feat(onboarding): selección de compañero con mascotas Canvas y DataStore

- OnboardingScreen (1280×800 landscape): grid de 4 tarjetas, CTA píldora Continuar
- Mascotas Zorro Rachi / Gata Menta / Conejo Sol dibujadas con Canvas
  (breathe 3.2s + blink 5s del PRD §6.4), marco punteado para 'Mi dibujo'
- 'Mi dibujo' abre Photo Picker (sin permisos de almacenamiento)
- MVVM: OnboardingViewModel con StateFlow<OnboardingUiState>
- Persistencia en DataStore Preferences vía ProfileRepository (Hilt)
- NavHost onboarding→dashboard (placeholder módulo 2) + STATUS.md
This commit is contained in:
Johann
2026-07-03 21:26:24 -06:00
parent 1b5a1f76e1
commit ae8f6b7a65
12 changed files with 1034 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# STATUS — Rachita
> Estado vivo del desarrollo. Actualizar al cerrar cada hito.
**Última actualización:** 2026-07-03
## Sprint 1 (en curso)
| Módulo | Estado |
|---|---|
| 1. Onboarding / Selección de avatar | ✅ Implementado (Compose + MVVM + DataStore) |
| 2. Dashboard de hábitos + racha | ⬜ Pendiente (placeholder en NavHost) |
| 3. Monitoreo de batería (overlay + bloqueo carga) | ⬜ Pendiente |
## Infraestructura local
- Proyecto Android en `app/` — Gradle 8.9 (wrapper), AGP 8.7.3, Kotlin 2.0.21, JDK 17.
- `compileSdk 35` / `targetSdk 34` / `minSdk 26` (androidx.core 1.15 exige compilar contra 35).
- SDK local vía Homebrew: `/opt/homebrew/share/android-commandlinetools` (`local.properties` no versionado).
- Build verificado con `JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug`.
## Decisiones tomadas en este sprint
- Fuentes Baloo 2 / Nunito por Downloadable Fonts (Google Fonts provider), sin assets empaquetados.
- Mascotas dibujadas 100 % con Canvas de Compose (`MascotIllustrations.kt`); estados tired/sleep se agregarán con el módulo de batería.
- Avatar "Mi dibujo" usa Photo Picker (`PickVisualMedia`) — sin permisos de almacenamiento.
## Próximos pasos
1. Módulo 2: Dashboard (tarjetas de hábitos, check 74 px, racha, confeti) con Room.
2. Módulo 3: BatteryManager receiver + overlay <20 % + Activity de bloqueo en carga.
3. Backend .NET 10 (sprint 2+, ver CLAUDE.md §4).
@@ -0,0 +1,81 @@
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.background
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.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
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().background(CreamBackground),
color = CreamBackground,
) {
RachitaNavHost()
}
}
}
}
}
private object Routes {
const val ONBOARDING = "onboarding"
const val DASHBOARD = "dashboard"
}
@Composable
private fun RachitaNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Routes.ONBOARDING) {
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) {
// Dashboard placeholder — implemented in the next sub-task of sprint 1.
DashboardPlaceholder()
}
}
}
@Composable
private fun DashboardPlaceholder() {
// Intentionally empty; will be replaced when module 2 (dashboard) is wired up.
Surface(
modifier = Modifier.fillMaxSize(),
color = CreamBackground,
) {}
}
@@ -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,21 @@
package mx.paputec.rachita.di
import android.content.Context
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.preferences.ProfileRepositoryImpl
import mx.paputec.rachita.domain.repository.ProfileRepository
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideProfileRepository(@ApplicationContext context: Context): ProfileRepository =
ProfileRepositoryImpl(context)
}
@@ -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,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,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,199 @@
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.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()
Box(
modifier = Modifier
.fillMaxSize()
.background(CreamBackground)
.padding(insets),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OnboardingHeader(childName = uiState.childName)
LazyVerticalGrid(
columns = GridCells.Fixed(4),
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
items(items = AvatarOptions, key = { it.kind }) { option ->
AvatarCard(
kind = option.kind,
label = stringResource(id = option.labelRes),
hint = option.hintRes?.let { stringResource(id = it) },
isSelected = uiState.selectedAvatar == option.kind,
onClick = {
if (option.kind == AvatarKind.Custom) {
imagePicker.launch(pickerRequest)
} else {
onAvatarSelected(option.kind)
}
},
modifier = Modifier.fillMaxWidth(),
)
}
}
ContinueButton(
enabled = uiState.canContinue && !uiState.isSaving,
onClick = onContinue,
)
}
}
}
@Composable
private fun OnboardingHeader(childName: String) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.onboarding_title),
style = MaterialTheme.typography.headlineLarge.copy(color = Ink),
textAlign = TextAlign.Center,
)
Text(
text = stringResource(id = R.string.onboarding_subtitle),
style = MaterialTheme.typography.bodyLarge.copy(color = WarmGreyDeep),
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp),
)
}
}
@Composable
private fun ContinueButton(enabled: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Button(
onClick = onClick,
enabled = enabled,
shape = RoundedCornerShape(999.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Coral,
contentColor = CreamBackground,
disabledContainerColor = Coral.copy(alpha = 0.35f),
disabledContentColor = CreamBackground.copy(alpha = 0.75f),
),
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 18.dp),
modifier = Modifier.height(72.dp),
) {
Text(
text = stringResource(id = R.string.onboarding_continue),
style = MaterialTheme.typography.titleLarge.copy(color = CreamBackground),
)
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 1280, heightDp = 800)
@Composable
private fun PreviewOnboardingUnselected() {
RachitaTheme {
OnboardingScreen(
uiState = OnboardingUiState(),
onAvatarSelected = {},
onNameChange = {},
onCustomAvatarPicked = {},
onContinue = {},
)
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 1280, heightDp = 800)
@Composable
private fun PreviewOnboardingSelectedRabbit() {
RachitaTheme {
OnboardingScreen(
uiState = OnboardingUiState(selectedAvatar = AvatarKind.Rabbit),
onAvatarSelected = {},
onNameChange = {},
onCustomAvatarPicked = {},
onContinue = {},
)
}
}
@@ -0,0 +1,15 @@
package mx.paputec.rachita.ui.onboarding
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.model.Profile
data class OnboardingUiState(
val childName: String = Profile.DEFAULT_CHILD_NAME,
val selectedAvatar: AvatarKind? = null,
val customAvatarUri: String? = null,
val isSaving: Boolean = false,
) {
val canContinue: Boolean
get() = selectedAvatar != null &&
(selectedAvatar != AvatarKind.Custom || !customAvatarUri.isNullOrBlank())
}
@@ -0,0 +1,69 @@
package mx.paputec.rachita.ui.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.repository.ProfileRepository
@HiltViewModel
class OnboardingViewModel @Inject constructor(
private val profileRepository: ProfileRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(OnboardingUiState())
val uiState: StateFlow<OnboardingUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
val stored = profileRepository.profile.first()
_uiState.update {
it.copy(
childName = stored.childName,
selectedAvatar = stored.avatar,
customAvatarUri = stored.customAvatarUri,
)
}
}
}
fun selectAvatar(kind: AvatarKind) {
_uiState.update { current ->
current.copy(
selectedAvatar = kind,
customAvatarUri = if (kind == AvatarKind.Custom) current.customAvatarUri else null,
)
}
}
fun setCustomAvatar(uri: String) {
_uiState.update { it.copy(selectedAvatar = AvatarKind.Custom, customAvatarUri = uri) }
}
fun updateName(name: String) {
_uiState.update { it.copy(childName = name) }
}
fun confirm() {
val state = _uiState.value
val chosen = state.selectedAvatar ?: return
_uiState.update { it.copy(isSaving = true) }
viewModelScope.launch {
profileRepository.setChildName(state.childName)
if (chosen == AvatarKind.Custom && !state.customAvatarUri.isNullOrBlank()) {
profileRepository.setCustomAvatar(state.customAvatarUri)
} else {
profileRepository.setAvatar(chosen)
}
profileRepository.markOnboardingCompleted()
_uiState.update { it.copy(isSaving = false) }
}
}
}
@@ -0,0 +1,143 @@
package mx.paputec.rachita.ui.onboarding.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import mx.paputec.rachita.R
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.ui.theme.CardBorder
import mx.paputec.rachita.ui.theme.Coral
import mx.paputec.rachita.ui.theme.CreamCard
import mx.paputec.rachita.ui.theme.CreamCardSoft
import mx.paputec.rachita.ui.theme.RachitaTheme
import mx.paputec.rachita.ui.theme.SuccessGreen
@Composable
fun AvatarCard(
kind: AvatarKind,
label: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
hint: String? = null,
) {
val borderColor = if (isSelected) Coral else CardBorder
val borderWidth by animateDpAsState(
targetValue = if (isSelected) 3.dp else 2.dp,
animationSpec = spring(stiffness = 300f),
label = "avatar-card-border",
)
val backgroundColor = if (isSelected) CreamCard else CreamCardSoft
Box(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.border(width = borderWidth, color = borderColor, shape = RoundedCornerShape(24.dp))
.clickable(onClick = onClick)
.padding(16.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = Alignment.Center,
) {
Mascot(kind = kind, modifier = Modifier.fillMaxSize())
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = label,
style = MaterialTheme.typography.titleLarge,
)
if (hint != null) {
Text(
text = hint,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
AnimatedVisibility(
visible = isSelected,
enter = scaleIn(spring(stiffness = 350f)) + fadeIn(),
exit = scaleOut() + fadeOut(),
modifier = Modifier
.align(Alignment.TopEnd),
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(SuccessGreen)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
text = stringResource(id = R.string.onboarding_selected_badge),
style = MaterialTheme.typography.labelMedium.copy(color = CreamCard),
)
}
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 260, heightDp = 320)
@Composable
private fun PreviewAvatarCardUnselected() {
RachitaTheme {
AvatarCard(
kind = AvatarKind.Fox,
label = "Zorro Rachi",
isSelected = false,
onClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 260, heightDp = 320)
@Composable
private fun PreviewAvatarCardSelected() {
RachitaTheme {
AvatarCard(
kind = AvatarKind.Cat,
label = "Gata Menta",
isSelected = true,
onClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -0,0 +1,369 @@
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.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
// A mascot head drawn with Compose Canvas (PRD §5.1: idle state = blink every ~5 s
// + subtle breathing). No bitmap assets — everything is vector so we can retint
// the same drawings later for the tired / sleep states.
@Composable
fun Mascot(
kind: AvatarKind,
modifier: Modifier = Modifier,
) {
val transition = rememberInfiniteTransition(label = "mascot-$kind")
val breathe by transition.animateFloat(
initialValue = 1f,
targetValue = 1.03f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 3200, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "breathe",
)
// Blink cycles every ~5 s and lasts ~180 ms of closed eye.
val blink by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 5000, easing = LinearEasing),
),
label = "blink",
)
val eyeOpen = if (blink > 0.96f) 0.2f else 1f
Canvas(modifier = modifier) {
val bodyScale = breathe
when (kind) {
AvatarKind.Fox -> drawFox(bodyScale, eyeOpen)
AvatarKind.Cat -> drawCat(bodyScale, eyeOpen)
AvatarKind.Rabbit -> drawRabbit(bodyScale, eyeOpen)
AvatarKind.Custom -> drawCustomFrame()
}
}
}
private fun DrawScope.drawFox(bodyScale: Float, eyeOpen: Float) {
drawMascotHead(
headColor = Coral,
earColor = Coral,
innerEarColor = CreamBackground,
snoutColor = CreamBackground,
bodyScale = bodyScale,
eyeOpen = eyeOpen,
earStyle = EarStyle.Pointy,
)
}
private fun DrawScope.drawCat(bodyScale: Float, eyeOpen: Float) {
drawMascotHead(
headColor = Mint,
earColor = Mint,
innerEarColor = Pink.copy(alpha = 0.5f),
snoutColor = CreamBackground,
bodyScale = bodyScale,
eyeOpen = eyeOpen,
earStyle = EarStyle.Triangular,
)
}
private fun DrawScope.drawRabbit(bodyScale: Float, eyeOpen: Float) {
drawMascotHead(
headColor = SunDeep,
earColor = SunDeep,
innerEarColor = Pink.copy(alpha = 0.4f),
snoutColor = CreamBackground,
bodyScale = bodyScale,
eyeOpen = eyeOpen,
earStyle = EarStyle.Long,
)
}
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 using a series of short arcs approximated with strokes on
// small circles arranged around the perimeter — reads as "your drawing goes
// here" when no image is picked yet.
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,
)
}
// Interior soft crayon lines to suggest scribbles.
val scribbleColor = Coral.copy(alpha = 0.35f)
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 = scribbleColor, 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,
) {
val cx = size.width / 2f
val cy = size.height / 2f
// Reserve headroom for ears so tall shapes (rabbit) still fit inside the canvas.
val topReserve = when (earStyle) {
EarStyle.Long -> 0.32f
EarStyle.Pointy -> 0.16f
EarStyle.Triangular -> 0.14f
}
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)
}
// Main head circle.
drawCircle(color = headColor, radius = headRadius, center = headCenter)
// Snout patch — soft rounded shape below eyes.
val snoutTop = headCenter.y + headRadius * 0.05f
val snoutSize = Size(headRadius * 1.05f, headRadius * 0.75f)
val snoutTopLeft = Offset(headCenter.x - snoutSize.width / 2f, snoutTop)
drawOval(color = snoutColor, topLeft = snoutTopLeft, size = snoutSize)
// Cheeks — pink blush on the sides.
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),
)
// Eyes — squished vertically by eyeOpen (blink).
val eyeOffsetX = headRadius * 0.32f
val eyeCenterY = headCenter.y - headRadius * 0.1f
val eyeWidth = headRadius * 0.18f
val eyeHeight = headRadius * 0.28f * eyeOpen
drawOval(
color = Ink,
topLeft = Offset(headCenter.x - eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f),
size = Size(eyeWidth, eyeHeight),
)
drawOval(
color = Ink,
topLeft = Offset(headCenter.x + eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f),
size = Size(eyeWidth, eyeHeight),
)
// Eye shine — small white dot in the top-right of each eye when the eye is open.
if (eyeOpen > 0.7f) {
val shineRadius = eyeWidth * 0.22f
drawCircle(
color = CreamBackground,
radius = shineRadius,
center = Offset(headCenter.x - eyeOffsetX + eyeWidth * 0.18f, eyeCenterY - eyeHeight * 0.2f),
)
drawCircle(
color = CreamBackground,
radius = shineRadius,
center = Offset(headCenter.x + eyeOffsetX + eyeWidth * 0.18f, eyeCenterY - eyeHeight * 0.2f),
)
}
// Nose triangle.
val nose = Path().apply {
val noseCx = headCenter.x
val noseTop = headCenter.y + headRadius * 0.15f
val noseWidth = headRadius * 0.16f
val noseHeight = headRadius * 0.12f
moveTo(noseCx - noseWidth / 2f, noseTop)
lineTo(noseCx + noseWidth / 2f, noseTop)
lineTo(noseCx, noseTop + noseHeight)
close()
}
drawPath(nose, color = Ink)
// Smile: two small arcs joined below the nose.
val mouthPath = Path().apply {
val mouthTop = headCenter.y + headRadius * 0.35f
val mouthWidth = headRadius * 0.35f
moveTo(headCenter.x - mouthWidth, mouthTop)
cubicTo(
headCenter.x - mouthWidth * 0.4f, mouthTop + headRadius * 0.18f,
headCenter.x + mouthWidth * 0.4f, mouthTop + headRadius * 0.18f,
headCenter.x + mouthWidth, mouthTop,
)
}
drawPath(mouthPath, color = Ink, style = Stroke(width = 5.dp.toPx()))
}
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.6f
val earWidth = headRadius * 0.42f
val earBaseY = center.y - headRadius * 0.85f
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
val innerTopLeft = Offset(
baseX - innerWidth / 2f,
earBaseY - earHeight + (earHeight - innerHeight) / 2f,
)
drawOval(color = innerColor, topLeft = innerTopLeft, 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 PreviewFox() {
Mascot(kind = AvatarKind.Fox, modifier = Modifier.size(200.dp))
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
@Composable
private fun PreviewCat() {
Mascot(kind = AvatarKind.Cat, modifier = Modifier.size(200.dp))
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 260)
@Composable
private fun PreviewRabbit() {
Mascot(kind = AvatarKind.Rabbit, modifier = Modifier.size(200.dp, 240.dp))
}
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
@Composable
private fun PreviewCustom() {
Mascot(kind = AvatarKind.Custom, modifier = Modifier.size(200.dp))
}