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.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.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 = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
-126
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user