app organization and movement options

This commit is contained in:
Seb3thehacker committed 2026-10-01 20:34:35 -07:00
1 parent a2d9ed7008
commit ff8a6a4cff
3 files changed
+100 -8

No files matched your search

@@ -163,6 +163,50 @@ internal fun rememberAllEntries(): List<Entry> {
return remember(installed) { BuiltInApps.entries + installed.orEmpty() } 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<String>): 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<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 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] }
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. */ /** 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? =
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id } 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) } 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 @Composable
fun CarLauncher() { fun CarLauncher() {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
@@ -216,11 +260,8 @@ fun CarLauncher() {
) { openApp(entry, id?.let { running[it] }, navigator, frame) } ) { openApp(entry, id?.let { running[it] }, navigator, frame) }
} }
// Gearslip's own apps first, then everything installed, then Settings last where it's expected. val appOrder by CarSettings.appOrder.collectAsState()
val builtIns = all.filter { it.builtIn != null && (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) } val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
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)
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp), columns = GridCells.Adaptive(minSize = 108.dp),
@@ -34,6 +34,7 @@ object CarSettings {
private const val KEY_THEME = "car_app_theme" private const val KEY_THEME = "car_app_theme"
private const val KEY_UI_THEME = "car_ui_theme" private const val KEY_UI_THEME = "car_ui_theme"
private const val KEY_PINNED = "car_pinned_apps" 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_EXPERIMENTAL_FEATURES = "car_experimental_features"
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant" private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
private const val KEY_CAR_SENSORS = "car_sensors_enabled" 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. */ /** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
val uiTheme: StateFlow<String> = uiThemeFlow val uiTheme: StateFlow<String> = uiThemeFlow
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet()) private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
private val experimentalFeaturesFlow = MutableStateFlow(false) private val experimentalFeaturesFlow = MutableStateFlow(false)
private val voiceAssistantFlow = MutableStateFlow(false) private val voiceAssistantFlow = MutableStateFlow(false)
private val carSensorsFlow = 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. */ /** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
val pinnedApps: StateFlow<Set<String>> = pinnedFlow val pinnedApps: StateFlow<Set<String>> = pinnedFlow
/** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */
val appOrder: StateFlow<List<String>> = appOrderFlow
/** Flattened component names of the map and media apps used last, brought back on connect. */ /** Flattened component names of the map and media apps used last, brought back on connect. */
val lastNav: StateFlow<String?> = lastNavFlow val lastNav: StateFlow<String?> = lastNavFlow
val lastMedia: StateFlow<String?> = lastMediaFlow val lastMedia: StateFlow<String?> = lastMediaFlow
@@ -197,6 +202,8 @@ object CarSettings {
}.getOrDefault(NightMode.AUTO) }.getOrDefault(NightMode.AUTO)
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "") pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
.split(",").filter { it.isNotEmpty() }.toSet() .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" experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true" voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
@@ -286,4 +293,9 @@ object CarSettings {
pinnedFlow.value = next pinnedFlow.value = next
AppSettings.putString(app, KEY_PINNED, next.joinToString(",")) AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
} }
fun setAppOrder(ids: List<String>) {
appOrderFlow.value = ids
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
}
} }
@@ -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 * What a long press on an app offers - pin it to or unpin it from the nav bar, move it along the
* it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that * 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 * 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. * car's virtual display has no reason to host well.
*/ */
@@ -218,6 +218,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
} }
if (navigator.current == CarScreen.Apps) MoveButtons(id)
if (slot != null) { if (slot != null) {
GsButton( GsButton(
onClick = { onClick = {
@@ -237,3 +238,41 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
} }
} }
} }
/**
* 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) }
}
}