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
+74 -83

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,
end = if (LocalCardsAtEnd.current) 0.dp else LocalSidePanelInset.current,
).padding(24.dp),
verticalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
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 -> {
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) }
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) {
// 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
// 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.
Text("Map stopped.", style = MaterialTheme.typography.titleMedium)
GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") }
GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") }
Text("Map stopped", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
"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 {
// Autostart is still picking one - a navigation app is installed, so this resolves
// 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,
* 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. */
private enum class AppGroup { MAPS, MUSIC, PHONE, MESSAGING, OTHER, WEB, SCREEN_SHARING, 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 enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT }
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
entry.builtIn?.let {
@@ -231,29 +218,23 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
}
/**
* The launcher's tiles under their headings: grouped by [AppGroup], alphabetical inside a group
* with Settings last of all, then with the driver's own moves from [custom] laid over it. Moved
* apps only trade places among themselves and inside their own heading, so an app installed later
* still lands in its group and nothing ends up under the wrong heading.
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
* group with Settings last of all, then with the driver's own moves from [custom] laid over it.
* Moved apps only trade places among themselves, so an app installed later still lands in its group.
*/
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 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() }),
)
if (custom.isEmpty()) return grouped
val rank = custom.withIndex().associate { (i, id) -> id to i }
fun Entry.rank() = componentId?.let { rank[it] }
return sorted.groupBy { groupOf(it, mediaPackages).heading }.map { (heading, entries) ->
val moved = entries.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
LauncherSection(heading, entries.map { if (it.rank() != null) moved.next() else it })
}
val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
return grouped.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. */
internal fun findEntry(id: String): Entry? =
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
@@ -306,27 +287,16 @@ fun CarLauncher() {
}
val appOrder by CarSettings.appOrder.collectAsState()
val sections = launcherSections(all, showVehicleData, appOrder)
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 116.dp),
columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 20.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(20.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
sections.forEach { section ->
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) }
}
items(tiles) { AppTile(it) }
}
}
@@ -350,8 +320,8 @@ private fun AppTile(tile: Tile) {
GsIconBox(
onClick = tile.onClick,
onLongClick = tile.onLongClick,
modifier = Modifier.height(120.dp).gsPanelShadow(shape),
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
modifier = Modifier.height(120.dp),
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
shape = shape,
) {
Box(Modifier.fillMaxSize()) {
@@ -368,7 +338,7 @@ private fun AppTile(tile: Tile) {
BrokenBadge(
22.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
private fun TileIcon(tile: Tile) {
when {
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(56.dp))
tile.glyph != null -> Icon(tile.glyph, 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(52.dp))
}
}
@@ -707,33 +707,39 @@ 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
* Home, the cover taking whatever the title and controls leave, so every tab keeps the song in
* sight and the controls in one place.
* The player beside the browse list, the queue and the lyrics: the cover as big as the controls
* leave room for, with the title and artist beside it rather than under it, so the cover gets the
* height; then the seek bar and the controls along the bottom.
*/
@Composable
private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphics.Bitmap?, modifier: Modifier) {
val shape = RoundedCornerShape(24.dp)
Surface(modifier.gsPanelShadow(shape), shape = shape, color = MaterialTheme.colorScheme.surfaceContainer) {
AlbumGlow(art, Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().padding(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CoverArt(art, Modifier.weight(1f).fillMaxWidth(), Alignment.BottomCenter)
Spacer(Modifier.height(10.dp))
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
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())
Column(modifier) {
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val cover = minOf(maxHeight, maxWidth * 0.62f).coerceAtLeast(0.dp)
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.Bottom) {
CoverArt(art, Modifier.size(cover))
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f).padding(bottom = 4.dp)) {
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 3, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist,
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
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())
}
}
@@ -267,16 +267,13 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
@Composable
private fun MoveButtons(id: String) {
val custom by CarSettings.appOrder.collectAsState()
// Only within the app's own heading: past its edge it would land under another one.
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 order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom).mapNotNull { it.componentId }
val index = order.indexOf(id)
if (index < 0) return
fun move(by: Int) {
val next = order.toMutableList()
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)) {
GsButton(