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:
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+369
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user