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:
Johann
2026-07-04 17:03:46 -06:00
parent 25154af616
commit 9bfe43b704
8 changed files with 870 additions and 127 deletions
@@ -4,6 +4,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -20,11 +21,19 @@ import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mx.paputec.rachita.domain.model.AvatarKind
import mx.paputec.rachita.domain.repository.ProfileRepository 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.DashboardScreen
import mx.paputec.rachita.ui.dashboard.DashboardViewModel import mx.paputec.rachita.ui.dashboard.DashboardViewModel
import mx.paputec.rachita.ui.onboarding.OnboardingScreen import mx.paputec.rachita.ui.onboarding.OnboardingScreen
@@ -43,7 +52,12 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = CreamBackground, color = CreamBackground,
) { ) {
RachitaNavHost() 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) private val _startRoute = MutableStateFlow<String?>(null)
val startRoute: StateFlow<String?> = _startRoute.asStateFlow() 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 { init {
viewModelScope.launch { viewModelScope.launch {
val profile = profileRepository.profile.first() 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 @Composable
private fun RachitaNavHost() { private fun RachitaNavHost() {
val startViewModel: StartRouteViewModel = hiltViewModel() 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.23 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 = {})
}
}
@@ -15,6 +15,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path 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.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke 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.SunDeep
import mx.paputec.rachita.ui.theme.WarmGreyDeep import mx.paputec.rachita.ui.theme.WarmGreyDeep
// A mascot head drawn with Compose Canvas (PRD §5.1: idle state = blink every ~5 s // PRD §5.1: idle (blink + breathe) · tired (droopy brows + sweat, battery low)
// + subtle breathing). No bitmap assets — everything is vector so we can retint // · sleep (closed eyes + floating Z's, charging capsule).
// the same drawings later for the tired / sleep states. 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 @Composable
fun Mascot( fun Mascot(
kind: AvatarKind, kind: AvatarKind,
modifier: Modifier = Modifier, 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( val breathe by transition.animateFloat(
initialValue = 1f, initialValue = 1f,
targetValue = 1.03f, targetValue = if (mood == MascotMood.Sleep) 1.045f else 1.03f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 3200, easing = LinearEasing), animation = tween(durationMillis = if (mood == MascotMood.Sleep) 3600 else 3200, easing = LinearEasing),
repeatMode = RepeatMode.Reverse, repeatMode = RepeatMode.Reverse,
), ),
label = "breathe", 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( val blink by transition.animateFloat(
initialValue = 0f, initialValue = 0f,
targetValue = 1f, targetValue = 1f,
@@ -59,79 +64,63 @@ fun Mascot(
), ),
label = "blink", 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) { Canvas(modifier = modifier) {
val bodyScale = breathe
when (kind) { when (kind) {
AvatarKind.Fox -> drawFox(bodyScale, eyeOpen) AvatarKind.Fox -> drawMascotHead(
AvatarKind.Cat -> drawCat(bodyScale, eyeOpen) headColor = Coral, earColor = Coral, innerEarColor = CreamBackground,
AvatarKind.Rabbit -> drawRabbit(bodyScale, eyeOpen) 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() 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() { private fun DrawScope.drawCustomFrame() {
val cx = size.width / 2f val cx = size.width / 2f
val cy = size.height / 2f val cy = size.height / 2f
val radius = max(size.width, size.height) * 0.32f val radius = max(size.width, size.height) * 0.32f
// Dashed frame using a series of short arcs approximated with strokes on // Dashed frame — reads as "your drawing goes here" until an image is picked.
// small circles arranged around the perimeter — reads as "your drawing goes
// here" when no image is picked yet.
val dashCount = 24 val dashCount = 24
val strokeColor = WarmGreyDeep.copy(alpha = 0.7f) val strokeColor = WarmGreyDeep.copy(alpha = 0.7f)
for (i in 0 until dashCount) { for (i in 0 until dashCount) {
val angle = (2f * Math.PI * i / dashCount).toFloat() val angle = (2f * Math.PI * i / dashCount).toFloat()
val px = cx + radius * kotlin.math.cos(angle) val px = cx + radius * kotlin.math.cos(angle)
val py = cy + radius * kotlin.math.sin(angle) val py = cy + radius * kotlin.math.sin(angle)
drawCircle( drawCircle(color = strokeColor, radius = 4.dp.toPx(), center = Offset(px, py), style = Fill)
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 stroke = Stroke(width = 6.dp.toPx())
val path = Path().apply { val path = Path().apply {
moveTo(cx - radius * 0.5f, cy - radius * 0.1f) 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, 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 { val path2 = Path().apply {
moveTo(cx - radius * 0.4f, cy + radius * 0.25f) moveTo(cx - radius * 0.4f, cy + radius * 0.25f)
@@ -164,6 +153,8 @@ private fun DrawScope.drawMascotHead(
bodyScale: Float, bodyScale: Float,
eyeOpen: Float, eyeOpen: Float,
earStyle: EarStyle, earStyle: EarStyle,
mood: MascotMood,
moodPhase: Float,
) { ) {
val cx = size.width / 2f val cx = size.width / 2f
val cy = size.height / 2f val cy = size.height / 2f
@@ -184,16 +175,16 @@ private fun DrawScope.drawMascotHead(
EarStyle.Long -> drawRabbitEars(headCenter, headRadius, earColor, innerEarColor) EarStyle.Long -> drawRabbitEars(headCenter, headRadius, earColor, innerEarColor)
} }
// Main head circle.
drawCircle(color = headColor, radius = headRadius, center = headCenter) drawCircle(color = headColor, radius = headRadius, center = headCenter)
// Snout patch — soft rounded shape below eyes.
val snoutTop = headCenter.y + headRadius * 0.05f val snoutTop = headCenter.y + headRadius * 0.05f
val snoutSize = Size(headRadius * 1.05f, headRadius * 0.75f) val snoutSize = Size(headRadius * 1.05f, headRadius * 0.75f)
val snoutTopLeft = Offset(headCenter.x - snoutSize.width / 2f, snoutTop) drawOval(
drawOval(color = snoutColor, topLeft = snoutTopLeft, size = snoutSize) color = snoutColor,
topLeft = Offset(headCenter.x - snoutSize.width / 2f, snoutTop),
size = snoutSize,
)
// Cheeks — pink blush on the sides.
val cheekOffset = headRadius * 0.55f val cheekOffset = headRadius * 0.55f
val cheekRadius = headRadius * 0.13f val cheekRadius = headRadius * 0.13f
drawCircle( drawCircle(
@@ -207,69 +198,140 @@ private fun DrawScope.drawMascotHead(
center = Offset(headCenter.x + cheekOffset, headCenter.y + headRadius * 0.15f), center = Offset(headCenter.x + cheekOffset, headCenter.y + headRadius * 0.15f),
) )
// Eyes — squished vertically by eyeOpen (blink).
val eyeOffsetX = headRadius * 0.32f val eyeOffsetX = headRadius * 0.32f
val eyeCenterY = headCenter.y - headRadius * 0.1f val eyeCenterY = headCenter.y - headRadius * 0.1f
val eyeWidth = headRadius * 0.18f val eyeWidth = headRadius * 0.18f
val eyeHeight = headRadius * 0.28f * eyeOpen
drawOval( if (mood == MascotMood.Sleep) {
color = Ink, // Closed eyes: downward arcs (peaceful sleep).
topLeft = Offset(headCenter.x - eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f), listOf(-1f, 1f).forEach { side ->
size = Size(eyeWidth, eyeHeight), val eyeCx = headCenter.x + side * eyeOffsetX
) val arc = Path().apply {
drawOval( moveTo(eyeCx - eyeWidth, eyeCenterY)
color = Ink, quadraticBezierTo(eyeCx, eyeCenterY + eyeWidth * 0.9f, eyeCx + eyeWidth, eyeCenterY)
topLeft = Offset(headCenter.x + eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f), }
size = Size(eyeWidth, eyeHeight), drawPath(arc, color = Ink, style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round))
) }
// Eye shine — small white dot in the top-right of each eye when the eye is open. } else {
if (eyeOpen > 0.7f) { val eyeHeight = headRadius * 0.28f * eyeOpen
val shineRadius = eyeWidth * 0.22f listOf(-1f, 1f).forEach { side ->
drawCircle( drawOval(
color = CreamBackground, color = Ink,
radius = shineRadius, topLeft = Offset(headCenter.x + side * eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f),
center = Offset(headCenter.x - eyeOffsetX + eyeWidth * 0.18f, eyeCenterY - eyeHeight * 0.2f), size = Size(eyeWidth, eyeHeight),
) )
drawCircle( }
color = CreamBackground, if (eyeOpen > 0.7f) {
radius = shineRadius, val shineRadius = eyeWidth * 0.22f
center = Offset(headCenter.x + eyeOffsetX + eyeWidth * 0.18f, eyeCenterY - eyeHeight * 0.2f), 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 nose = Path().apply {
val noseCx = headCenter.x
val noseTop = headCenter.y + headRadius * 0.15f val noseTop = headCenter.y + headRadius * 0.15f
val noseWidth = headRadius * 0.16f val noseWidth = headRadius * 0.16f
val noseHeight = headRadius * 0.12f moveTo(headCenter.x - noseWidth / 2f, noseTop)
moveTo(noseCx - noseWidth / 2f, noseTop) lineTo(headCenter.x + noseWidth / 2f, noseTop)
lineTo(noseCx + noseWidth / 2f, noseTop) lineTo(headCenter.x, noseTop + headRadius * 0.12f)
lineTo(noseCx, noseTop + noseHeight)
close() close()
} }
drawPath(nose, color = Ink) 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 mouthPath = Path().apply {
val mouthTop = headCenter.y + headRadius * 0.35f 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) moveTo(headCenter.x - mouthWidth, mouthTop)
cubicTo( cubicTo(
headCenter.x - mouthWidth * 0.4f, mouthTop + headRadius * 0.18f, headCenter.x - mouthWidth * 0.4f, mouthTop + curve,
headCenter.x + mouthWidth * 0.4f, mouthTop + headRadius * 0.18f, headCenter.x + mouthWidth * 0.4f, mouthTop + curve,
headCenter.x + mouthWidth, mouthTop, headCenter.x + mouthWidth, mouthTop,
) )
} }
drawPath(mouthPath, color = Ink, style = Stroke(width = 5.dp.toPx())) 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( // PRD §6.4 "sweat": a drop sliding down the temple, fading as it falls (2.4 s loop).
center: Offset, private fun DrawScope.drawSweatDrop(headCenter: Offset, headRadius: Float, phase: Float) {
headRadius: Float, val dropX = headCenter.x + headRadius * 0.82f
color: Color, val dropY = headCenter.y - headRadius * 0.45f + headRadius * 0.5f * phase
innerColor: Color, 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 earHeight = headRadius * 0.9f
val earWidth = headRadius * 0.55f val earWidth = headRadius * 0.55f
val earBaseY = center.y - headRadius * 0.7f val earBaseY = center.y - headRadius * 0.7f
@@ -292,12 +354,7 @@ private fun DrawScope.drawFoxEars(
} }
} }
private fun DrawScope.drawCatEars( private fun DrawScope.drawCatEars(center: Offset, headRadius: Float, color: Color, innerColor: Color) {
center: Offset,
headRadius: Float,
color: Color,
innerColor: Color,
) {
val earHeight = headRadius * 0.7f val earHeight = headRadius * 0.7f
val earWidth = headRadius * 0.7f val earWidth = headRadius * 0.7f
val earBaseY = center.y - headRadius * 0.75f val earBaseY = center.y - headRadius * 0.75f
@@ -320,12 +377,7 @@ private fun DrawScope.drawCatEars(
} }
} }
private fun DrawScope.drawRabbitEars( private fun DrawScope.drawRabbitEars(center: Offset, headRadius: Float, color: Color, innerColor: Color) {
center: Offset,
headRadius: Float,
color: Color,
innerColor: Color,
) {
val earHeight = headRadius * 1.35f val earHeight = headRadius * 1.35f
val earWidth = headRadius * 0.42f val earWidth = headRadius * 0.42f
val earBaseY = center.y - headRadius * 0.8f val earBaseY = center.y - headRadius * 0.8f
@@ -335,11 +387,11 @@ private fun DrawScope.drawRabbitEars(
drawOval(color = color, topLeft = topLeft, size = Size(earWidth, earHeight)) drawOval(color = color, topLeft = topLeft, size = Size(earWidth, earHeight))
val innerWidth = earWidth * 0.55f val innerWidth = earWidth * 0.55f
val innerHeight = earHeight * 0.75f val innerHeight = earHeight * 0.75f
val innerTopLeft = Offset( drawOval(
baseX - innerWidth / 2f, color = innerColor,
earBaseY - earHeight + (earHeight - innerHeight) / 2f, 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) @Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
@Composable @Composable
private fun PreviewFox() { private fun PreviewFoxIdle() {
Mascot(kind = AvatarKind.Fox, modifier = Modifier.size(200.dp)) Mascot(kind = AvatarKind.Fox, modifier = Modifier.size(200.dp))
} }
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220) @Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
@Composable @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)) Mascot(kind = AvatarKind.Cat, modifier = Modifier.size(200.dp))
} }
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 260) @Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 260)
@Composable @Composable
private fun PreviewRabbit() { private fun PreviewRabbitIdle() {
Mascot(kind = AvatarKind.Rabbit, modifier = Modifier.size(200.dp, 240.dp)) Mascot(kind = AvatarKind.Rabbit, modifier = Modifier.size(200.dp, 240.dp))
} }
@Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220) @Preview(showBackground = true, backgroundColor = 0xFFFFF6EE, widthDp = 220, heightDp = 220)
@Composable @Composable
private fun PreviewCustom() { private fun PreviewCustomFrame() {
Mascot(kind = AvatarKind.Custom, modifier = Modifier.size(200.dp)) Mascot(kind = AvatarKind.Custom, modifier = Modifier.size(200.dp))
} }
+15
View File
@@ -54,4 +54,19 @@
<string name="menu_settings">Ajustes</string> <string name="menu_settings">Ajustes</string>
<string name="cd_habit_check">Marcar hábito %1$s</string> <string name="cd_habit_check">Marcar hábito %1$s</string>
<string name="cd_battery">Batería %1$d por ciento</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> </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))
}
}