diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt new file mode 100644 index 0000000..adfbfb2 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt @@ -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, running: Map) { + 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, + 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 = 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) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 761dcf4..b773721 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -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, @@ -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, running: Map) { - 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, - 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 = 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. */ private object NoHaptics : HapticFeedback { override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt new file mode 100644 index 0000000..4e66055 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt @@ -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, 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() +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index d671de5..30f9749 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -444,7 +444,7 @@ private sealed interface LyricsState { } @Composable -private fun Notice(title: String, detail: String?, action: Pair Unit>? = null) { +internal fun Notice(title: String, detail: String?, action: Pair Unit>? = null) { Column( Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalArrangement = Arrangement.Center, @@ -568,157 +568,3 @@ private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) // --- 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, 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() -} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index c9d31be..3afb133 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -293,7 +293,7 @@ internal fun ActionRow(actions: List, modifier: Modifier = Modifier, lar } /** 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 && 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 --------------------------------------------------------------------- -@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? = null, - modifier: Modifier = Modifier, -) { - val rows = buildList { - single?.items?.filterIsInstance()?.let(::addAll) - sectioned?.forEach { list -> addAll(list.items.filterIsInstance()) } - } - 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().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, val details: List) { - companion object { - fun of(rows: List): 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, 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 = (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateRows.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateRows.kt new file mode 100644 index 0000000..7e4aeb7 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateRows.kt @@ -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? = null, + modifier: Modifier = Modifier, +) { + val rows = buildList { + single?.items?.filterIsInstance()?.let(::addAll) + sectioned?.forEach { list -> addAll(list.items.filterIsInstance()) } + } + 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().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, val details: List) { + companion object { + fun of(rows: List): 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, 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 +}