From b2df84c46b63b29ee2535a6f5ee5a5a67ea165d3 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 12 Oct 2026 01:31:24 -0700 Subject: [PATCH] voice and media search are built in now, with a voice intro screen and a new overlay over the car screen --- .../gearslip/car/AssistantOverlay.kt | 95 ++++++++----- .../app/seb3thehacker/gearslip/car/CarKeys.kt | 9 +- .../seb3thehacker/gearslip/car/CarSettings.kt | 30 ----- .../gearslip/car/CarSettingsScreen.kt | 44 ++---- .../app/seb3thehacker/gearslip/car/CarUi.kt | 10 +- .../seb3thehacker/gearslip/car/MediaScreen.kt | 3 +- .../seb3thehacker/gearslip/ui/IntroScreen.kt | 126 +++++++++++++++++- 7 files changed, 205 insertions(+), 112 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt index 8c7a161..961d3ec 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt @@ -4,7 +4,21 @@ import app.seb3thehacker.gearslip.car.theme.* import android.content.Intent import android.net.Uri import android.provider.Settings +import androidx.compose.animation.core.FastOutSlowInEasing +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.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -30,47 +44,66 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp /** - * The band that appears while [CarAssistant] is listening, thinking or speaking - a thin strip - * across the top, same idea as [CallOverlay]'s active-call banner, so it never blocks the screen - * the driver was already looking at. + * What the assistant hears and says, laid over the bottom of whatever screen is up: a dark shade + * so white words read over a bright map, and a glow along the bottom edge in the phone's own + * colours that breathes while listening. The shade passes touches through; only the buttons take them. */ @Composable fun AssistantOverlay(modifier: Modifier = Modifier) { val state by CarAssistant.state.collectAsState() if (state.phase == CarAssistant.Phase.IDLE) return val context = LocalContext.current + val listening = state.phase == CarAssistant.Phase.LISTENING + val glow = MaterialTheme.colorScheme.primary + val glow2 = MaterialTheme.colorScheme.tertiary + val breathe by rememberInfiniteTransition(label = "glow").animateFloat( + 0.55f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "glow", + ) + val strength = when (state.phase) { + CarAssistant.Phase.LISTENING -> breathe + CarAssistant.Phase.ERROR -> 0.3f + else -> 0.6f + } - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = modifier.fillMaxWidth(), - ) { - Row( - Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - PulsingMic(state.phase == CarAssistant.Phase.LISTENING) - Column(Modifier.weight(1f)) { - Text( - when (state.phase) { - CarAssistant.Phase.LISTENING -> "Listening…" - CarAssistant.Phase.THINKING -> "…" - CarAssistant.Phase.SPEAKING -> state.reply - CarAssistant.Phase.ERROR -> state.reply - CarAssistant.Phase.IDLE -> "" - }, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - ) - if (state.heard.isNotBlank() && state.phase != CarAssistant.Phase.SPEAKING) { - Text( - "\"${state.heard}\"", - style = MaterialTheme.typography.bodySmall, + Box( + modifier + .fillMaxWidth() + .drawBehind { + drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Black.copy(alpha = 0.6f), 1f to Color.Black.copy(alpha = 0.85f))) + // A wide, flat oval of light rising from the bottom edge. + scale(1f, 0.22f, Offset(size.width / 2, size.height)) { + drawCircle( + Brush.radialGradient( + 0f to glow.copy(alpha = 0.9f * strength), 0.5f to glow2.copy(alpha = 0.35f * strength), 1f to Color.Transparent, + center = Offset(size.width / 2, size.height), radius = size.width * 0.55f, + ), + radius = size.width * 0.55f, center = Offset(size.width / 2, size.height), ) } } + .padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 20.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PulsingMic(listening) + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + val (small, big) = when (state.phase) { + CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { "Listening…" } + CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" } + else -> state.heard to state.reply + } + if (small.isNotBlank()) { + Text(small, color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text( + big, + color = if (big == "Listening…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } if (state.missingPermission) { GsButton(tone = GsTone.Tonal, onClick = { context.startActivity( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt index ed4b4f0..3eed2a1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt @@ -102,15 +102,10 @@ object CarKeys { /** * The voice key: answers the message on screen if there is one, otherwise starts the - * assistant when it's turned on. + * assistant. */ private fun voice() = main.post { - if (VoiceReply.onVoiceKey()) return@post - if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.voiceAssistantEnabled.value) { - CarAssistant.start() - } else { - GearslipLog.i("keys: voice pressed, but the assistant is off in settings") - } + if (!VoiceReply.onVoiceKey()) CarAssistant.start() } /** Answers a ringing call; with nothing ringing, opens the phone screen. */ diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index 64afbda..a0b3024 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -38,10 +38,8 @@ object CarSettings { private const val KEY_PINNED = "car_pinned_apps" private const val KEY_APP_ORDER = "car_app_order" private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features" - private const val KEY_VOICE_ASSISTANT = "car_voice_assistant" private const val KEY_SYSTEM_SPEECH = "car_system_speech" private const val KEY_CAR_SENSORS = "car_sensors_enabled" - private const val KEY_MEDIA_SEARCH = "car_media_search_enabled" private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning" private const val KEY_UNITS = "car_units" private const val KEY_WHATS_NEW = "car_whats_new_after_updates" @@ -73,10 +71,8 @@ object CarSettings { private val pinnedFlow = MutableStateFlow>(emptySet()) private val appOrderFlow = MutableStateFlow>(emptyList()) private val experimentalFeaturesFlow = MutableStateFlow(false) - private val voiceAssistantFlow = MutableStateFlow(false) private val systemSpeechFlow = MutableStateFlow(false) private val carSensorsFlow = MutableStateFlow(false) - private val mediaSearchFlow = MutableStateFlow(false) private val seenSafetyWarningFlow = MutableStateFlow(false) private val unitsFlow = MutableStateFlow(Units.AUTO) @@ -172,13 +168,6 @@ object CarSettings { */ val experimentalFeaturesEnabled: StateFlow = experimentalFeaturesFlow - /** - * The voice assistant is experimental and off by default; a driver has to turn it on - * knowingly, from the settings screen, after reading the warning shown there. This flag - * alone does not run the assistant - [experimentalFeaturesEnabled] must also be on. - */ - val voiceAssistantEnabled: StateFlow = voiceAssistantFlow - /** Listen with the phone's own speech service instead of Gearslip's on-device model. */ val systemSpeech: StateFlow = systemSpeechFlow @@ -190,13 +179,6 @@ object CarSettings { */ val carSensorsEnabled: StateFlow = carSensorsFlow - /** - * Searching inside a media app is experimental and off by default: many apps claim to take - * searches and then ignore them or play something else. The media screen's search button - * shows only once this and [experimentalFeaturesEnabled] are both on. - */ - val mediaSearchEnabled: StateFlow = mediaSearchFlow - /** * Whether the car screen's own "drive safely" warning has already been shown and dismissed * once, from this same install. Separate from the phone side's own compat/safety dialogs - @@ -231,10 +213,8 @@ object CarSettings { appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "") .split(",").filter { it.isNotEmpty() } experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true" - voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true" systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true" carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" - mediaSearchFlow.value = AppSettings.getString(app, KEY_MEDIA_SEARCH, "false") == "true" seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true" unitsFlow.value = runCatching { Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name)) @@ -304,21 +284,11 @@ object CarSettings { AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString()) } - fun setVoiceAssistantEnabled(on: Boolean) { - voiceAssistantFlow.value = on - AppSettings.putString(app, KEY_VOICE_ASSISTANT, on.toString()) - } - fun setCarSensorsEnabled(on: Boolean) { carSensorsFlow.value = on AppSettings.putString(app, KEY_CAR_SENSORS, on.toString()) } - fun setMediaSearchEnabled(on: Boolean) { - mediaSearchFlow.value = on - AppSettings.putString(app, KEY_MEDIA_SEARCH, on.toString()) - } - fun setNightMode(mode: NightMode) { nightFlow.value = mode AppSettings.putString(app, KEY_NIGHT, mode.name) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index 05a57e3..e511cb0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -58,9 +58,8 @@ fun CarSettingsScreen() { val theme by CarSettings.appTheme.collectAsState() val uiTheme by CarSettings.uiTheme.collectAsState() val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() - val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState() val carSensors by CarSettings.carSensorsEnabled.collectAsState() - val mediaSearch by CarSettings.mediaSearchEnabled.collectAsState() + val systemSpeech by CarSettings.systemSpeech.collectAsState() val display by CarEnvironment.display.collectAsState() val vehicle by CarEnvironment.vehicle.collectAsState() val context = LocalContext.current @@ -138,6 +137,14 @@ fun CarSettingsScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant, ) + ExperimentalFeatureRow( + title = "Use the phone's speech recognition", + description = "Listens through the phone's own speech service instead of Gearslip's. On " + + "most phones that's Google's, which may send your voice to Google. GrapheneOS has " + + "none, so Gearslip keeps using its own.", + checked = systemSpeech, + ) { CarSettings.setSystemSpeech(it) } + WebPageSetting() ScreenFit(vehicle) @@ -148,7 +155,7 @@ fun CarSettingsScreen() { experimentalFeatures, ) { CarSettings.setExperimentalFeaturesEnabled(it) } if (experimentalFeatures) { - ExperimentalFeaturesList(voiceAssistant, carSensors, mediaSearch) + ExperimentalFeaturesList(carSensors) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -205,33 +212,11 @@ private fun ChoiceRow(title: String, options: List>, selecte * so the list reads fine whether there's one entry or several. */ @Composable -private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean, mediaSearch: Boolean) { +private fun ExperimentalFeaturesList(carSensors: Boolean) { Column( Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - ExperimentalFeatureRow( - title = "Voice assistant", - description = "Push-to-talk and the car's voice button start listening: navigate, " + - "play media, call, or open an app by saying so. Can mishear you or act on the " + - "wrong command - keep your eyes on the road, and confirm anything it does " + - "before you rely on it.", - warning = true, - checked = voiceAssistant, - ) { CarSettings.setVoiceAssistantEnabled(it) } - - if (voiceAssistant) { - val systemSpeech by CarSettings.systemSpeech.collectAsState() - ExperimentalFeatureRow( - title = "Use the phone's speech recognition", - description = "Understands more words, like place and brand names, but listens " + - "through the phone's microphone, not the car's. On most phones this is Google's, " + - "which may send your voice to Google. GrapheneOS has none, so it stays with " + - "Gearslip's own.", - checked = systemSpeech, - ) { CarSettings.setSystemSpeech(it) } - } - ExperimentalFeatureRow( title = "Vehicle data", description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " + @@ -240,13 +225,6 @@ private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolea "guess, not a fact, until you've watched one change for yourself.", checked = carSensors, ) { CarSettings.setCarSensorsEnabled(it) } - - ExperimentalFeatureRow( - title = "Media search", - description = "Adds a search button to the media screen. Some apps ignore the search " + - "or play something else.", - checked = mediaSearch, - ) { CarSettings.setMediaSearchEnabled(it) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 60ce1bd..99649b7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -258,7 +258,7 @@ fun CarUi() { // Above the popup: a call is more urgent than any notification. VoiceHelpOverlay(Modifier.align(Alignment.Center)) CallOverlay(Modifier.align(Alignment.TopCenter)) - AssistantOverlay(Modifier.align(Alignment.TopCenter)) + AssistantOverlay(Modifier.align(Alignment.BottomCenter)) if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) { // Bottom corner, not top: a screen's own header almost always puts // something in the top-right (settings, search, a Liked Songs @@ -470,12 +470,8 @@ private fun CarNavBar(navigator: CarNavigator) { NavHomeButton(navigator, entries, running) Spacer(Modifier.width(6.dp)) NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() } - val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() - val voiceEnabled by CarSettings.voiceAssistantEnabled.collectAsState() - if (experimentalFeatures && voiceEnabled) { - Spacer(Modifier.width(6.dp)) - NavAssistantIcon() - } + Spacer(Modifier.width(6.dp)) + NavAssistantIcon() // Pinned shortcuts, then whatever else is open right now. A pinned app that's also // open stays in its pinned spot, marked with a dot, instead of showing up twice. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index 4f53f3a..13cf66e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -90,7 +90,6 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { val search by media.search.collectAsStateWithLifecycle() var tab by remember { mutableStateOf(Tab.NOW_PLAYING) } var searching by remember { mutableStateOf(false) } - val searchAllowed = CarSettings.experimentalFeaturesEnabled.collectAsState().value && CarSettings.mediaSearchEnabled.collectAsState().value // The connection outlives this screen: the home screen's player is the same one. LaunchedEffect(app) { CarServices.openMedia(app) } @@ -179,7 +178,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } - if (searchAllowed && (now.canSearch || media.canSearchLibrary)) { + if (now.canSearch || media.canSearchLibrary) { GsIconButton( Icons.Filled.Search, "Search ${app.label}", diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt index 0388b35..974a4c0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt @@ -68,6 +68,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import app.seb3thehacker.gearslip.BuildConfig +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.foundation.layout.wrapContentHeight +import app.seb3thehacker.gearslip.speech.SpeechModels import app.seb3thehacker.gearslip.stats.UsageStats import kotlin.math.PI import kotlin.math.cos @@ -115,7 +123,7 @@ private fun introColors(): IntroColors { ) } -private enum class IntroStep { WELCOME, WIRELESS, KNOBS, GRAPHENE, THANKS, DRIVE } +private enum class IntroStep { WELCOME, WIRELESS, KNOBS, VOICE, GRAPHENE, THANKS, DRIVE } private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f) private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f) @@ -156,6 +164,7 @@ fun IntroScreen(onDone: () -> Unit) { "If your car does wireless Android Auto, pair once and leave the cable at home.", step) { WifiIcon(c.onSecondaryC) } IntroStep.KNOBS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Knobs work.", "Turn, push, done. No touchscreen? Gearslip doesn't need one anymore.", step) { DialIcon(c.onTertiaryC) } + IntroStep.VOICE -> VoiceStep(c, ::cq, ::cqSp, step) IntroStep.GRAPHENE -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Protections back on.", "On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode. If you " + "turned it on for Gearslip, turn it off to get full protection back.", step, @@ -234,7 +243,7 @@ private fun Welcome(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit ) } - Ticker(c.primary, c.onPrimary, "WIRELESS ✦ KNOB SUPPORT ✦ FASTER VIDEO ✦ AUTO UPDATES ✦ ", cq, cqSp, reverse = false, tilt = -4f, + Ticker(c.primary, c.onPrimary, "WIRELESS ✦ KNOB SUPPORT ✦ OFFLINE VOICE ✦ FASTER VIDEO ✦ AUTO UPDATES ✦ ", cq, cqSp, reverse = false, tilt = -4f, key = key, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = height * 0.15f)) Confetti(c, key, Modifier.fillMaxSize()) } @@ -320,6 +329,7 @@ private fun BadgeStep( c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, badge: Color, title: String, body: String, key: Any, action: Pair Unit>? = null, outlined: Boolean = false, + extra: (@Composable () -> Unit)? = null, icon: @Composable () -> Unit, ) { val spin by rememberInfiniteTransition(label = "spin").animateFloat( @@ -345,6 +355,7 @@ private fun BadgeStep( style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)), modifier = Modifier.up(entrance(key, 350, 700), cq(7f)), ) + extra?.invoke() if (action != null) { val (label, onClick) = action Spacer(Modifier.height(if (outlined) cq(7f) else cq(9f))) @@ -366,6 +377,90 @@ private fun BadgeStep( } } +/** + * Voice: a few things to say, drifting past, and a button that fetches both speech models so the + * assistant works the first time someone tries it in the car. + */ +@Composable +private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) { + val context = LocalContext.current + remember { SpeechModels.init(context) } + val needed = listOf(SpeechModels.Model.KROKO, SpeechModels.Model.PARAKEET_LARGE) + val states = needed.map { SpeechModels.state(it).collectAsState().value } + val ready = states.all { it == SpeechModels.State.Ready } + val label = when { + states.any { it is SpeechModels.State.Downloading } -> + "Downloading, ${needed.zip(states).sumOf { (m, st) -> m.downloadMb * percentOf(st) } / needed.sumOf { it.downloadMb }}%" + states.any { it is SpeechModels.State.Failed } -> "Download failed. Try again" + else -> "Download voice, ${needed.sumOf { it.downloadMb }} MB" + } + BadgeStep( + c, cq, cqSp, c.primaryC, "Just say it.", + "Tap the mic, or press the talk button on your wheel, and speak. Your phone turns speech " + + "into text by itself, and nothing you say leaves it. Say \"help\" in the car for the full list.", + key, + action = if (ready) null else label to { needed.forEach { SpeechModels.download(context, it) } }, + outlined = true, + extra = { CommandDrift(c, cq, cqSp, key) }, + ) { MicIcon(c.onPrimaryC) } +} + +private fun percentOf(state: SpeechModels.State) = when (state) { + SpeechModels.State.Ready -> 100 + is SpeechModels.State.Downloading -> state.percent + else -> 0 +} + +private val SAMPLE_COMMANDS = listOf( + "Navigate to Mission Viejo", "Play Daft Punk", "What's playing?", "Next song", + "Call Mom", "Open Organic Maps", "Pause", "Help", +) + +/** Example commands rising slowly through a window that fades at both edges. */ +@Composable +private fun CommandDrift(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) { + val scroll by rememberInfiniteTransition(label = "drift").animateFloat( + 0f, 1f, infiniteRepeatable(tween(SAMPLE_COMMANDS.size * 2_200, easing = LinearEasing)), label = "drift", + ) + Box( + Modifier + .padding(top = cq(6f)) + .fillMaxWidth() + .height(cq(34f)) + .up(entrance(key, 500, 700), cq(7f)) + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient(0f to Color.Transparent, 0.25f to Color.Black, 0.75f to Color.Black, 1f to Color.Transparent), + blendMode = BlendMode.DstIn, + ) + } + .clipToBounds(), + ) { + Column( + Modifier + .wrapContentHeight(Alignment.Top, unbounded = true) + // The list is there twice; rising by half loops cleanly. + .graphicsLayer { translationY = -size.height / 2f * scroll }, + verticalArrangement = Arrangement.spacedBy(cq(2.4f)), + ) { + repeat(2) { + SAMPLE_COMMANDS.forEach { say -> + Text( + "\u201c$say\u201d", color = c.onSecondaryC, + style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.SemiBold), + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(c.secondaryC) + .padding(horizontal = cq(4.4f), vertical = cq(2f)), + ) + } + } + } + } +} + /** Material's scalloped "cookie": a circle whose radius waves around its edge. */ @Composable private fun Cookie(color: Color, modifier: Modifier) { @@ -442,6 +537,33 @@ private fun DialIcon(color: Color) { } } +@Composable +private fun MicIcon(color: Color) { + val t = rememberInfiniteTransition(label = "mic") + val waves = (0..1).map { i -> + t.animateFloat( + 0.25f, 1f, + infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse, + initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)), + label = "mic$i", + ) + } + Canvas(Modifier.fillMaxSize()) { + inIconBox { + drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f)) + val stroke = Stroke(4f, cap = StrokeCap.Round) + drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke) + drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round) + // Sound waves either side, pulsing as if someone's talking. + listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) -> + val r = if (i == 0) 15f else 21f + drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false, + Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke) + } + } + } +} + @Composable private fun ShieldIcon(color: Color, tick: Color) { val p = entrance(IntroStep.GRAPHENE, 800, 500)