From 9bfe43b7043ab5cd15114b27cfd1ad05e2397228 Mon Sep 17 00:00:00 2001 From: Johann Date: Sat, 4 Jul 2026 17:03:46 -0600 Subject: [PATCH] =?UTF-8?q?feat(battery):=20m=C3=B3dulo=203=20=E2=80=94=20?= =?UTF-8?q?overlay=20bater=C3=ADa=20baja=20+=20bloqueo=20nocturno=20en=20c?= =?UTF-8?q?arga?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../java/mx/paputec/rachita/MainActivity.kt | 45 ++- .../rachita/domain/LowBatteryPolicy.kt | 25 ++ .../rachita/ui/battery/BatteryUxViewModel.kt | 79 +++++ .../rachita/ui/battery/ChargingLockScreen.kt | 269 +++++++++++++++ .../rachita/ui/battery/LowBatteryOverlay.kt | 195 +++++++++++ .../components/MascotIllustrations.kt | 316 +++++++++++------- app/app/src/main/res/values/strings.xml | 15 + .../rachita/domain/LowBatteryPolicyTest.kt | 53 +++ 8 files changed, 870 insertions(+), 127 deletions(-) create mode 100644 app/app/src/main/java/mx/paputec/rachita/domain/LowBatteryPolicy.kt create mode 100644 app/app/src/main/java/mx/paputec/rachita/ui/battery/BatteryUxViewModel.kt create mode 100644 app/app/src/main/java/mx/paputec/rachita/ui/battery/ChargingLockScreen.kt create mode 100644 app/app/src/main/java/mx/paputec/rachita/ui/battery/LowBatteryOverlay.kt create mode 100644 app/app/src/test/java/mx/paputec/rachita/domain/LowBatteryPolicyTest.kt diff --git a/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt b/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt index 5a7d2d7..2e324f3 100644 --- a/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt +++ b/app/app/src/main/java/mx/paputec/rachita/MainActivity.kt @@ -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, ) { - 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(null) val startRoute: StateFlow = _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 = 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() diff --git a/app/app/src/main/java/mx/paputec/rachita/domain/LowBatteryPolicy.kt b/app/app/src/main/java/mx/paputec/rachita/domain/LowBatteryPolicy.kt new file mode 100644 index 0000000..4f9e4f5 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/domain/LowBatteryPolicy.kt @@ -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 + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/battery/BatteryUxViewModel.kt b/app/app/src/main/java/mx/paputec/rachita/ui/battery/BatteryUxViewModel.kt new file mode 100644 index 0000000..047d081 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/battery/BatteryUxViewModel.kt @@ -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(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 = 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 + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/battery/ChargingLockScreen.kt b/app/app/src/main/java/mx/paputec/rachita/ui/battery/ChargingLockScreen.kt new file mode 100644 index 0000000..c4207f2 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/battery/ChargingLockScreen.kt @@ -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 = {}) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/battery/LowBatteryOverlay.kt b/app/app/src/main/java/mx/paputec/rachita/ui/battery/LowBatteryOverlay.kt new file mode 100644 index 0000000..d88b0a0 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/ui/battery/LowBatteryOverlay.kt @@ -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 = {}) + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt index 51cade6..0bbaac3 100644 --- a/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt +++ b/app/app/src/main/java/mx/paputec/rachita/ui/onboarding/components/MascotIllustrations.kt @@ -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 - val eyeHeight = headRadius * 0.28f * eyeOpen - drawOval( - color = Ink, - topLeft = Offset(headCenter.x - eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f), - size = Size(eyeWidth, eyeHeight), - ) - drawOval( - color = Ink, - topLeft = Offset(headCenter.x + eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f), - size = Size(eyeWidth, eyeHeight), - ) - // Eye shine — small white dot in the top-right of each eye when the eye is open. - if (eyeOpen > 0.7f) { - val shineRadius = eyeWidth * 0.22f - drawCircle( - color = CreamBackground, - radius = shineRadius, - center = Offset(headCenter.x - eyeOffsetX + eyeWidth * 0.18f, eyeCenterY - eyeHeight * 0.2f), - ) - drawCircle( - color = CreamBackground, - radius = shineRadius, - center = Offset(headCenter.x + eyeOffsetX + eyeWidth * 0.18f, eyeCenterY - eyeHeight * 0.2f), - ) + + 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 + side * eyeOffsetX - eyeWidth / 2f, eyeCenterY - eyeHeight / 2f), + size = Size(eyeWidth, eyeHeight), + ) + } + 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)) } diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml index 4fc36af..339161c 100644 --- a/app/app/src/main/res/values/strings.xml +++ b/app/app/src/main/res/values/strings.xml @@ -54,4 +54,19 @@ Ajustes Marcar hábito %1$s Batería %1$d por ciento + + + ¡Me estoy quedando sin energía! + Solo me queda %1$d%% de batería. ¿Me conectas al cargador para seguir entrenando contigo? + Ahora no + Conectar cargador + Entrenando en mi cápsula de energía… + Guarda la tablet mientras se carga. Cuando despierte, ¡estaremos a tope de batería para jugar y dibujar! + Cargando · %1$d%% + Ya la desconecté + Mmm… sigo conectada al cargador 🔌 + + ¡Desperté a tope de energía! + Ya puedes desconectarme. ¡Vamos a jugar y dibujar! + salir (debug) diff --git a/app/app/src/test/java/mx/paputec/rachita/domain/LowBatteryPolicyTest.kt b/app/app/src/test/java/mx/paputec/rachita/domain/LowBatteryPolicyTest.kt new file mode 100644 index 0000000..d93eb91 --- /dev/null +++ b/app/app/src/test/java/mx/paputec/rachita/domain/LowBatteryPolicyTest.kt @@ -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)) + } +}