car: split the nav bar, template rows and media browsing into their own files

This commit is contained in:
Seb3thehacker committed 2026-10-24 14:26:12 -07:00
1 parent ca33b5d01c
commit 795963f178
6 files changed
+1290 -1028

No files matched your search

@@ -0,0 +1,664 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.material.icons.filled.Call
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.call.CallUiState
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.border
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.BoxScope
import androidx.car.app.model.CarIcon
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.text
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.animation.animateContentSize
import android.media.session.PlaybackState
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.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.gsColors
import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.notify.CarNotifications
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
// The nav bar along the bottom of the car screen: home, apps, the assistant, running apps,
// the next turn or a call, the player, and the clock. Split out of CarUi.kt.
/**
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
* back at all, since a car touchscreen has no hardware button for it.
*/
@Composable
private fun BreadcrumbBar(navigator: CarNavigator) {
val trail = navigator.trail
if (trail.size <= 1) return
Row(
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex
Text(
crumb.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = if (isLast) Modifier else Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { navigator.jumpTo(index) }
.padding(horizontal = 4.dp, vertical = 2.dp),
)
if (!isLast) {
Text(
"›",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
}
}
/**
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
* more room.
*/
@Composable
internal fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val now by rememberNow()
val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data
val pinnedIds by CarSettings.pinnedApps.collectAsState()
val entries = rememberAllEntries()
val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
}
val frame by CarEnvironment.frame.collectAsState()
val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of
// these two from three screens deep in a media app shouldn't mean reading it
// first.
NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
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.
// The map app lives on the Home button, so it never gets a second icon here.
val runningEntries = running.keys
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
.mapNotNull { id -> entries.find { it.componentId == id } }
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
// Next turn first, off the map only; the player shares the space when it fits,
// and gives way to the turn when it doesn't.
// A call takes the player's place until it ends.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
if (call != null) {
NavCallPill(call, Modifier.weight(1f, fill = false))
} else if (turn == null || roomForBoth) {
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
}
}
}
// Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above.
// The clock is also the notifications button: the dashboard lists them under the
// calendar and weather, and a dot here marks anything unread.
val unread by CarNotifications.unread.collectAsState()
NavChip(
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
{ navigator.dashboard() },
) {
if (unread > 0) {
Box(
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-2).dp)
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
if (battery.percent >= 0) {
Text(
"${battery.percent}%",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (battery.charging) {
ChargingGlyph(Modifier.size(13.dp))
}
}
weatherData?.let { w ->
Text(
" · ${w.temp}°",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
/**
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
*/
@Composable
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
val selected = navigator.current == CarScreen.Home
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
GsIconBox(
{ navigator.home() },
colors = navColors(selected),
latched = selected,
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
) {
val icon = navEntry?.icon
if (icon != null) {
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
}
} else {
Icon(
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
)
}
}
}
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
@Composable
private fun NavDivider() {
Box(
Modifier
.padding(horizontal = 10.dp)
.width(1.dp)
.height(28.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/**
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
* already open; a long press opens its menu (pin, unpin, close).
*/
@Composable
private fun NavEntryIcon(
entry: Entry,
running: Map<String, RunningSlot>,
navigator: CarNavigator,
frame: CarEnvironment.Frame,
) {
val id = entry.componentId ?: return
val slot = running[id]
Box(Modifier.padding(horizontal = 3.dp)) {
NavAppIcon(
entry,
open = slot != null,
showing = slot != null && slot.isShowing(navigator.current),
onLongClick = { navigator.showAppMenu(id) },
) { openApp(entry, slot, navigator, frame) }
}
}
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
@Composable
private fun NavAppIcon(
entry: Entry,
open: Boolean,
showing: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
GsIconBox(
onClick,
Modifier.size(44.dp),
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
latched = showing,
onLongClick = onLongClick,
) {
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
if (open) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 2.dp)
.size(width = 12.dp, height = 3.dp)
.clip(RoundedCornerShape(50))
.background(scheme.primary),
)
}
}
}
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
}
}
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
@Composable
private fun navColors(selected: Boolean): GsColors {
val s = MaterialTheme.colorScheme
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
else GsColors(s.surfaceContainerHighest, s.onSurface)
}
/**
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
* two buttons beside it.
*/
@Composable
private fun NavAssistantIcon() {
val state by CarAssistant.state.collectAsState()
val active = state.phase != CarAssistant.Phase.IDLE
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
private val NAV_ITEM_PADDING = 8.dp
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
@Composable
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
while (true) {
value = System.currentTimeMillis()
delay(60_000 - value % 60_000)
}
}
/** How long the pill shows a new song's title before settling to just the controls. */
private const val TITLE_SHOWN_MS = 3_000L
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
/** The next manoeuvre, as the running nav app last described it. */
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
/**
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
* while it's running, whether or not the map is the screen being shown.
*/
@Composable
private fun rememberNextTurn(): NextTurn? {
val status by CarServices.nav.status.collectAsState()
val template by CarServices.nav.template.collectAsState()
// Read so a change of units redraws the distance straight away, not at the next update.
CarSettings.units.collectAsState().value
if (status.phase != CarAppConnection.Phase.RUNNING) return null
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
if (routing.isLoading) return null
val step = routing.currentStep ?: return null
return NextTurn(
icon = step.maneuver?.icon,
distance = routing.currentDistance.display(),
road = step.road.text().ifEmpty { step.cue.text() },
)
}
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
@Composable
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
// Same finish as every other button on the bar, in the accent colour; the text runs a step
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
GsIconBox(
onClick,
Modifier.widthIn(max = 340.dp).height(44.dp),
colors = gsColors(GsTone.Primary),
) {
Row(
Modifier.padding(NAV_ITEM_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CarGlyph(turn.icon, Modifier.size(28.dp))
if (turn.distance.isNotEmpty()) {
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
}
if (turn.road.isNotEmpty()) {
Text(
turn.road,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
@Composable
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
val context = LocalContext.current.applicationContext
val muted by CarCalls.muted.collectAsState()
val elapsed by produceState(0L, call.since) {
while (true) {
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
}
}
val scheme = MaterialTheme.colorScheme
Surface(
modifier = modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = scheme.secondaryContainer,
contentColor = scheme.onSecondaryContainer,
) {
Row(
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Text(
call.caller?.label ?: "On a call",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
"%d:%02d".format(elapsed / 60, elapsed % 60),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
)
Spacer(Modifier.width(2.dp))
GsIconButton(
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
if (muted) "Unmute" else "Mute",
CarCalls::toggleMute,
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
)
GsIconButton(
MediaIcons.CallEnd, "End call", { CarCalls.end(context) },
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
)
}
}
}
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
private fun nowPlayingLabel(title: String, artist: String): String =
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
.ifEmpty { "Nothing playing" }
/**
* The player in the nav bar, on every screen. Art and title open the full media screen;
* play/pause and skip work right here. The dock button moves the player into the column beside
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
*/
@Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return
// Docked beside the map, the player is already on screen - the pill steps aside for it.
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
// The media screen is the full player already; the pill would only repeat its controls.
if (navigator.current == CarScreen.Media) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
val position by produceState(now.currentPosition(), now) {
while (true) {
value = now.currentPosition()
if (!now.playing) break
delay(1000)
}
}
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
// goes and the pill settles to just the controls - unless the driver keeps the title up.
val keepTitle by CarSettings.playerTitle.collectAsState()
var announcing by remember { mutableStateOf(true) }
LaunchedEffect(now.title, now.artist) {
announcing = true
delay(TITLE_SHOWN_MS)
announcing = false
}
val showTitle = keepTitle || announcing
val scheme = MaterialTheme.colorScheme
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
Surface(
modifier = modifier
.animateContentSize()
// With the title up, all the room the bar has, so as much of it shows as can.
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
contentColor = onPill,
) {
Box {
Row(
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
// Art (and the title while it shows) opens the full media screen.
Row(
Modifier
.then(if (showTitle) Modifier.weight(1f) else Modifier)
.fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
if (showTitle) {
Text(
nowPlayingLabel(now.title, now.artist),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (!showTitle) {
GsIconButton(
MediaIcons.Previous, "Previous", media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
media::togglePlay,
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
)
if (!showTitle) {
GsIconButton(
MediaIcons.Next, "Next", media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
size = 36.dp, iconSize = 22.dp,
)
}
if (now.durationMs > 0) {
// matchParentSize, so the line follows the pill's width rather than widening it.
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
Box(
Modifier
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
.height(2.dp)
.background(onPill.copy(alpha = 0.7f)),
)
}
}
}
}
}
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
* bolt, so this is drawn rather than a vector asset.
*/
@Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
androidx.compose.foundation.Canvas(modifier) {
val w = size.width
val h = size.height
val bolt = androidx.compose.ui.graphics.Path().apply {
moveTo(w * 0.58f, 0f)
lineTo(w * 0.12f, h * 0.58f)
lineTo(w * 0.46f, h * 0.58f)
lineTo(w * 0.42f, h)
lineTo(w * 0.88f, h * 0.40f)
lineTo(w * 0.54f, h * 0.40f)
close()
}
drawPath(bolt, tint)
}
}
@@ -358,7 +358,7 @@ fun CarUi() {
} }
} }
private val NAV_BAR_HEIGHT = 56.dp internal val NAV_BAR_HEIGHT = 56.dp
/** /**
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows, * How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
@@ -480,568 +480,6 @@ private fun DebugBuildBadge(modifier: Modifier = Modifier) {
} }
} }
/**
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
* back at all, since a car touchscreen has no hardware button for it.
*/
@Composable
private fun BreadcrumbBar(navigator: CarNavigator) {
val trail = navigator.trail
if (trail.size <= 1) return
Row(
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex
Text(
crumb.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = if (isLast) Modifier else Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { navigator.jumpTo(index) }
.padding(horizontal = 4.dp, vertical = 2.dp),
)
if (!isLast) {
Text(
"›",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
}
}
/**
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
* more room.
*/
@Composable
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val now by rememberNow()
val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data
val pinnedIds by CarSettings.pinnedApps.collectAsState()
val entries = rememberAllEntries()
val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
}
val frame by CarEnvironment.frame.collectAsState()
val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of
// these two from three screens deep in a media app shouldn't mean reading it
// first.
NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
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.
// The map app lives on the Home button, so it never gets a second icon here.
val runningEntries = running.keys
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
.mapNotNull { id -> entries.find { it.componentId == id } }
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
// Next turn first, off the map only; the player shares the space when it fits,
// and gives way to the turn when it doesn't.
// A call takes the player's place until it ends.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
if (call != null) {
NavCallPill(call, Modifier.weight(1f, fill = false))
} else if (turn == null || roomForBoth) {
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
}
}
}
// Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above.
// The clock is also the notifications button: the dashboard lists them under the
// calendar and weather, and a dot here marks anything unread.
val unread by CarNotifications.unread.collectAsState()
NavChip(
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
{ navigator.dashboard() },
) {
if (unread > 0) {
Box(
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-2).dp)
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
if (battery.percent >= 0) {
Text(
"${battery.percent}%",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (battery.charging) {
ChargingGlyph(Modifier.size(13.dp))
}
}
weatherData?.let { w ->
Text(
" · ${w.temp}°",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
/**
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
*/
@Composable
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
val selected = navigator.current == CarScreen.Home
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
GsIconBox(
{ navigator.home() },
colors = navColors(selected),
latched = selected,
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
) {
val icon = navEntry?.icon
if (icon != null) {
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
}
} else {
Icon(
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
)
}
}
}
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
@Composable
private fun NavDivider() {
Box(
Modifier
.padding(horizontal = 10.dp)
.width(1.dp)
.height(28.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/**
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
* already open; a long press opens its menu (pin, unpin, close).
*/
@Composable
private fun NavEntryIcon(
entry: Entry,
running: Map<String, RunningSlot>,
navigator: CarNavigator,
frame: CarEnvironment.Frame,
) {
val id = entry.componentId ?: return
val slot = running[id]
Box(Modifier.padding(horizontal = 3.dp)) {
NavAppIcon(
entry,
open = slot != null,
showing = slot != null && slot.isShowing(navigator.current),
onLongClick = { navigator.showAppMenu(id) },
) { openApp(entry, slot, navigator, frame) }
}
}
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
@Composable
private fun NavAppIcon(
entry: Entry,
open: Boolean,
showing: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
GsIconBox(
onClick,
Modifier.size(44.dp),
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
latched = showing,
onLongClick = onLongClick,
) {
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
if (open) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 2.dp)
.size(width = 12.dp, height = 3.dp)
.clip(RoundedCornerShape(50))
.background(scheme.primary),
)
}
}
}
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
}
}
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
@Composable
private fun navColors(selected: Boolean): GsColors {
val s = MaterialTheme.colorScheme
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
else GsColors(s.surfaceContainerHighest, s.onSurface)
}
/**
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
* two buttons beside it.
*/
@Composable
private fun NavAssistantIcon() {
val state by CarAssistant.state.collectAsState()
val active = state.phase != CarAssistant.Phase.IDLE
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
private val NAV_ITEM_PADDING = 8.dp
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
@Composable
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
while (true) {
value = System.currentTimeMillis()
delay(60_000 - value % 60_000)
}
}
/** How long the pill shows a new song's title before settling to just the controls. */
private const val TITLE_SHOWN_MS = 3_000L
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
/** The next manoeuvre, as the running nav app last described it. */
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
/**
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
* while it's running, whether or not the map is the screen being shown.
*/
@Composable
private fun rememberNextTurn(): NextTurn? {
val status by CarServices.nav.status.collectAsState()
val template by CarServices.nav.template.collectAsState()
// Read so a change of units redraws the distance straight away, not at the next update.
CarSettings.units.collectAsState().value
if (status.phase != CarAppConnection.Phase.RUNNING) return null
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
if (routing.isLoading) return null
val step = routing.currentStep ?: return null
return NextTurn(
icon = step.maneuver?.icon,
distance = routing.currentDistance.display(),
road = step.road.text().ifEmpty { step.cue.text() },
)
}
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
@Composable
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
// Same finish as every other button on the bar, in the accent colour; the text runs a step
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
GsIconBox(
onClick,
Modifier.widthIn(max = 340.dp).height(44.dp),
colors = gsColors(GsTone.Primary),
) {
Row(
Modifier.padding(NAV_ITEM_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CarGlyph(turn.icon, Modifier.size(28.dp))
if (turn.distance.isNotEmpty()) {
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
}
if (turn.road.isNotEmpty()) {
Text(
turn.road,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
@Composable
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
val context = LocalContext.current.applicationContext
val muted by CarCalls.muted.collectAsState()
val elapsed by produceState(0L, call.since) {
while (true) {
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
}
}
val scheme = MaterialTheme.colorScheme
Surface(
modifier = modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = scheme.secondaryContainer,
contentColor = scheme.onSecondaryContainer,
) {
Row(
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Text(
call.caller?.label ?: "On a call",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
"%d:%02d".format(elapsed / 60, elapsed % 60),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
)
Spacer(Modifier.width(2.dp))
GsIconButton(
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
if (muted) "Unmute" else "Mute",
CarCalls::toggleMute,
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
)
GsIconButton(
MediaIcons.CallEnd, "End call", { CarCalls.end(context) },
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
)
}
}
}
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
private fun nowPlayingLabel(title: String, artist: String): String =
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
.ifEmpty { "Nothing playing" }
/**
* The player in the nav bar, on every screen. Art and title open the full media screen;
* play/pause and skip work right here. The dock button moves the player into the column beside
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
*/
@Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return
// Docked beside the map, the player is already on screen - the pill steps aside for it.
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
// The media screen is the full player already; the pill would only repeat its controls.
if (navigator.current == CarScreen.Media) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
val position by produceState(now.currentPosition(), now) {
while (true) {
value = now.currentPosition()
if (!now.playing) break
delay(1000)
}
}
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
// goes and the pill settles to just the controls - unless the driver keeps the title up.
val keepTitle by CarSettings.playerTitle.collectAsState()
var announcing by remember { mutableStateOf(true) }
LaunchedEffect(now.title, now.artist) {
announcing = true
delay(TITLE_SHOWN_MS)
announcing = false
}
val showTitle = keepTitle || announcing
val scheme = MaterialTheme.colorScheme
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
Surface(
modifier = modifier
.animateContentSize()
// With the title up, all the room the bar has, so as much of it shows as can.
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
contentColor = onPill,
) {
Box {
Row(
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
// Art (and the title while it shows) opens the full media screen.
Row(
Modifier
.then(if (showTitle) Modifier.weight(1f) else Modifier)
.fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
if (showTitle) {
Text(
nowPlayingLabel(now.title, now.artist),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (!showTitle) {
GsIconButton(
MediaIcons.Previous, "Previous", media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
media::togglePlay,
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
)
if (!showTitle) {
GsIconButton(
MediaIcons.Next, "Next", media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
size = 36.dp, iconSize = 22.dp,
)
}
if (now.durationMs > 0) {
// matchParentSize, so the line follows the pill's width rather than widening it.
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
Box(
Modifier
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
.height(2.dp)
.background(onPill.copy(alpha = 0.7f)),
)
}
}
}
}
}
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
* bolt, so this is drawn rather than a vector asset.
*/
@Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
androidx.compose.foundation.Canvas(modifier) {
val w = size.width
val h = size.height
val bolt = androidx.compose.ui.graphics.Path().apply {
moveTo(w * 0.58f, 0f)
lineTo(w * 0.12f, h * 0.58f)
lineTo(w * 0.46f, h * 0.58f)
lineTo(w * 0.42f, h)
lineTo(w * 0.88f, h * 0.40f)
lineTo(w * 0.54f, h * 0.40f)
close()
}
drawPath(bolt, tint)
}
}
/** Stands in for the phone's vibration motor on the car screen: does nothing. */ /** Stands in for the phone's vibration motor on the car screen: does nothing. */
private object NoHaptics : HapticFeedback { private object NoHaptics : HapticFeedback {
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {} override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
@@ -0,0 +1,232 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import android.provider.Settings
import android.content.Intent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaArt
import app.seb3thehacker.gearslip.media.LyricsResult
import app.seb3thehacker.gearslip.media.Lyrics
import app.seb3thehacker.gearslip.media.MediaEntry
import app.seb3thehacker.gearslip.media.NowPlaying
import app.seb3thehacker.gearslip.media.QueueTrack
import android.media.session.PlaybackState
import kotlinx.coroutines.delay
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
// The media screen's browse list, up-next queue, rows and icons. Split out of MediaScreen.kt.
@Composable
internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
Column(modifier) {
// At the library root there's no trail and nowhere to go up to, so there's nothing this
// row could say that the app's name in the top-left corner hasn't already - showing it
// again here (as "Browse", or as the app's own name) was just the same label twice.
if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
}
browse.trail.lastOrNull()?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
when {
browse.loading -> Notice("Loading…", null)
browse.unavailable -> Notice(
"$appLabel keeps its library to itself.",
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
)
browse.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
}
}
}
}
// --- up next -----------------------------------------------------------------------------------
/**
* Whatever the app has queued up, starting right after the track that's playing now - not the
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/
@Composable
internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
val upNext = remember(queue, now.activeQueueItemId) {
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
if (current >= 0) queue.drop(current + 1) else queue
}
Column(modifier) {
if (upNext.isEmpty()) {
Notice(
"Nothing queued.",
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
"not every app tells Gearslip what comes next.",
)
} else {
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
}
}
}
}
@Composable
private fun QueueRow(track: QueueTrack, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(track.iconBitmap, track.iconUri) {
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
}
Surface(
onClick = { media.playQueueItem(track) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (entry.browsable && entry.playable) {
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
}
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
}
/** A row's artwork, or a folder or note in its place when the app sends none. */
@Composable
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
Box(
Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else Icon(fallback, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(30.dp))
}
}
/** Material's transport icons, from their published path data (they live in the extended set). */
internal object MediaIcons {
val MusicNote = icon("MusicNote", "M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z")
val Folder = icon("Folder", "M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z")
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
/** The 3x3 app grid; not in material-icons-core. */
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
/** A screen with a filled right-hand panel: "put the player beside the map". */
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
val CallEnd = icon("CallEnd", "M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z")
val Mic = icon("Mic", "M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z")
val MicOff = icon("MicOff", "M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z")
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f,
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
}
@@ -444,7 +444,7 @@ private sealed interface LyricsState {
} }
@Composable @Composable
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) { internal fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
Column( Column(
Modifier.fillMaxSize().padding(horizontal = 24.dp), Modifier.fillMaxSize().padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -568,157 +568,3 @@ private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier)
// --- browsing -------------------------------------------------------------------------------- // --- browsing --------------------------------------------------------------------------------
@Composable
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
Column(modifier) {
// At the library root there's no trail and nowhere to go up to, so there's nothing this
// row could say that the app's name in the top-left corner hasn't already - showing it
// again here (as "Browse", or as the app's own name) was just the same label twice.
if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
}
browse.trail.lastOrNull()?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
when {
browse.loading -> Notice("Loading…", null)
browse.unavailable -> Notice(
"$appLabel keeps its library to itself.",
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
)
browse.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
}
}
}
}
// --- up next -----------------------------------------------------------------------------------
/**
* Whatever the app has queued up, starting right after the track that's playing now - not the
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/
@Composable
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
val upNext = remember(queue, now.activeQueueItemId) {
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
if (current >= 0) queue.drop(current + 1) else queue
}
Column(modifier) {
if (upNext.isEmpty()) {
Notice(
"Nothing queued.",
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
"not every app tells Gearslip what comes next.",
)
} else {
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
}
}
}
}
@Composable
private fun QueueRow(track: QueueTrack, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(track.iconBitmap, track.iconUri) {
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
}
Surface(
onClick = { media.playQueueItem(track) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (entry.browsable && entry.playable) {
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
}
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
}
/** A row's artwork, or a folder or note in its place when the app sends none. */
@Composable
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
Box(
Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else Icon(fallback, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(30.dp))
}
}
/** Material's transport icons, from their published path data (they live in the extended set). */
internal object MediaIcons {
val MusicNote = icon("MusicNote", "M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z")
val Folder = icon("Folder", "M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z")
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
/** The 3x3 app grid; not in material-icons-core. */
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
/** A screen with a filled right-hand panel: "put the player beside the map". */
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
val CallEnd = icon("CallEnd", "M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z")
val Mic = icon("Mic", "M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z")
val MicOff = icon("MicOff", "M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z")
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f,
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
}
@@ -293,7 +293,7 @@ internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, lar
} }
/** A row with nothing to show: no words, no picture, nothing to tap. */ /** A row with nothing to show: no words, no picture, nothing to tap. */
private fun CarRow.isBlank(): Boolean = internal fun CarRow.isBlank(): Boolean =
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null && title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty() toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
@@ -514,315 +514,6 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
// --- lists, grids and panes --------------------------------------------------------------------- // --- lists, grids and panes ---------------------------------------------------------------------
@Composable
internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
style = ChromeType.title,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@Composable
internal fun ItemListColumn(
single: ItemList?,
sectioned: List<ItemList>? = null,
modifier: Modifier = Modifier,
) {
val rows = buildList {
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
val onSelected = single?.onSelectedDelegate
val chosen = single?.selectedIndex ?: -1
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
itemsIndexed(rows) { index, row ->
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
}
}
}
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
@Composable
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return
LazyVerticalGrid(
columns = GridCells.Adaptive(84.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items) { item -> GridTile(item, iconSize = 36.dp) }
}
}
/**
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
* instead of wrapping into a grid and wants its full-size cover art.
*/
@Composable
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
Column(
modifier
.clickable { item.onClickDelegate.click("grid item") }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CarGlyph(item.image, Modifier.size(iconSize))
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
style = ChromeType.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = item.text.text()
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
* a single action counts itself down rather than waiting to be pressed, on the theory that a
* driver glancing at the car screen has already decided. It never applies to more than one
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
*/
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
val card = PlaceCard.of(rows)
Column(modifier) {
card.heading?.let { PlaceHeading(it) }
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
if (card.heading == null && card.buttons.isEmpty()) {
card.details.forEach { RowItem(it, large = true) }
} else {
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
}
}
}
/**
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
* heading and no buttons, and draws exactly as a plain list of rows.
*/
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
companion object {
fun of(rows: List<CarRow>): PlaceCard {
// A short tappable row with an icon and nothing under it is a button in all but name:
// MAPS.ME's "Let's go" and "Save".
val buttons = rows.filter {
it.onClickDelegate != null && it.toggle == null && it.image != null &&
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
// A first row that can't be tapped and has no icon is the place's name and kind.
val heading = rows.firstOrNull()?.takeIf {
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
}
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
}
}
}
@Composable
private fun PlaceHeading(row: CarRow) {
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
row.texts.orEmpty().take(2).forEach {
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
@Composable
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEachIndexed { i, row ->
GsIconBox(
onClick = { row.onClickDelegate.click("row") },
modifier = Modifier.weight(1f),
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
enabled = row.isEnabled,
shape = RoundedCornerShape(24.dp),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CarGlyph(row.image, Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/**
* One fact about a place, tighter than a list row: a small label over its value, or just the
* value when the row has no label. A phone number gets a Call button that asks first.
*/
@Composable
private fun DetailRow(row: CarRow, placeName: String) {
val context = LocalContext.current
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
val label = row.title.text().takeIf { texts.isNotEmpty() }
val values = texts.ifEmpty { listOf(row.title.text()) }
val number = values.firstOrNull { it.isPhoneNumber() }
val canCall = number != null &&
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
Row(
Modifier
.fillMaxWidth()
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
row.image?.let {
CarGlyph(it, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
label?.let {
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
values.take(3).forEach {
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (canCall) {
Spacer(Modifier.width(8.dp))
GsIconBox(
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(24.dp),
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Call", style = ChromeType.body)
}
}
}
ActionRow(row.actions.orEmpty())
}
}
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
private fun String.isPhoneNumber(): Boolean {
val t = trim()
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
return t.count { it.isDigit() } in 7..15
}
@Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle
// Shown at once and corrected when the app answers with its own template, so the switch
// never lags a tap; keyed on what the app last said so its answer wins.
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
Row(
Modifier
.fillMaxWidth()
.let {
if (!clickable) it else it.clickable {
when {
toggle != null -> {
checked = !checked
toggle.onCheckedChangeDelegate.checkedChanged(checked)
}
selection != null -> selection.onPick()
else -> row.onClickDelegate.click("row")
}
}
}
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selection != null) {
RadioButton(selected = selection.selected, onClick = null)
Spacer(Modifier.width(12.dp))
}
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
// large only changes how much room a row gets (more lines, a bigger icon above) - the
// type itself is the same scale as every other row, so a pane's one row and a plain
// list's tenth row read as the same kind of text.
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = ChromeType.title,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text(
line.text(),
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
if (toggle != null) {
Spacer(Modifier.width(8.dp))
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
}
}
}
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
/**
* Whether this colour stands out against the background that [content] was chosen for: at least
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
*/
private fun Color.readableOn(content: Color): Boolean {
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
val a = luminance() + 0.05f
val b = background.luminance() + 0.05f
return maxOf(a, b) / minOf(a, b) >= 3f
}
internal fun Color.luminanceIsDark(): Boolean = internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
@@ -0,0 +1,391 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.foundation.Image
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import app.seb3thehacker.gearslip.host.selected
import app.seb3thehacker.gearslip.host.checkedChanged
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.draw.alpha
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.Pane
import androidx.car.app.model.Row as CarRow
import androidx.car.app.model.Template
import app.seb3thehacker.gearslip.host.click
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.image
import app.seb3thehacker.gearslip.host.isDrawable
import app.seb3thehacker.gearslip.host.standardType
import app.seb3thehacker.gearslip.host.text
// Lists, grids, panes and rows of templated apps. Split out of TemplateParts.kt.
@Composable
internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
style = ChromeType.title,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@Composable
internal fun ItemListColumn(
single: ItemList?,
sectioned: List<ItemList>? = null,
modifier: Modifier = Modifier,
) {
val rows = buildList {
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
val onSelected = single?.onSelectedDelegate
val chosen = single?.selectedIndex ?: -1
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
itemsIndexed(rows) { index, row ->
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
}
}
}
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
@Composable
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return
LazyVerticalGrid(
columns = GridCells.Adaptive(84.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items) { item -> GridTile(item, iconSize = 36.dp) }
}
}
/**
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
* instead of wrapping into a grid and wants its full-size cover art.
*/
@Composable
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
Column(
modifier
.clickable { item.onClickDelegate.click("grid item") }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CarGlyph(item.image, Modifier.size(iconSize))
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
style = ChromeType.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = item.text.text()
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
* a single action counts itself down rather than waiting to be pressed, on the theory that a
* driver glancing at the car screen has already decided. It never applies to more than one
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
*/
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
val card = PlaceCard.of(rows)
Column(modifier) {
card.heading?.let { PlaceHeading(it) }
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
if (card.heading == null && card.buttons.isEmpty()) {
card.details.forEach { RowItem(it, large = true) }
} else {
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
}
}
}
/**
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
* heading and no buttons, and draws exactly as a plain list of rows.
*/
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
companion object {
fun of(rows: List<CarRow>): PlaceCard {
// A short tappable row with an icon and nothing under it is a button in all but name:
// MAPS.ME's "Let's go" and "Save".
val buttons = rows.filter {
it.onClickDelegate != null && it.toggle == null && it.image != null &&
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
// A first row that can't be tapped and has no icon is the place's name and kind.
val heading = rows.firstOrNull()?.takeIf {
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
}
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
}
}
}
@Composable
private fun PlaceHeading(row: CarRow) {
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
row.texts.orEmpty().take(2).forEach {
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
@Composable
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEachIndexed { i, row ->
GsIconBox(
onClick = { row.onClickDelegate.click("row") },
modifier = Modifier.weight(1f),
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
enabled = row.isEnabled,
shape = RoundedCornerShape(24.dp),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CarGlyph(row.image, Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/**
* One fact about a place, tighter than a list row: a small label over its value, or just the
* value when the row has no label. A phone number gets a Call button that asks first.
*/
@Composable
private fun DetailRow(row: CarRow, placeName: String) {
val context = LocalContext.current
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
val label = row.title.text().takeIf { texts.isNotEmpty() }
val values = texts.ifEmpty { listOf(row.title.text()) }
val number = values.firstOrNull { it.isPhoneNumber() }
val canCall = number != null &&
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
Row(
Modifier
.fillMaxWidth()
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
row.image?.let {
CarGlyph(it, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
label?.let {
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
values.take(3).forEach {
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (canCall) {
Spacer(Modifier.width(8.dp))
GsIconBox(
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(24.dp),
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Call", style = ChromeType.body)
}
}
}
ActionRow(row.actions.orEmpty())
}
}
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
private fun String.isPhoneNumber(): Boolean {
val t = trim()
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
return t.count { it.isDigit() } in 7..15
}
@Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle
// Shown at once and corrected when the app answers with its own template, so the switch
// never lags a tap; keyed on what the app last said so its answer wins.
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
Row(
Modifier
.fillMaxWidth()
.let {
if (!clickable) it else it.clickable {
when {
toggle != null -> {
checked = !checked
toggle.onCheckedChangeDelegate.checkedChanged(checked)
}
selection != null -> selection.onPick()
else -> row.onClickDelegate.click("row")
}
}
}
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selection != null) {
RadioButton(selected = selection.selected, onClick = null)
Spacer(Modifier.width(12.dp))
}
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
// large only changes how much room a row gets (more lines, a bigger icon above) - the
// type itself is the same scale as every other row, so a pane's one row and a plain
// list's tenth row read as the same kind of text.
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = ChromeType.title,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text(
line.text(),
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
if (toggle != null) {
Spacer(Modifier.width(8.dp))
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
}
}
}
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
/**
* Whether this colour stands out against the background that [content] was chosen for: at least
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
*/
internal fun Color.readableOn(content: Color): Boolean {
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
val a = luminance() + 0.05f
val b = background.luminance() + 0.05f
return maxOf(a, b) / minOf(a, b) >= 3f
}