diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt index 292057f..af48399 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt index 3902684..46a135d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -189,20 +189,7 @@ internal fun rememberAllEntries(): List { } /** 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) +private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT } private fun groupOf(entry: Entry, mediaPackages: Set): AppGroup { entry.builtIn?.let { @@ -231,29 +218,23 @@ private fun groupOf(entry: Entry, mediaPackages: Set): 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, showVehicleData: Boolean, custom: List): List { +internal fun launcherOrder(all: List, showVehicleData: Boolean, custom: List): List { 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({ 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, showVehicleData: Boolean, custom: List): List = - 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)) } } 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 9135927..98f29d8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -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()) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt index 8a5b293..0a50ca5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt @@ -267,16 +267,13 @@ internal fun AppMenu(navigator: CarNavigator, running: Map) @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(