diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..98ef96a --- /dev/null +++ b/STATUS.md @@ -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). diff --git a/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt b/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt new file mode 100644 index 0000000..8486f89 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt @@ -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, + ) {} +} diff --git a/app/app/src/main/java/mx/paputec/rachita/data/preferences/ProfileRepositoryImpl.kt b/app/app/src/main/java/mx/paputec/rachita/data/preferences/ProfileRepositoryImpl.kt new file mode 100644 index 0000000..343b3bc --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/data/preferences/ProfileRepositoryImpl.kt @@ -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 = 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 } diff --git a/app/app/src/main/java/mx/paputec/rachita/di/DataModule.kt b/app/app/src/main/java/mx/paputec/rachita/di/DataModule.kt new file mode 100644 index 0000000..0a900de --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/di/DataModule.kt @@ -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) +} diff --git a/app/app/src/main/java/mx/paputec/rachita/domain/model/Avatar.kt b/app/app/src/main/java/mx/paputec/rachita/domain/model/Avatar.kt new file mode 100644 index 0000000..aca3143 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/domain/model/Avatar.kt @@ -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, +} diff --git a/app/app/src/main/java/mx/paputec/rachita/domain/model/Profile.kt b/app/app/src/main/java/mx/paputec/rachita/domain/model/Profile.kt new file mode 100644 index 0000000..a4541e4 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/domain/model/Profile.kt @@ -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, + ) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/domain/repository/ProfileRepository.kt b/app/app/src/main/java/mx/paputec/rachita/domain/repository/ProfileRepository.kt new file mode 100644 index 0000000..d231967 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/domain/repository/ProfileRepository.kt @@ -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 + + suspend fun setAvatar(kind: AvatarKind) + suspend fun setCustomAvatar(imageUri: String) + suspend fun setChildName(name: String) + suspend fun markOnboardingCompleted() +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/OnboardingScreen.kt b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..e90e0ef --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/OnboardingScreen.kt @@ -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 = {}, + ) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/OnboardingUiState.kt b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/OnboardingUiState.kt new file mode 100644 index 0000000..4195f9a --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/OnboardingUiState.kt @@ -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()) +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/OnboardingViewModel.kt b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..214029e --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/OnboardingViewModel.kt @@ -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 = _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) } + } + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/AvatarCard.kt b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/AvatarCard.kt new file mode 100644 index 0000000..0a0cfaf --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/AvatarCard.kt @@ -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(), + ) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt new file mode 100644 index 0000000..c28c2e1 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt @@ -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)) +}