feat(battery): módulo 3 — overlay batería baja + bloqueo nocturno en carga
- Mascota con estados de ánimo: Tired (cejas caídas + gota de sudor 2.4s) y Sleep (ojos cerrados + Z flotantes 2.6s), PRD §5.1/§6.4 - LowBatteryPolicy: bandas 20/15/10/5 (BA-03) con 7 tests — al descartar en una banda solo reaparece al caer a la siguiente - BatteryUxViewModel: máquina de estados normal→low→charging; conectar el cargador resetea el ciclo de alertas - LowBatteryOverlay (BA-01/02/04): scrim del prototipo, mascota cansada, % real en negritas, barra coral, 'Ahora no' + 'Conectar cargador' con coralpulse; bloquea interacción con el fondo - ChargingLockScreen (CA-01..05): gradiente nocturno + 46 estrellas twinkle, cápsula de energía con glow, 'Cargando · N%', botón 'Ya la desconecté' que NUNCA desbloquea (el candado cae solo al detectar la desconexión real — CA-04 estructural), despertar al 100%, sin back - Escape 'salir (debug)' solo en builds DEBUG para desarrollo por USB - Overlays montados sobre el NavHost: cubren cualquier pantalla
This commit is contained in:
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -20,11 +21,19 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import mx.paputec.rachita.domain.model.AvatarKind
|
||||
import mx.paputec.rachita.domain.repository.ProfileRepository
|
||||
import mx.paputec.rachita.ui.battery.BatteryUxState
|
||||
import mx.paputec.rachita.ui.battery.BatteryUxViewModel
|
||||
import mx.paputec.rachita.ui.battery.ChargingLockScreen
|
||||
import mx.paputec.rachita.ui.battery.LowBatteryOverlay
|
||||
import mx.paputec.rachita.ui.dashboard.DashboardScreen
|
||||
import mx.paputec.rachita.ui.dashboard.DashboardViewModel
|
||||
import mx.paputec.rachita.ui.onboarding.OnboardingScreen
|
||||
@@ -43,7 +52,12 @@ class MainActivity : ComponentActivity() {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = CreamBackground,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
RachitaNavHost()
|
||||
// Battery states cover any screen (PRD §5.3): the low
|
||||
// overlay sits above content; the charging lock above all.
|
||||
BatteryUxHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +78,12 @@ class StartRouteViewModel @Inject constructor(
|
||||
private val _startRoute = MutableStateFlow<String?>(null)
|
||||
val startRoute: StateFlow<String?> = _startRoute.asStateFlow()
|
||||
|
||||
// Chosen companion, consumed by the battery overlays (AV-05: the avatar
|
||||
// follows the child across every surface of the app).
|
||||
val avatar: StateFlow<AvatarKind> = profileRepository.profile
|
||||
.map { it.avatar ?: AvatarKind.Fox }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AvatarKind.Fox)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val profile = profileRepository.profile.first()
|
||||
@@ -72,6 +92,29 @@ class StartRouteViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BatteryUxHost() {
|
||||
val batteryViewModel: BatteryUxViewModel = hiltViewModel()
|
||||
val uxState by batteryViewModel.uxState.collectAsStateWithLifecycle()
|
||||
val profileViewModel: StartRouteViewModel = hiltViewModel()
|
||||
val avatar by profileViewModel.avatar.collectAsStateWithLifecycle()
|
||||
|
||||
when (val state = uxState) {
|
||||
is BatteryUxState.LowBattery -> LowBatteryOverlay(
|
||||
percent = state.percent,
|
||||
avatar = avatar,
|
||||
onDismiss = batteryViewModel::dismissLowBattery,
|
||||
)
|
||||
is BatteryUxState.Charging -> ChargingLockScreen(
|
||||
percent = state.percent,
|
||||
isFull = state.isFull,
|
||||
avatar = avatar,
|
||||
onDebugExit = batteryViewModel::debugSuppressChargingLock,
|
||||
)
|
||||
BatteryUxState.None -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RachitaNavHost() {
|
||||
val startViewModel: StartRouteViewModel = hiltViewModel()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package mx.paputec.rachita.domain
|
||||
|
||||
/**
|
||||
* Low-battery alert bands (PRD BA-03): the overlay shows at <20 % and, once
|
||||
* dismissed, reappears only when the battery falls into the next band
|
||||
* (15/10/5) — never spamming within the same band.
|
||||
*/
|
||||
object LowBatteryPolicy {
|
||||
|
||||
// Descending thresholds; a percent belongs to the tightest band that contains it.
|
||||
private val BANDS = listOf(20, 15, 10, 5)
|
||||
|
||||
/** Band for the percent, or null when battery is above the alert range. */
|
||||
fun bandFor(percent: Int): Int? = BANDS.filter { percent <= it }.minOrNull()
|
||||
|
||||
/**
|
||||
* True when the overlay should be visible: inside a band, not charging,
|
||||
* and the band is deeper than the one the child already dismissed.
|
||||
*/
|
||||
fun shouldShowOverlay(percent: Int, isCharging: Boolean, dismissedBand: Int?): Boolean {
|
||||
if (isCharging) return false
|
||||
val band = bandFor(percent) ?: return false
|
||||
return dismissedBand == null || band < dismissedBand
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mx.paputec.rachita.ui.battery
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import mx.paputec.rachita.BuildConfig
|
||||
import mx.paputec.rachita.domain.LowBatteryPolicy
|
||||
import mx.paputec.rachita.domain.model.BatteryStatus
|
||||
import mx.paputec.rachita.platform.battery.BatteryStatusMonitor
|
||||
|
||||
// PRD §5.3 state machine: normal → low (<20 %) → charging → normal.
|
||||
sealed interface BatteryUxState {
|
||||
data object None : BatteryUxState
|
||||
data class LowBattery(val percent: Int) : BatteryUxState
|
||||
data class Charging(val percent: Int, val isFull: Boolean) : BatteryUxState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class BatteryUxViewModel @Inject constructor(
|
||||
batteryMonitor: BatteryStatusMonitor,
|
||||
) : ViewModel() {
|
||||
|
||||
private val dismissedBand = MutableStateFlow<Int?>(null)
|
||||
private val debugUnlocked = MutableStateFlow(false)
|
||||
private var lastStatus: BatteryStatus? = null
|
||||
|
||||
init {
|
||||
// Plugging in resets the alert cycle (BA-03) and any debug unlock.
|
||||
batteryMonitor.status
|
||||
.onEach { status ->
|
||||
lastStatus = status
|
||||
if (status.isCharging) dismissedBand.value = null else debugUnlocked.value = false
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
val uxState: StateFlow<BatteryUxState> = combine(
|
||||
batteryMonitor.status,
|
||||
dismissedBand,
|
||||
debugUnlocked,
|
||||
) { status, dismissed, debugBypass ->
|
||||
when {
|
||||
status.isCharging && !debugBypass ->
|
||||
BatteryUxState.Charging(percent = status.percent, isFull = status.percent >= 100)
|
||||
|
||||
LowBatteryPolicy.shouldShowOverlay(status.percent, status.isCharging, dismissed) ->
|
||||
BatteryUxState.LowBattery(percent = status.percent)
|
||||
|
||||
else -> BatteryUxState.None
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = BatteryUxState.None,
|
||||
)
|
||||
|
||||
/** "Ahora no": silences the overlay until the next deeper band (BA-03). */
|
||||
fun dismissLowBattery() {
|
||||
val percent = lastStatus?.percent ?: return
|
||||
dismissedBand.value = LowBatteryPolicy.bandFor(percent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug-only escape hatch so USB-tethered development is not permanently
|
||||
* locked behind the charging screen. No-op in release builds (CA-04 stays
|
||||
* strict for real devices on chargers).
|
||||
*/
|
||||
fun debugSuppressChargingLock() {
|
||||
if (BuildConfig.DEBUG) debugUnlocked.value = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package mx.paputec.rachita.ui.battery
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.delay
|
||||
import mx.paputec.rachita.BuildConfig
|
||||
import mx.paputec.rachita.R
|
||||
import mx.paputec.rachita.domain.model.AvatarKind
|
||||
import mx.paputec.rachita.ui.onboarding.components.Mascot
|
||||
import mx.paputec.rachita.ui.onboarding.components.MascotMood
|
||||
import mx.paputec.rachita.ui.theme.ChargingGreen
|
||||
import mx.paputec.rachita.ui.theme.NightGradientEnd
|
||||
import mx.paputec.rachita.ui.theme.NightGradientMid
|
||||
import mx.paputec.rachita.ui.theme.NightGradientStart
|
||||
import mx.paputec.rachita.ui.theme.RachitaTheme
|
||||
|
||||
// CA-01..05: full-screen night lock while charging. It cannot be dismissed by
|
||||
// back or by the escape button — the screen disappears on its own when the
|
||||
// real battery state says the charger left (BatteryUxViewModel), which is the
|
||||
// PRD's "verify before unlocking" rule made structural.
|
||||
@Composable
|
||||
fun ChargingLockScreen(
|
||||
percent: Int,
|
||||
isFull: Boolean,
|
||||
avatar: AvatarKind,
|
||||
onDebugExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// CA-01: no back navigation while charging.
|
||||
BackHandler { }
|
||||
|
||||
var showStillPlugged by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(showStillPlugged) {
|
||||
if (showStillPlugged) {
|
||||
delay(2_500)
|
||||
showStillPlugged = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(NightGradientStart, NightGradientMid, NightGradientEnd),
|
||||
),
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {},
|
||||
),
|
||||
) {
|
||||
TwinklingStars()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 460.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
EnergyCapsule(avatar = avatar, awake = isFull)
|
||||
Text(
|
||||
text = stringResource(if (isFull) R.string.charging_full_title else R.string.charging_title),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(color = Color.White),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(if (isFull) R.string.charging_full_subtitle else R.string.charging_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White.copy(alpha = 0.85f)),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
ChargingBar(percent = percent)
|
||||
Text(
|
||||
text = stringResource(R.string.charging_progress, percent),
|
||||
style = MaterialTheme.typography.labelLarge.copy(color = ChargingGreen),
|
||||
)
|
||||
|
||||
// CA-04: pressing while still plugged never unlocks — it only answers
|
||||
// gently. Real unplugging dismisses this screen automatically.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(Color.White.copy(alpha = 0.14f))
|
||||
.clickable { showStillPlugged = true }
|
||||
.padding(horizontal = 28.dp, vertical = 14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.charging_unplug_button),
|
||||
style = MaterialTheme.typography.labelLarge.copy(color = Color.White),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = showStillPlugged, enter = fadeIn(), exit = fadeOut()) {
|
||||
Text(
|
||||
text = stringResource(R.string.charging_still_plugged),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = Color.White.copy(alpha = 0.9f)),
|
||||
)
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
TextButton(onClick = onDebugExit) {
|
||||
Text(
|
||||
text = stringResource(R.string.charging_debug_exit),
|
||||
style = MaterialTheme.typography.labelSmall.copy(color = Color.White.copy(alpha = 0.4f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRD §6.4 "twinkle" (2.2–3 s): fixed pseudo-random star field, each star with
|
||||
// its own phase so the sky shimmers instead of blinking in unison.
|
||||
@Composable
|
||||
private fun TwinklingStars() {
|
||||
val stars = remember {
|
||||
val random = Random(seed = 7)
|
||||
List(46) {
|
||||
Triple(random.nextFloat(), random.nextFloat(), random.nextFloat())
|
||||
}
|
||||
}
|
||||
val t by rememberInfiniteTransition(label = "twinkle").animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(animation = tween(2600, easing = LinearEasing)),
|
||||
label = "phase",
|
||||
)
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
stars.forEach { (fx, fy, phase) ->
|
||||
val alpha = 0.25f + 0.75f * ((sin((t + phase) * 2f * Math.PI).toFloat() + 1f) / 2f)
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = alpha * 0.8f),
|
||||
radius = (1.2f + 1.6f * phase) * density,
|
||||
center = Offset(fx * size.width, fy * size.height * 0.92f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CA-02: the companion sleeps inside a translucent "energy capsule" with a
|
||||
// pulsing glow; at 100 % it wakes up (CA-05).
|
||||
@Composable
|
||||
private fun EnergyCapsule(avatar: AvatarKind, awake: Boolean) {
|
||||
val glow by rememberInfiniteTransition(label = "glow").animateFloat(
|
||||
initialValue = 0.95f,
|
||||
targetValue = 1.06f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1400),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "capsule",
|
||||
)
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(210.dp)
|
||||
.scale(glow)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
ChargingGreen.copy(alpha = 0.28f),
|
||||
Color.White.copy(alpha = 0.10f),
|
||||
Color.Transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(170.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.10f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Mascot(
|
||||
kind = avatar,
|
||||
mood = if (awake) MascotMood.Idle else MascotMood.Sleep,
|
||||
modifier = Modifier.size(130.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChargingBar(percent: Int) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(Color.White.copy(alpha = 0.18f)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fraction = (percent / 100f).coerceIn(0f, 1f))
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(ChargingGreen),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 800, heightDp = 640)
|
||||
@Composable
|
||||
private fun PreviewChargingLock() {
|
||||
RachitaTheme {
|
||||
ChargingLockScreen(percent = 64, isFull = false, avatar = AvatarKind.Fox, onDebugExit = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 800, heightDp = 640)
|
||||
@Composable
|
||||
private fun PreviewChargingLockFull() {
|
||||
RachitaTheme {
|
||||
ChargingLockScreen(percent = 100, isFull = true, avatar = AvatarKind.Fox, onDebugExit = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package mx.paputec.rachita.ui.battery
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import mx.paputec.rachita.R
|
||||
import mx.paputec.rachita.domain.model.AvatarKind
|
||||
import mx.paputec.rachita.ui.onboarding.components.Mascot
|
||||
import mx.paputec.rachita.ui.onboarding.components.MascotMood
|
||||
import mx.paputec.rachita.ui.theme.CardBorder
|
||||
import mx.paputec.rachita.ui.theme.Coral
|
||||
import mx.paputec.rachita.ui.theme.CoralIntense
|
||||
import mx.paputec.rachita.ui.theme.CreamBackground
|
||||
import mx.paputec.rachita.ui.theme.CreamCardSoft
|
||||
import mx.paputec.rachita.ui.theme.Ink
|
||||
import mx.paputec.rachita.ui.theme.RachitaTheme
|
||||
|
||||
// BA-01/02: modal overlay over any screen — dimmed backdrop, tired mascot,
|
||||
// real percent, coral battery bar and two CTAs. Charging is framed as caring
|
||||
// for the companion, never as punishment (BA-04).
|
||||
@Composable
|
||||
fun LowBatteryOverlay(
|
||||
percent: Int,
|
||||
avatar: AvatarKind,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Back behaves like "Ahora no" — the overlay is dismissible (unlike the charging lock).
|
||||
BackHandler(onBack = onDismiss)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// rgba(40,30,26,.5) from the prototype backdrop.
|
||||
.background(Color(0x80281E1A))
|
||||
// Consume taps so the dashboard behind is not interactive.
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 420.dp)
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(CreamBackground)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Mascot(kind = avatar, mood = MascotMood.Tired, modifier = Modifier.size(130.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.battery_low_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
val template = stringResource(R.string.battery_low_body, percent)
|
||||
val bold = "$percent%"
|
||||
val start = template.indexOf(bold)
|
||||
if (start >= 0) {
|
||||
append(template.substring(0, start))
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.ExtraBold, color = CoralIntense)) {
|
||||
append(bold)
|
||||
}
|
||||
append(template.substring(start + bold.length))
|
||||
} else {
|
||||
append(template)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
BatteryBar(percent = percent)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(Color.White)
|
||||
.border(2.dp, CardBorder, RoundedCornerShape(999.dp))
|
||||
.clickable(onClick = onDismiss)
|
||||
.padding(vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.battery_low_later),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
ConnectChargerButton(onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BatteryBar(percent: Int) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(CreamCardSoft),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fraction = (percent / 100f).coerceIn(0f, 1f))
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(CoralIntense),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PRD §6.4 "coralpulse": the urgent CTA gently pulses (1.6 s loop). Physically
|
||||
// plugging in is what really matters — tapping just closes the overlay; the
|
||||
// charging lock then appears on its own when the plug is detected.
|
||||
@Composable
|
||||
private fun ConnectChargerButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val pulse by rememberInfiniteTransition(label = "coralpulse").animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.045f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 800),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "pulse",
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.scale(pulse)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(Coral)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.battery_low_connect),
|
||||
style = MaterialTheme.typography.labelLarge.copy(color = CreamBackground),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 800, heightDp = 640)
|
||||
@Composable
|
||||
private fun PreviewLowBatteryOverlay() {
|
||||
RachitaTheme {
|
||||
LowBatteryOverlay(percent = 18, avatar = AvatarKind.Fox, onDismiss = {})
|
||||
}
|
||||
}
|
||||
+185
-121
@@ -15,6 +15,7 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
@@ -30,27 +31,31 @@ 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.
|
||||
// PRD §5.1: idle (blink + breathe) · tired (droopy brows + sweat, battery low)
|
||||
// · sleep (closed eyes + floating Z's, charging capsule).
|
||||
enum class MascotMood { Idle, Tired, Sleep }
|
||||
|
||||
// A mascot head drawn with Compose Canvas — no bitmap assets, so the same
|
||||
// drawing is retinted and re-expressed across moods (PRD §6.4 timings).
|
||||
@Composable
|
||||
fun Mascot(
|
||||
kind: AvatarKind,
|
||||
modifier: Modifier = Modifier,
|
||||
mood: MascotMood = MascotMood.Idle,
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "mascot-$kind")
|
||||
val transition = rememberInfiniteTransition(label = "mascot-$kind-$mood")
|
||||
|
||||
val breathe by transition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.03f,
|
||||
targetValue = if (mood == MascotMood.Sleep) 1.045f else 1.03f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 3200, easing = LinearEasing),
|
||||
animation = tween(durationMillis = if (mood == MascotMood.Sleep) 3600 else 3200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "breathe",
|
||||
)
|
||||
|
||||
// Blink cycles every ~5 s and lasts ~180 ms of closed eye.
|
||||
// Blink cycles every ~5 s (idle only); ~180 ms of closed eye.
|
||||
val blink by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
@@ -59,79 +64,63 @@ fun Mascot(
|
||||
),
|
||||
label = "blink",
|
||||
)
|
||||
val eyeOpen = if (blink > 0.96f) 0.2f else 1f
|
||||
|
||||
// Shared 0..1 phase for the sweat drop (2.4 s) and floating Z's (2.6 s).
|
||||
val moodPhase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = if (mood == MascotMood.Sleep) 2600 else 2400,
|
||||
easing = LinearEasing,
|
||||
),
|
||||
),
|
||||
label = "moodPhase",
|
||||
)
|
||||
|
||||
val eyeOpen = when (mood) {
|
||||
MascotMood.Idle -> if (blink > 0.96f) 0.2f else 1f
|
||||
MascotMood.Tired -> 0.45f
|
||||
MascotMood.Sleep -> 0f
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
val bodyScale = breathe
|
||||
when (kind) {
|
||||
AvatarKind.Fox -> drawFox(bodyScale, eyeOpen)
|
||||
AvatarKind.Cat -> drawCat(bodyScale, eyeOpen)
|
||||
AvatarKind.Rabbit -> drawRabbit(bodyScale, eyeOpen)
|
||||
AvatarKind.Fox -> drawMascotHead(
|
||||
headColor = Coral, earColor = Coral, innerEarColor = CreamBackground,
|
||||
snoutColor = CreamBackground, bodyScale = breathe, eyeOpen = eyeOpen,
|
||||
earStyle = EarStyle.Pointy, mood = mood, moodPhase = moodPhase,
|
||||
)
|
||||
AvatarKind.Cat -> drawMascotHead(
|
||||
headColor = Mint, earColor = Mint, innerEarColor = Pink.copy(alpha = 0.5f),
|
||||
snoutColor = CreamBackground, bodyScale = breathe, eyeOpen = eyeOpen,
|
||||
earStyle = EarStyle.Triangular, mood = mood, moodPhase = moodPhase,
|
||||
)
|
||||
AvatarKind.Rabbit -> drawMascotHead(
|
||||
headColor = SunDeep, earColor = SunDeep, innerEarColor = Pink.copy(alpha = 0.4f),
|
||||
snoutColor = CreamBackground, bodyScale = breathe, eyeOpen = eyeOpen,
|
||||
earStyle = EarStyle.Long, mood = mood, moodPhase = moodPhase,
|
||||
)
|
||||
AvatarKind.Custom -> drawCustomFrame()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.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.
|
||||
// Dashed frame — reads as "your drawing goes here" until an image is picked.
|
||||
val dashCount = 24
|
||||
val strokeColor = WarmGreyDeep.copy(alpha = 0.7f)
|
||||
for (i in 0 until dashCount) {
|
||||
val angle = (2f * Math.PI * i / dashCount).toFloat()
|
||||
val px = cx + radius * kotlin.math.cos(angle)
|
||||
val py = cy + radius * kotlin.math.sin(angle)
|
||||
drawCircle(
|
||||
color = strokeColor,
|
||||
radius = 4.dp.toPx(),
|
||||
center = Offset(px, py),
|
||||
style = Fill,
|
||||
)
|
||||
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)
|
||||
@@ -141,7 +130,7 @@ private fun DrawScope.drawCustomFrame() {
|
||||
cx + radius * 0.5f, cy - radius * 0.1f,
|
||||
)
|
||||
}
|
||||
drawPath(path, color = scribbleColor, style = stroke)
|
||||
drawPath(path, color = Coral.copy(alpha = 0.35f), style = stroke)
|
||||
|
||||
val path2 = Path().apply {
|
||||
moveTo(cx - radius * 0.4f, cy + radius * 0.25f)
|
||||
@@ -164,6 +153,8 @@ private fun DrawScope.drawMascotHead(
|
||||
bodyScale: Float,
|
||||
eyeOpen: Float,
|
||||
earStyle: EarStyle,
|
||||
mood: MascotMood,
|
||||
moodPhase: Float,
|
||||
) {
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
@@ -184,16 +175,16 @@ private fun DrawScope.drawMascotHead(
|
||||
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)
|
||||
drawOval(
|
||||
color = snoutColor,
|
||||
topLeft = Offset(headCenter.x - snoutSize.width / 2f, snoutTop),
|
||||
size = snoutSize,
|
||||
)
|
||||
|
||||
// Cheeks — pink blush on the sides.
|
||||
val cheekOffset = headRadius * 0.55f
|
||||
val cheekRadius = headRadius * 0.13f
|
||||
drawCircle(
|
||||
@@ -207,69 +198,140 @@ private fun DrawScope.drawMascotHead(
|
||||
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
|
||||
|
||||
if (mood == MascotMood.Sleep) {
|
||||
// Closed eyes: downward arcs (peaceful sleep).
|
||||
listOf(-1f, 1f).forEach { side ->
|
||||
val eyeCx = headCenter.x + side * eyeOffsetX
|
||||
val arc = Path().apply {
|
||||
moveTo(eyeCx - eyeWidth, eyeCenterY)
|
||||
quadraticBezierTo(eyeCx, eyeCenterY + eyeWidth * 0.9f, eyeCx + eyeWidth, eyeCenterY)
|
||||
}
|
||||
drawPath(arc, color = Ink, style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round))
|
||||
}
|
||||
} else {
|
||||
val eyeHeight = headRadius * 0.28f * eyeOpen
|
||||
listOf(-1f, 1f).forEach { side ->
|
||||
drawOval(
|
||||
color = Ink,
|
||||
topLeft = Offset(headCenter.x - eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f),
|
||||
topLeft = Offset(headCenter.x + side * 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),
|
||||
)
|
||||
}
|
||||
if (eyeOpen > 0.7f) {
|
||||
val shineRadius = eyeWidth * 0.22f
|
||||
listOf(-1f, 1f).forEach { side ->
|
||||
drawCircle(
|
||||
color = CreamBackground,
|
||||
radius = shineRadius,
|
||||
center = Offset(
|
||||
headCenter.x + side * eyeOffsetX + eyeWidth * 0.18f,
|
||||
eyeCenterY - eyeHeight * 0.2f,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mood == MascotMood.Tired) {
|
||||
// Droopy eyebrows: short strokes slanting down toward the outer face.
|
||||
listOf(-1f, 1f).forEach { side ->
|
||||
val browInnerX = headCenter.x + side * (eyeOffsetX - eyeWidth * 0.6f)
|
||||
val browOuterX = headCenter.x + side * (eyeOffsetX + eyeWidth * 0.7f)
|
||||
val browY = eyeCenterY - headRadius * 0.24f
|
||||
val brow = Path().apply {
|
||||
moveTo(browInnerX, browY)
|
||||
lineTo(browOuterX, browY + headRadius * 0.09f)
|
||||
}
|
||||
drawPath(brow, color = Ink, style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round))
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
moveTo(headCenter.x - noseWidth / 2f, noseTop)
|
||||
lineTo(headCenter.x + noseWidth / 2f, noseTop)
|
||||
lineTo(headCenter.x, noseTop + headRadius * 0.12f)
|
||||
close()
|
||||
}
|
||||
drawPath(nose, color = Ink)
|
||||
|
||||
// Smile: two small arcs joined below the nose.
|
||||
// Mouth: smile when idle, small flat/open when tired, tiny relaxed when asleep.
|
||||
val mouthPath = Path().apply {
|
||||
val mouthTop = headCenter.y + headRadius * 0.35f
|
||||
val mouthWidth = headRadius * 0.35f
|
||||
val mouthWidth = when (mood) {
|
||||
MascotMood.Idle -> headRadius * 0.35f
|
||||
MascotMood.Tired -> headRadius * 0.22f
|
||||
MascotMood.Sleep -> headRadius * 0.14f
|
||||
}
|
||||
val curve = when (mood) {
|
||||
MascotMood.Idle -> headRadius * 0.18f
|
||||
MascotMood.Tired -> headRadius * 0.02f
|
||||
MascotMood.Sleep -> headRadius * 0.06f
|
||||
}
|
||||
moveTo(headCenter.x - mouthWidth, mouthTop)
|
||||
cubicTo(
|
||||
headCenter.x - mouthWidth * 0.4f, mouthTop + headRadius * 0.18f,
|
||||
headCenter.x + mouthWidth * 0.4f, mouthTop + headRadius * 0.18f,
|
||||
headCenter.x - mouthWidth * 0.4f, mouthTop + curve,
|
||||
headCenter.x + mouthWidth * 0.4f, mouthTop + curve,
|
||||
headCenter.x + mouthWidth, mouthTop,
|
||||
)
|
||||
}
|
||||
drawPath(mouthPath, color = Ink, style = Stroke(width = 5.dp.toPx()))
|
||||
|
||||
when (mood) {
|
||||
MascotMood.Tired -> drawSweatDrop(headCenter, headRadius, moodPhase)
|
||||
MascotMood.Sleep -> drawFloatingZs(headCenter, headRadius, moodPhase)
|
||||
MascotMood.Idle -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawFoxEars(
|
||||
center: Offset,
|
||||
headRadius: Float,
|
||||
color: Color,
|
||||
innerColor: Color,
|
||||
) {
|
||||
// PRD §6.4 "sweat": a drop sliding down the temple, fading as it falls (2.4 s loop).
|
||||
private fun DrawScope.drawSweatDrop(headCenter: Offset, headRadius: Float, phase: Float) {
|
||||
val dropX = headCenter.x + headRadius * 0.82f
|
||||
val dropY = headCenter.y - headRadius * 0.45f + headRadius * 0.5f * phase
|
||||
val alpha = (1f - phase) * 0.9f
|
||||
val r = headRadius * 0.11f
|
||||
val color = Mint.copy(alpha = alpha)
|
||||
|
||||
val tear = Path().apply {
|
||||
moveTo(dropX, dropY - r * 1.5f)
|
||||
cubicTo(dropX + r, dropY - r * 0.3f, dropX + r, dropY + r * 0.6f, dropX, dropY + r * 0.8f)
|
||||
cubicTo(dropX - r, dropY + r * 0.6f, dropX - r, dropY - r * 0.3f, dropX, dropY - r * 1.5f)
|
||||
close()
|
||||
}
|
||||
drawPath(tear, color = color)
|
||||
}
|
||||
|
||||
// PRD §6.4 "floatZ": Z letters rising from the head and fading (2.6 s loop).
|
||||
private fun DrawScope.drawFloatingZs(headCenter: Offset, headRadius: Float, phase: Float) {
|
||||
val baseX = headCenter.x + headRadius * 0.85f
|
||||
val baseY = headCenter.y - headRadius * 0.9f
|
||||
// Three Z's at staggered phases so there is always one visible.
|
||||
for (i in 0 until 3) {
|
||||
val local = (phase + i / 3f) % 1f
|
||||
val zSize = headRadius * (0.16f + 0.08f * i)
|
||||
val x = baseX + headRadius * 0.22f * i + headRadius * 0.1f * local
|
||||
val y = baseY - headRadius * 0.85f * local
|
||||
val alpha = ((1f - local) * 0.85f).coerceIn(0f, 1f)
|
||||
drawZ(Offset(x, y), zSize, Ink.copy(alpha = alpha))
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawZ(topLeft: Offset, sizePx: Float, color: Color) {
|
||||
val z = Path().apply {
|
||||
moveTo(topLeft.x, topLeft.y)
|
||||
lineTo(topLeft.x + sizePx, topLeft.y)
|
||||
lineTo(topLeft.x, topLeft.y + sizePx)
|
||||
lineTo(topLeft.x + sizePx, topLeft.y + sizePx)
|
||||
}
|
||||
drawPath(z, color = color, style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round))
|
||||
}
|
||||
|
||||
private fun DrawScope.drawFoxEars(center: Offset, headRadius: Float, color: Color, innerColor: Color) {
|
||||
val earHeight = headRadius * 0.9f
|
||||
val earWidth = headRadius * 0.55f
|
||||
val earBaseY = center.y - headRadius * 0.7f
|
||||
@@ -292,12 +354,7 @@ private fun DrawScope.drawFoxEars(
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawCatEars(
|
||||
center: Offset,
|
||||
headRadius: Float,
|
||||
color: Color,
|
||||
innerColor: Color,
|
||||
) {
|
||||
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
|
||||
@@ -320,12 +377,7 @@ private fun DrawScope.drawCatEars(
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawRabbitEars(
|
||||
center: Offset,
|
||||
headRadius: Float,
|
||||
color: Color,
|
||||
innerColor: Color,
|
||||
) {
|
||||
private fun DrawScope.drawRabbitEars(center: Offset, headRadius: Float, color: Color, innerColor: Color) {
|
||||
val earHeight = headRadius * 1.35f
|
||||
val earWidth = headRadius * 0.42f
|
||||
val earBaseY = center.y - headRadius * 0.8f
|
||||
@@ -335,11 +387,11 @@ private fun DrawScope.drawRabbitEars(
|
||||
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 = Offset(baseX - innerWidth / 2f, earBaseY - earHeight + (earHeight - innerHeight) / 2f),
|
||||
size = Size(innerWidth, innerHeight),
|
||||
)
|
||||
drawOval(color = innerColor, topLeft = innerTopLeft, size = Size(innerWidth, innerHeight))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,24 +399,36 @@ 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() {
|
||||
private fun PreviewFoxIdle() {
|
||||
Mascot(kind = AvatarKind.Fox, modifier = Modifier.size(200.dp))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
|
||||
@Composable
|
||||
private fun PreviewCat() {
|
||||
private fun PreviewFoxTired() {
|
||||
Mascot(kind = AvatarKind.Fox, modifier = Modifier.size(200.dp), mood = MascotMood.Tired)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, backgroundColor = 0xFF2A2350, widthDp = 220, heightDp = 220)
|
||||
@Composable
|
||||
private fun PreviewFoxSleep() {
|
||||
Mascot(kind = AvatarKind.Fox, modifier = Modifier.size(200.dp), mood = MascotMood.Sleep)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
|
||||
@Composable
|
||||
private fun PreviewCatIdle() {
|
||||
Mascot(kind = AvatarKind.Cat, modifier = Modifier.size(200.dp))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 260)
|
||||
@Composable
|
||||
private fun PreviewRabbit() {
|
||||
private fun PreviewRabbitIdle() {
|
||||
Mascot(kind = AvatarKind.Rabbit, modifier = Modifier.size(200.dp, 240.dp))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
|
||||
@Composable
|
||||
private fun PreviewCustom() {
|
||||
private fun PreviewCustomFrame() {
|
||||
Mascot(kind = AvatarKind.Custom, modifier = Modifier.size(200.dp))
|
||||
}
|
||||
|
||||
@@ -54,4 +54,19 @@
|
||||
<string name="menu_settings">Ajustes</string>
|
||||
<string name="cd_habit_check">Marcar hábito %1$s</string>
|
||||
<string name="cd_battery">Batería %1$d por ciento</string>
|
||||
|
||||
<!-- Battery module (module 3). Copy exact from prototype (PRD §5.3). -->
|
||||
<string name="battery_low_title">¡Me estoy quedando sin energía!</string>
|
||||
<string name="battery_low_body">Solo me queda %1$d%% de batería. ¿Me conectas al cargador para seguir entrenando contigo?</string>
|
||||
<string name="battery_low_later">Ahora no</string>
|
||||
<string name="battery_low_connect">Conectar cargador</string>
|
||||
<string name="charging_title">Entrenando en mi cápsula de energía…</string>
|
||||
<string name="charging_subtitle">Guarda la tablet mientras se carga. Cuando despierte, ¡estaremos a tope de batería para jugar y dibujar!</string>
|
||||
<string name="charging_progress">Cargando · %1$d%%</string>
|
||||
<string name="charging_unplug_button">Ya la desconecté</string>
|
||||
<string name="charging_still_plugged">Mmm… sigo conectada al cargador 🔌</string>
|
||||
<!-- CA-05 (P1): wake copy not present in prototype; proposed here. -->
|
||||
<string name="charging_full_title">¡Desperté a tope de energía!</string>
|
||||
<string name="charging_full_subtitle">Ya puedes desconectarme. ¡Vamos a jugar y dibujar!</string>
|
||||
<string name="charging_debug_exit">salir (debug)</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package mx.paputec.rachita.domain
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LowBatteryPolicyTest {
|
||||
|
||||
@Test
|
||||
fun `no band above twenty percent`() {
|
||||
assertEquals(null, LowBatteryPolicy.bandFor(21))
|
||||
assertEquals(null, LowBatteryPolicy.bandFor(100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bands map to the tightest threshold`() {
|
||||
assertEquals(20, LowBatteryPolicy.bandFor(20))
|
||||
assertEquals(20, LowBatteryPolicy.bandFor(18))
|
||||
assertEquals(15, LowBatteryPolicy.bandFor(15))
|
||||
assertEquals(15, LowBatteryPolicy.bandFor(12))
|
||||
assertEquals(10, LowBatteryPolicy.bandFor(10))
|
||||
assertEquals(10, LowBatteryPolicy.bandFor(7))
|
||||
assertEquals(5, LowBatteryPolicy.bandFor(5))
|
||||
assertEquals(5, LowBatteryPolicy.bandFor(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlay shows on first entry into a band`() {
|
||||
assertTrue(LowBatteryPolicy.shouldShowOverlay(percent = 18, isCharging = false, dismissedBand = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlay hides after dismissal within the same band`() {
|
||||
assertFalse(LowBatteryPolicy.shouldShowOverlay(percent = 17, isCharging = false, dismissedBand = 20))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlay reappears when falling into a deeper band`() {
|
||||
assertTrue(LowBatteryPolicy.shouldShowOverlay(percent = 14, isCharging = false, dismissedBand = 20))
|
||||
assertTrue(LowBatteryPolicy.shouldShowOverlay(percent = 4, isCharging = false, dismissedBand = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlay never shows while charging`() {
|
||||
assertFalse(LowBatteryPolicy.shouldShowOverlay(percent = 8, isCharging = true, dismissedBand = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlay never shows above the alert range`() {
|
||||
assertFalse(LowBatteryPolicy.shouldShowOverlay(percent = 55, isCharging = false, dismissedBand = null))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user