car: undo the launcher's headings, the media player beside the tabs drops its card for a bigger cover with the title beside it, and the map-stopped buttons grow

This commit is contained in:
Seb3thehacker committed 2026-10-26 01:31:08 -07:00
1 parent 324112ca8b
commit 74f557524f
4 files changed
+68 -77

No files matched your search

@@ -161,7 +161,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
start = if (LocalCardsAtEnd.current) LocalSidePanelInset.current else 0.dp, start = if (LocalCardsAtEnd.current) LocalSidePanelInset.current else 0.dp,
end = if (LocalCardsAtEnd.current) 0.dp else LocalSidePanelInset.current, end = if (LocalCardsAtEnd.current) 0.dp else LocalSidePanelInset.current,
).padding(24.dp), ).padding(24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
val hasLast = CarSettings.lastNav.value != null val hasLast = CarSettings.lastNav.value != null
@@ -173,7 +173,8 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> { CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium) Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium)
status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
GsButton(onClick = navigator::apps, tone = GsTone.Tonal) { Text("Choose another app") } Spacer(Modifier.height(8.dp))
StatusButton("Choose another app", GsTone.Primary, navigator::apps)
} }
else -> if (!hasNavApps) { else -> if (!hasNavApps) {
// Nothing to auto-select and nothing to pick from Car apps either - this is the // Nothing to auto-select and nothing to pick from Car apps either - this is the
@@ -188,9 +189,18 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
// IDLE with a last app on record means the driver stopped it themselves - it will // IDLE with a last app on record means the driver stopped it themselves - it will
// not come back on its own, so the screen must offer a way to bring it back rather // not come back on its own, so the screen must offer a way to bring it back rather
// than sit on a "starting" message that never resolves. // than sit on a "starting" message that never resolves.
Text("Map stopped.", style = MaterialTheme.typography.titleMedium) Text("Map stopped", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") } Text(
GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") } "It stays closed until you open it again.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// Side by side, the same size, the likely one lit: one clear choice, not a stack.
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
StatusButton("Reopen the map", GsTone.Primary) { scope.launch { CarServices.reconnectNav(frame) } }
StatusButton("Car apps", GsTone.Tonal) { navigator.apps() }
}
} else { } else {
// Autostart is still picking one - a navigation app is installed, so this resolves // Autostart is still picking one - a navigation app is installed, so this resolves
// to a "Starting..." state itself, in the next frame or two. // to a "Starting..." state itself, in the next frame or two.
@@ -199,6 +209,14 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
} }
} }
/** A big, even button for the map pane's few choices. */
@Composable
private fun StatusButton(label: String, tone: GsTone, onClick: () -> Unit) {
GsButton(onClick = onClick, tone = tone, modifier = Modifier.width(220.dp).height(64.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
}
/** /**
* The player, docked beside the map: the art as large as it fits, then title, progress, transport, * The player, docked beside the map: the art as large as it fits, then title, progress, transport,
* and like, shuffle, repeat and lyrics along the bottom. While it's here the nav bar's pill steps aside on Home, so the controls never show * and like, shuffle, repeat and lyrics along the bottom. While it's here the nav bar's pill steps aside on Home, so the controls never show
@@ -189,20 +189,7 @@ internal fun rememberAllEntries(): List<Entry> {
} }
/** The launcher's groups, in the order they're laid out. */ /** The launcher's groups, in the order they're laid out. */
private enum class AppGroup { MAPS, MUSIC, PHONE, MESSAGING, OTHER, WEB, SCREEN_SHARING, SETTINGS, EXIT } private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT }
/** The heading a group's tiles sit under; calls and messages share one, as do Gearslip's own. */
private val AppGroup.heading: String
get() = when (this) {
AppGroup.MAPS -> "Navigation"
AppGroup.MUSIC -> "Media"
AppGroup.PHONE, AppGroup.MESSAGING -> "Calls and messages"
AppGroup.OTHER -> "Other apps"
else -> "Gearslip"
}
/** One heading on the launcher and the tiles under it, in order. */
internal class LauncherSection(val heading: String, val entries: List<Entry>)
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup { private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
entry.builtIn?.let { entry.builtIn?.let {
@@ -231,28 +218,22 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
} }
/** /**
* The launcher's tiles under their headings: grouped by [AppGroup], alphabetical inside a group * The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
* with Settings last of all, then with the driver's own moves from [custom] laid over it. Moved * group with Settings last of all, then with the driver's own moves from [custom] laid over it.
* apps only trade places among themselves and inside their own heading, so an app installed later * Moved apps only trade places among themselves, so an app installed later still lands in its group.
* still lands in its group and nothing ends up under the wrong heading.
*/ */
internal fun launcherSections(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<LauncherSection> { internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> {
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData } val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData }
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet() val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
val sorted = shown.sortedWith( val grouped = shown.sortedWith(
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }), compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
) )
if (custom.isEmpty()) return grouped
val rank = custom.withIndex().associate { (i, id) -> id to i } val rank = custom.withIndex().associate { (i, id) -> id to i }
fun Entry.rank() = componentId?.let { rank[it] } fun Entry.rank() = componentId?.let { rank[it] }
return sorted.groupBy { groupOf(it, mediaPackages).heading }.map { (heading, entries) -> val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
val moved = entries.filter { it.rank() != null }.sortedBy { it.rank() }.iterator() return grouped.map { if (it.rank() != null) moved.next() else it }
LauncherSection(heading, entries.map { if (it.rank() != null) moved.next() else it })
} }
}
/** The launcher's tiles in the order they're drawn, headings aside. */
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> =
launcherSections(all, showVehicleData, custom).flatMap { it.entries }
/** An app by the id a pin or the running list knows it by, built-in or installed. */ /** An app by the id a pin or the running list knows it by, built-in or installed. */
internal fun findEntry(id: String): Entry? = internal fun findEntry(id: String): Entry? =
@@ -306,27 +287,16 @@ fun CarLauncher() {
} }
val appOrder by CarSettings.appOrder.collectAsState() val appOrder by CarSettings.appOrder.collectAsState()
val sections = launcherSections(all, showVehicleData, appOrder) val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 116.dp), columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 20.dp), contentPadding = PaddingValues(20.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
sections.forEach { section -> items(tiles) { AppTile(it) }
item(span = { GridItemSpan(maxLineSpan) }, contentType = "heading") {
Text(
section.heading,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp, top = 12.dp),
)
}
items(section.entries.map(::tileOf)) { AppTile(it) }
}
} }
} }
@@ -350,8 +320,8 @@ private fun AppTile(tile: Tile) {
GsIconBox( GsIconBox(
onClick = tile.onClick, onClick = tile.onClick,
onLongClick = tile.onLongClick, onLongClick = tile.onLongClick,
modifier = Modifier.height(120.dp).gsPanelShadow(shape), modifier = Modifier.height(120.dp),
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
shape = shape, shape = shape,
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@@ -368,7 +338,7 @@ private fun AppTile(tile: Tile) {
BrokenBadge( BrokenBadge(
22.dp, 22.dp,
Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp) Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp)
.border(2.dp, MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape), .border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape),
) )
} }
} }
@@ -382,8 +352,8 @@ private fun AppTile(tile: Tile) {
@Composable @Composable
private fun TileIcon(tile: Tile) { private fun TileIcon(tile: Tile) {
when { when {
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(56.dp)) tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(56.dp)) tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
} }
} }
@@ -707,35 +707,41 @@ private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modif
} }
/** /**
* The player beside the browse list, the queue and the lyrics: the same card as beside the map on * The player beside the browse list, the queue and the lyrics: the cover as big as the controls
* Home, the cover taking whatever the title and controls leave, so every tab keeps the song in * leave room for, with the title and artist beside it rather than under it, so the cover gets the
* sight and the controls in one place. * height; then the seek bar and the controls along the bottom.
*/ */
@Composable @Composable
private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphics.Bitmap?, modifier: Modifier) { private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphics.Bitmap?, modifier: Modifier) {
val shape = RoundedCornerShape(24.dp) Column(modifier) {
Surface(modifier.gsPanelShadow(shape), shape = shape, color = MaterialTheme.colorScheme.surfaceContainer) { BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
AlbumGlow(art, Modifier.fillMaxSize()) val cover = minOf(maxHeight, maxWidth * 0.62f).coerceAtLeast(0.dp)
Column(Modifier.fillMaxSize().padding(14.dp), horizontalAlignment = Alignment.CenterHorizontally) { Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.Bottom) {
CoverArt(art, Modifier.weight(1f).fillMaxWidth(), Alignment.BottomCenter) CoverArt(art, Modifier.size(cover))
Spacer(Modifier.height(10.dp)) Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f).padding(bottom = 4.dp)) {
Text( Text(
now.title.ifEmpty { "Nothing playing" }, now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis, maxLines = 3, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
now.artist, now.artist,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis, maxLines = 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
) )
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(vertical = 2.dp), showTimes = false)
TransportRow(now, media, skip = 52.dp, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
PlayModeRow(now, media, size = 46.dp, modifier = Modifier.fillMaxWidth())
} }
} }
} }
Spacer(Modifier.height(10.dp))
PlayerProgress(now, media, Modifier.fillMaxWidth())
Spacer(Modifier.height(6.dp))
TransportRow(now, media, skip = 54.dp, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(10.dp))
PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth())
}
}
// --- browsing -------------------------------------------------------------------------------- // --- browsing --------------------------------------------------------------------------------
@@ -267,16 +267,13 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
@Composable @Composable
private fun MoveButtons(id: String) { private fun MoveButtons(id: String) {
val custom by CarSettings.appOrder.collectAsState() val custom by CarSettings.appOrder.collectAsState()
// Only within the app's own heading: past its edge it would land under another one. val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom).mapNotNull { it.componentId }
val sections = launcherSections(rememberAllEntries(), rememberVehicleDataShown(), custom)
val section = sections.find { s -> s.entries.any { it.componentId == id } } ?: return
val order = section.entries.mapNotNull { it.componentId }
val index = order.indexOf(id) val index = order.indexOf(id)
if (index < 0) return if (index < 0) return
fun move(by: Int) { fun move(by: Int) {
val next = order.toMutableList() val next = order.toMutableList()
java.util.Collections.swap(next, index, index + by) java.util.Collections.swap(next, index, index + by)
CarSettings.setAppOrder(sections.flatMap { s -> if (s === section) next else s.entries.mapNotNull { it.componentId } }) CarSettings.setAppOrder(next)
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GsButton( GsButton(