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 a5c9ad8..a16dad4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -163,6 +163,50 @@ internal fun rememberAllEntries(): List { return remember(installed) { BuiltInApps.entries + installed.orEmpty() } } +/** The launcher's groups, in the order they're laid out. */ +private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS } + +private fun groupOf(entry: Entry, mediaPackages: Set): AppGroup { + entry.builtIn?.let { + return when (it) { + BuiltInApps.Phone -> AppGroup.PHONE + BuiltInApps.Web -> AppGroup.WEB + BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING + else -> AppGroup.SETTINGS + } + } + if (entry.media != null) return AppGroup.MUSIC + if (entry.messaging != null) return AppGroup.MESSAGING + val app = entry.template ?: return AppGroup.OTHER + // A media app's "ยท Browse" tile sits next to its player. + if (app.component.packageName in mediaPackages) return AppGroup.MUSIC + return when (app.kind) { + "calling" -> AppGroup.PHONE + "navigation", "poi", "parking", "charging" -> AppGroup.MAPS + "messaging" -> AppGroup.MESSAGING + "settings" -> AppGroup.SETTINGS + else -> AppGroup.OTHER + } +} + +/** + * 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 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 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] } + val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator() + return grouped.map { if (it.rank() != null) moved.next() else it } +} + /** 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 } @@ -190,7 +234,7 @@ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnviro entry.messaging?.let { navigator.messages(it.packageName, it.label) } } -/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */ +/** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */ @Composable fun CarLauncher() { val navigator = LocalCarNavigator.current @@ -216,11 +260,8 @@ fun CarLauncher() { ) { openApp(entry, id?.let { running[it] }, navigator, frame) } } - // Gearslip's own apps first, then everything installed, then Settings last where it's expected. - val builtIns = all.filter { it.builtIn != null && (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) } - val tiles = builtIns.filter { it.builtIn !== BuiltInApps.Settings }.map(::tileOf) + - all.filter { it.builtIn == null }.map(::tileOf) + - builtIns.filter { it.builtIn === BuiltInApps.Settings }.map(::tileOf) + val appOrder by CarSettings.appOrder.collectAsState() + val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf) LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 108.dp), diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index 002c46b..588bab4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -34,6 +34,7 @@ object CarSettings { private const val KEY_THEME = "car_app_theme" private const val KEY_UI_THEME = "car_ui_theme" private const val KEY_PINNED = "car_pinned_apps" + private const val KEY_APP_ORDER = "car_app_order" private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features" private const val KEY_VOICE_ASSISTANT = "car_voice_assistant" private const val KEY_CAR_SENSORS = "car_sensors_enabled" @@ -66,6 +67,7 @@ object CarSettings { /** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */ val uiTheme: StateFlow = uiThemeFlow private val pinnedFlow = MutableStateFlow>(emptySet()) + private val appOrderFlow = MutableStateFlow>(emptyList()) private val experimentalFeaturesFlow = MutableStateFlow(false) private val voiceAssistantFlow = MutableStateFlow(false) private val carSensorsFlow = MutableStateFlow(false) @@ -120,6 +122,9 @@ object CarSettings { /** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */ val pinnedApps: StateFlow> = pinnedFlow + /** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */ + val appOrder: StateFlow> = appOrderFlow + /** Flattened component names of the map and media apps used last, brought back on connect. */ val lastNav: StateFlow = lastNavFlow val lastMedia: StateFlow = lastMediaFlow @@ -197,6 +202,8 @@ object CarSettings { }.getOrDefault(NightMode.AUTO) pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "") .split(",").filter { it.isNotEmpty() }.toSet() + appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "") + .split(",").filter { it.isNotEmpty() } experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true" voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true" carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" @@ -286,4 +293,9 @@ object CarSettings { pinnedFlow.value = next AppSettings.putString(app, KEY_PINNED, next.joinToString(",")) } + + fun setAppOrder(ids: List) { + appOrderFlow.value = ids + AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(",")) + } } 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 d74c4ac..85b8898 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt @@ -148,8 +148,8 @@ internal fun closeApp(slot: RunningSlot, navigator: CarNavigator) { } /** - * What a long press on an app offers - pin it to or unpin it from the nav bar, and close it if - * it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that + * What a long press on an app offers - pin it to or unpin it from the nav bar, move it along the + * launcher, and close it if it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that * dismisses it. Drawn in the car UI's own layout rather than a system popup window, which the * car's virtual display has no reason to host well. */ @@ -218,6 +218,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map) style = MaterialTheme.typography.titleMedium, ) } + if (navigator.current == CarScreen.Apps) MoveButtons(id) if (slot != null) { GsButton( onClick = { @@ -237,3 +238,41 @@ internal fun AppMenu(navigator: CarNavigator, running: Map) } } } + +/** + * Moves the app one place left or right on the launcher. The menu stays open so a tile can be + * walked several places, and the grid behind the scrim shows where it went. + */ +@Composable +private fun MoveButtons(id: String) { + val custom by CarSettings.appOrder.collectAsState() + 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(next) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GsButton( + onClick = { move(-1) }, + tone = GsTone.Tonal, + enabled = index > 0, + modifier = Modifier.weight(1f).height(56.dp), + ) { Text("Move left", style = MaterialTheme.typography.titleMedium) } + GsButton( + onClick = { move(1) }, + tone = GsTone.Tonal, + enabled = index < order.lastIndex, + modifier = Modifier.weight(1f).height(56.dp), + ) { Text("Move right", style = MaterialTheme.typography.titleMedium) } + } + if (custom.isNotEmpty()) { + GsButton( + onClick = { CarSettings.setAppOrder(emptyList()) }, + tone = GsTone.Neutral, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { Text("Reset app order", style = MaterialTheme.typography.titleMedium) } + } +}