Author SHA1 Message Date
Seb3thehacker f2344169af 0.3.12-alpha: version and release notes 2026-10-24 19:01:29 -07:00
Seb3thehacker dc287a7fa2 release announcement removed 2026-10-24 18:59:30 -07:00
Seb3thehacker 2fcd586e67 README: new screenshots of the player, messages, the assistant and the dashboard 2026-10-24 17:51:19 -07:00
Seb3thehacker 184e4b70c8 README: new launcher screenshot 2026-10-24 17:09:28 -07:00
Seb3thehacker 30b185e86a README: caption the route options screenshot 2026-10-24 16:47:56 -07:00
Seb3thehacker c888d41d51 media: opening another media app keeps the player on the one still playing
Opening AntennaPod while Metrolist played switched Gearslip's player to
AntennaPod, so the nav bar lost the music and its buttons drove the wrong app.
Leaving an app where nothing started goes back to the one that kept playing.
2026-10-24 16:47:45 -07:00
Seb3thehacker b0ffcf2b54 README: new screenshots of navigation, route options, the player and the browser 2026-10-24 16:36:23 -07:00
Seb3thehacker feab4b4e96 debug: a switch to hide the car screen's DEBUG BUILD badge, for screenshots 2026-10-24 16:36:23 -07:00
Seb3thehacker 88e871c65e Vela works: a green check in the launcher and on the README's list 2026-10-24 16:25:58 -07:00
Seb3thehacker dc7018f394 video: adaptive bitrate is an experimental feature, off until it's been tried in a car 2026-10-24 16:25:58 -07:00
Seb3thehacker e920093fa8 README: wireless that connects by itself, calls from the nav bar, the new navigation and look, settings on the phone 2026-10-24 16:18:11 -07:00
Seb3thehacker b71905c8c6 setup, settings: USB audio's prompt is Android asking to share the screen 2026-10-24 16:18:11 -07:00
Seb3thehacker 0a5acc012e setup: end on the app list, the button picker, then usage notes; intro keeps what's new
The intro drops directions, app picks and the button picker. The setup guide's
long pages fade out with a scroll hint, and the app list's names read on dark.
2026-10-24 16:12:51 -07:00
Seb3thehacker 8d673d855b intro: what's new in 0.3.12, and setup always ends on recommended apps
A fresh look, directions at a glance, connecting by itself, better calls and
settings up front, then app picks with an Other cool apps row and the button
picker. The intro is named after its release, so a patch can carry one.
2026-10-24 15:49:41 -07:00
Seb3thehacker 6751951308 wireless: ignore a head unit's repeated Wi-Fi details instead of rejoining
ZLink head units send InfoResponse again once the stream is up. Rejoining
released the network carrying the stream, aborting it a second in, and left the
unit stuck waiting for a version request until ZLink was force closed. A session
ending mid-teardown no longer crashes the app either.
2026-10-24 15:20:23 -07:00
Seb3thehacker 0f2582f638 phone: each car settings group opens its own screen, under a bigger Car heading 2026-10-24 14:49:54 -07:00
Seb3thehacker bb07b95a9f video: adaptive bitrate when the car or its Wi-Fi falls behind
A quarter comes off when the frame rate sags while the encoder waits on acks;
two easy windows put a tenth back. If a cut doesn't help, the car's decoder is
the limit, so the cut is undone and adapting stops for that drive.
2026-10-24 14:46:59 -07:00
Seb3thehacker 9796320b58 phone: the car's settings on the home screen, in groups that open in place
The same settings as the car's Settings app, so a change in either shows in both.
2026-10-24 14:37:00 -07:00
Seb3thehacker 3cea17d178 host: move to Car App Library 1.8
Apps built on 1.8 can now hand Gearslip their newer template pieces (chips,
progress bars, timers); drawing them comes next. Vela, Organic Maps and RF
Sentinel connect as before.
2026-10-24 14:31:18 -07:00
Seb3thehacker 795963f178 car: split the nav bar, template rows and media browsing into their own files 2026-10-24 14:26:12 -07:00
Seb3thehacker ca33b5d01c launcher: decide each app's tiles in one place 2026-10-24 14:24:28 -07:00
Seb3thehacker aaa8babd09 phone: the same Material You tint as the car screen 2026-10-24 14:24:28 -07:00
Seb3thehacker 636e40d88a car settings: grouped, with each setting's name beside its choices 2026-10-24 14:24:28 -07:00
Seb3thehacker 1481bd9c52 browser: page title, forward, reload, start page, and an address bar 2026-10-24 14:24:28 -07:00
Seb3thehacker b57bfb5324 phone: ask for call permissions on the phone from the car's Phone screen 2026-10-24 14:24:28 -07:00
Seb3thehacker e2e0d3f8bb media: a note or folder where an app sends no artwork, and a hint when nothing is playing 2026-10-24 14:24:28 -07:00
Seb3thehacker d90c39f50a car: each app screen uses its own connection, with a plain title bar and a close button
Back and the header icon in an app opened from the launcher went to the map app.
The bar above it showed the template class and draw state. Map buttons rest
faintly instead of vanishing, and place cards share the navigation width.
2026-10-24 14:24:28 -07:00
Seb3thehacker 8a6c358257 build: copy the APK to build/ instead of moving it, so incremental builds come out whole 2026-10-24 14:24:28 -07:00
Seb3thehacker de01448f1b car: the card column only moves the map's center, so Vela's speedometer keeps its size 2026-10-24 14:14:05 -07:00
Seb3thehacker f414a3c2c8 launcher: RF Sentinel keeps its car app tile even though it also posts car notifications 2026-10-24 14:14:04 -07:00
Seb3thehacker ea45bf994b car: setting to put the home screen's player left of the map 2026-10-24 13:53:05 -07:00
Seb3thehacker f8aa0a259a car: navigation cards down the left with a bigger turn card, map on the right
The turn card shows the arrow, distance, instruction, lanes and the next turn;
the trip card stacks time left, arrival and distance. The app centers its map
right of the cards.
2026-10-24 13:53:05 -07:00
Seb3thehacker 0cf28b875c car: tint the car UI with the phone's Material You color
Cards over the map use the nav bar's color instead of near-black.
2026-10-24 13:53:05 -07:00
Seb3thehacker 1c4b472ce0 car: right turn arrows and route times in map apps
Bitmap icons all shared one cache key, so Vela's turn arrows showed whatever
bitmap loaded first (a fuel pin). Route options now show the time and distance
apps send as spans instead of a lone dot.
2026-10-24 13:53:05 -07:00
Seb3thehacker e54fb08fc9 launcher: a POI or other non-media car app gets its own tile, not a Browse tile under Music
RF Sentinel ships a POI car screen plus a media service for its sound, and only
showed up as a music app.
2026-10-24 13:32:23 -07:00
Seb3thehacker 0afe857420 phone: let the screen sleep while Gearslip is open
The car screen is its own display, so the phone's screen doesn't need to stay on, and it no
longer stays on with no car connected.
2026-10-24 12:10:49 -07:00
49 changed files with 2722 additions and 2166 deletions

No files matched your search

+14 -21
View File
@@ -1,27 +1,20 @@
Gearslip 0.3.02 connects wirelessly by itself, and phone calls work much better. Gearslip 0.3.12 gives the car screen a fresh look and fixes the wireless loop 0.3.02 caused on some head units.
## New ## New
- Wireless connects by itself. After your first wireless drive, Android asks once whether Gearslip may watch for that car. From then on, Gearslip connects whenever the car's Bluetooth does. A USB cable still wins. - Navigation cards sit down the left side, with a bigger turn card that shows your lane. The map keeps the right side.
- Phone calls work much better. Calls you place from the car's dialer go through, and you can end or mute a call from the nav bar, which shows who you're talking to and for how long. - The car screen and the phone app take their colors from your phone's Material You palette.
- The browser goes full screen, for the whole page or a video in it. - The car's settings sit on the phone's home screen, each group on its own screen.
- The nav bar can hide while you navigate, or until you tap the screen. - Put the home screen's player left or right of the map.
- Directions sit in short bars, so map apps keep their maps clear of them. - The browser shows the page title, and it has an address bar, forward, reload, and a start page.
- Share a log from an earlier launch, not just the current one. - If Gearslip lacks call permissions, the car's Phone screen asks for them on the phone.
- In the car preview, you type with the phone's keyboard.
## Changed ## Changed
- Help, Preview, and Logs sit in a row at the bottom of the phone's home screen. - Gearslip uses Car App Library 1.8. Map apps show the right turn arrows and route times, and Vela works fully.
- What's new, at the top of Settings, opens the release notes here on GitHub. - Setup ends with recommended apps, then the button picker, then usage notes.
## Fixed ## Fixed
- Before each spoken turn, Gearslip asks the car for its speakers, as Android Auto does. Some head units rebooted without this. - Some head units, such as ZLink adapters, sent their Wi-Fi details twice. Gearslip rejoined the network each time and dropped the connection. It now ignores the repeat.
- An app that won't run in Gearslip, such as Yandex Maps or 2GIS, now says so after 10 seconds instead of showing "Starting" forever. - Gearslip no longer crashes when a wireless connection ends.
- Head units that take only AAC music get one start message, not two. A 2014 aftermarket unit dropped the connection over the second. - Opening a second music app without playing anything keeps the player on the song that's still playing.
- With music on Bluetooth, the car keeps its Bluetooth source. - Vela's speed sign is full size again.
- Head units offering Android Auto 4.x and 6.x get a version they accept. - RF Sentinel and other non-media car apps get their own tile in the launcher.
- Homemade head units without a certificate keep working, and the wheel buttons and knob work as they do in the main build.
- A car that reports the wrong screen size gets the right one.
- iscle fixed how Gearslip frames, secures, and negotiates the connection, and how it checks what the car sends.
## Known issues
- Call audio plays through the car's Bluetooth phone connection, as it does with Android Auto. If a call plays on the phone instead, open the car in the phone's Bluetooth settings and turn on Phone calls.
+25 -18
View File
@@ -31,25 +31,27 @@ screen-sharing mode.
|---|---| |---|---|
| ![The launcher, with every car app on the phone and the route in the bar below](docs/screenshots/launcher.png) | ![Vela Maps showing the route, with the music player beside the map](docs/screenshots/navigation.png) | | ![The launcher, with every car app on the phone and the route in the bar below](docs/screenshots/launcher.png) | ![Vela Maps showing the route, with the music player beside the map](docs/screenshots/navigation.png) |
| Your car apps in one place. A red X marks the ones that don't work yet. | Vela Maps navigating, with the player beside it. | | Your car apps in one place. A red X marks the ones that don't work yet. | Vela Maps navigating, with the player beside it. |
| ![ViVi Music's song, art, and controls in Gearslip's player](docs/screenshots/music.png) | ![A new message card over Organic Maps](docs/screenshots/message.png) | | ![Metrolist's song, art, and controls in Gearslip's player](docs/screenshots/music.png) | ![A new message card over Vela Maps](docs/screenshots/message.png) |
| ViVi Music in Gearslip's player. | A new message, read aloud at a tap, over the map. | | Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
| ![The dashboard, with the weather, the next calendar event, and recent notifications](docs/screenshots/dashboard.png) | ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) | | ![The dashboard, with the weather, the week ahead, and recent notifications](docs/screenshots/dashboard.png) | ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) |
| The weather, your next event, and what you missed. | Browse the web while parked. | | The weather, your week, and what you missed. | Browse the web while parked. |
| ![The voice assistant listening over Organic Maps](docs/screenshots/assistant.png) | ![Coffee shops near you in Vela Maps, with the music player beside the map](docs/screenshots/places.png) | | ![The voice assistant listening over Vela Maps](docs/screenshots/assistant.png) | ![Three routes to a coffee shop in Vela Maps, each with its time and distance](docs/screenshots/places.png) |
| Ask for a place, a song, or an app. | Find coffee nearby without leaving the map. | | Ask for a place, a song, or an app. | Pick a route to coffee nearby. Each one shows its time and distance. |
## Features ## Features
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the - **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
call goes out. When a call comes in, answer or decline it on the car screen. call goes out. When a call comes in, answer or decline it on the car screen. During a
call, the nav bar shows who you're talking to, and you mute or hang up from there.
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen. - **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
Organic Maps, OsmAnd, and Flitsmeister work today. Vela runs, with rough Organic Maps, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you
edges. navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
the map keeps the right side.
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now - **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
play the best match. play the best match.
- **The web.** A browser template with its own on-screen keyboard, for anything that - **The web.** A browser with an address bar, back and forward, and its own on-screen
doesn't ship a car interface of its own. keyboard, for anything that doesn't ship a car interface of its own.
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to - **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
the current and coming weather. the current and coming weather.
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the - **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the
@@ -63,30 +65,35 @@ screen-sharing mode.
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music, - **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
messaging, and the rest. Press and hold an app to move it. messaging, and the rest. Press and hold an app to move it.
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work. - **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
- **Your phone's colors.** The car screen and the phone app take their colors from your
phone's Material You palette, and every card and button matches.
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys. - **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
can set things up before you drive. A change on either screen shows on both.
## Status ## Status
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless - **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
Bluetooth first. Bluetooth first. After the first wireless drive, Android asks once whether Gearslip may
watch for that car; from then on, Gearslip connects whenever the car's Bluetooth does.
- **Setup walks you through it.** First launch asks for the Android Auto certificate, - **Setup walks you through it.** First launch asks for the Android Auto certificate,
then one permission at a time, each with a plain reason and an Allow or Skip. A help then one permission at a time, each with a plain reason and an Allow or Skip. A help
screen inside the car answers anything setup didn't cover. screen inside the car answers anything setup didn't cover.
- **Templated apps work.** Gearslip renders all 16 template types that the Car App - **Templated apps work.** Gearslip renders the Car App Library's templates on the car
Library defines, and it draws its own keyboard for search, sign-in, and text fields. screen, and it draws its own keyboard for search, sign-in, and text fields. It has been
It has been tested against several real navigation and media apps. tested against several real navigation and media apps.
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit - **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
used in homemade car dashboards. used in homemade car dashboards.
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of - **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
Google's car library that accepts only Android Auto itself. Spotify still plays through Google's car library that accepts only Android Auto itself. Spotify still plays through
Gearslip's own player. Gearslip's own player. TIDAL lets only approved apps browse its library.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the - **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
car to hear calls through its speakers. car to hear calls through its speakers.
- **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the - **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the
default: Android never asks for permission, and your map app's voice reaches the car default: Android never asks for permission, and your map app's voice reaches the car
too. USB sounds a little clearer, with no delay, but Android shows a screen-share too. USB sounds a little clearer, with no delay, but each drive Android asks you to
consent dialog once per drive. Gearslip never reads the screen; capturing playback is share your screen, which lets Gearslip send the sound. Gearslip never reads the screen; capturing playback is
the only way an ordinary app can pass another app's audio along. the only way an ordinary app can pass another app's audio along.
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary - **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
Android permission or an on-device consent dialog. For USB audio, setup offers two Android permission or an on-device consent dialog. For USB audio, setup offers two
Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 569 KiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 116 KiB

+6 -6
View File
@@ -38,9 +38,9 @@ android {
applicationId = "app.seb3thehacker.gearslip" applicationId = "app.seb3thehacker.gearslip"
minSdk = 31 minSdk = 31
targetSdk = 37 targetSdk = 37
versionCode = 11 versionCode = 12
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater. // -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.02-alpha" versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.12-alpha"
manifestPlaceholders["appLabel"] = "Gearslip" manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address, // Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent. // set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
@@ -106,9 +106,10 @@ android {
} }
// Renames the produced APK and moves it to the root build/ folder (<root>/build/Gearslip-debug.apk) // Copies the produced APK to the root build/ folder (<root>/build/Gearslip-debug.apk) after the
// after the fact rather than fighting AGP's variant-output API across versions - simpler and // fact rather than fighting AGP's variant-output API across versions - simpler and doesn't break
// doesn't break when that API moves again. // when that API moves again. A copy, not a move: AGP packages incrementally into the APK it left
// last time, and with that file gone a build came out holding nothing but classes.dex.
tasks.register("renameApk") { tasks.register("renameApk") {
doLast { doLast {
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
@@ -118,7 +119,6 @@ tasks.register("renameApk") {
.forEach { apk -> .forEach { apk ->
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk") val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
apk.copyTo(renamed, overwrite = true) apk.copyTo(renamed, overwrite = true)
apk.delete()
} }
} }
} }
+6
View File
@@ -162,6 +162,12 @@
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
the head unit to Android Auto, without disabling the screen the switch is on. the head unit to Android Auto, without disabling the screen the switch is on.
--> -->
<activity
android:name=".PermissionActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@style/Theme.Gearslip.Invisible" />
<activity-alias <activity-alias
android:name=".GearslipUsbAccessory" android:name=".GearslipUsbAccessory"
android:targetActivity=".GearslipActivity" android:targetActivity=".GearslipActivity"
@@ -14,7 +14,6 @@ import android.os.ParcelFileDescriptor
import android.provider.Settings import android.provider.Settings
import android.view.MotionEvent import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -141,7 +140,6 @@ class GearslipActivity : ComponentActivity(), Projection {
// is both a bad first impression and unreliable: Android does not consistently show more // is both a bad first impression and unreliable: Android does not consistently show more
// than one system permission dialog fired back-to-back, which was silently dropping some. // than one system permission dialog fired back-to-back, which was silently dropping some.
enableEdgeToEdge() enableEdgeToEdge()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
GearslipLog.init(this) GearslipLog.init(this)
GearslipLog.installCrashHandler() GearslipLog.installCrashHandler()
@@ -1089,11 +1089,17 @@ class GearslipRunner(
} }
val useProjection = projection != null val useProjection = projection != null
// 60fps only over a cable: over Wi-Fi it doubles the data for little the driver sees.
videoFrameRate = if (config.frameRate == 1 && !wireless) 60 else 30
smoothWindows = 0
lastCut = null
decoderBound = false
adaptiveVideo = CarSettings.experimentalFeaturesEnabled.value && CarSettings.adaptiveVideoEnabled.value
if (adaptiveVideo) log.i("video: adaptive bitrate on (experimental)")
videoSource = VideoSource( videoSource = VideoSource(
width = width, width = width,
height = height, height = height,
// 60fps only over a cable: over Wi-Fi it doubles the data for little the driver sees. frameRate = videoFrameRate,
frameRate = if (config.frameRate == 1 && !wireless) 60 else 30,
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD, mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
wireless = wireless, wireless = wireless,
onCodecConfig = { csd -> onCodecConfig = { csd ->
@@ -1378,8 +1384,10 @@ class GearslipRunner(
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen") SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen")
log.i( log.i(
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent waited=${waited}ms " + "video: ${"%.1f".format(fps)} fps over 5s | sent=$sent waited=${waited}ms " +
"acked=$acksSeen inFlight=$inFlight split=$splitMessages", "acked=$acksSeen inFlight=$inFlight split=$splitMessages " +
"kbps=${(videoSource?.bitRate ?: 0) / 1000}",
) )
adaptBitRate(fps, waited)
// Self-healing safety net: force a fresh keyframe periodically regardless // Self-healing safety net: force a fresh keyframe periodically regardless
// of whether we think one is owed. Protects against any future case where // of whether we think one is owed. Protects against any future case where
@@ -1394,7 +1402,66 @@ class GearslipRunner(
}.apply { isDaemon = true }.start() }.apply { isDaemon = true }.start()
} }
/** The frame rate the stream was set up for, which adaptive bitrate measures against. */
@Volatile private var videoFrameRate = 30
/** Five-second windows in a row the stream kept up, towards climbing back up. */
private var smoothWindows = 0
/** The bitrate and frame rate before the last cut, to see whether cutting helped. */
private var lastCut: Pair<Int, Double>? = null
/** Experimental: read once per stream, so flipping the switch mid-drive changes nothing. */
@Volatile private var adaptiveVideo = false
/** Set once a cut didn't help: this car's decoder, not the link, sets the pace. */
private var decoderBound = false
/**
* Adaptive bitrate. A window where the encoder spent most of its time held back by the
* car's acks while the frame rate sagged means the car, or the Wi-Fi to it, can't take
* frames this size: a quarter comes off. Two easy windows in a row put a tenth back, up to
* where the stream started. A still screen (few frames, no waiting) changes nothing.
*/
private fun adaptBitRate(fps: Double, waitedMs: Long) {
if (!adaptiveVideo) return
val source = videoSource ?: return
val target = videoFrameRate.toDouble()
val behind = waitedMs > ADAPT_WINDOW_MS / 2 && fps < target * 0.8
val easy = waitedMs < ADAPT_WINDOW_MS / 10
when {
behind && !decoderBound -> {
smoothWindows = 0
val cut = lastCut
if (cut != null && fps < cut.second * 1.05) {
// The last cut bought no frames: the car's decoder is the limit, not the
// link, and smaller frames would only look worse. Undo it and stop here.
decoderBound = true
source.setBitRate(cut.first)
log.i("video: a lower bitrate didn't help the car keep up - back to ${cut.first / 1000} kbps, adapting off")
return
}
val before = source.bitRate
source.setBitRate((before * 0.75).toInt())
if (source.bitRate != before) {
lastCut = before to fps
log.i("video: car falling behind - bitrate ${before / 1000} -> ${source.bitRate / 1000} kbps")
}
}
easy && source.bitRate < source.fullBitRate -> {
lastCut = null
if (++smoothWindows >= 2) {
smoothWindows = 0
val before = source.bitRate
source.setBitRate((before * 1.1).toInt() + 1)
log.i("video: keeping up - bitrate ${before / 1000} -> ${source.bitRate / 1000} kbps")
}
}
else -> smoothWindows = 0
}
}
private companion object { private companion object {
/** The stats window adaptive bitrate reads, matching the watchdog's sleep. */
const val ADAPT_WINDOW_MS = 5_000L
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17 // protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
const val MSG_VERSION_REQUEST = 1 const val MSG_VERSION_REQUEST = 1
const val MSG_VERSION_RESPONSE = 2 const val MSG_VERSION_RESPONSE = 2
@@ -0,0 +1,49 @@
package app.seb3thehacker.gearslip
import android.app.Activity
import android.app.ActivityOptions
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Display
/**
* Asks for runtime permissions on the phone's own screen, for a car screen that can't show
* Android's dialog itself. It has no look of its own: the dialog appears, the answer is given,
* and it finishes.
*/
class PermissionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val wanted = intent.getStringArrayExtra(EXTRA_PERMISSIONS)
?.filter { checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED }
.orEmpty()
if (wanted.isEmpty() || savedInstanceState != null) {
if (wanted.isEmpty()) finish()
return
}
requestPermissions(wanted.toTypedArray(), REQUEST)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
GearslipLog.i("permissions: ${permissions.zip(grantResults.toList()).joinToString { (p, r) -> "${p.substringAfterLast('.')}=${r == 0}" }}")
finish()
}
companion object {
private const val EXTRA_PERMISSIONS = "permissions"
private const val REQUEST = 1
/** Shows the permission dialog on the phone, whichever screen [context] belongs to. */
fun ask(context: Context, permissions: Array<String>) {
val intent = Intent(context, PermissionActivity::class.java)
.putExtra(EXTRA_PERMISSIONS, permissions)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
runCatching { context.startActivity(intent, options.toBundle()) }
.onFailure { GearslipLog.w("permissions: could not ask on the phone: ${it.message}") }
}
}
}
@@ -42,6 +42,14 @@ class VideoSource(
private set private set
private var codec: MediaCodec? = null private var codec: MediaCodec? = null
/** The bitrate the encoder started at, the most [setBitRate] ever goes back up to. */
var fullBitRate = 0
private set
/** What the encoder is asked for right now. */
@Volatile var bitRate = 0
private set
private var worker: Thread? = null private var worker: Thread? = null
@Volatile private var running = false @Volatile private var running = false
private var frameIndex = 0L private var frameIndex = 0L
@@ -51,6 +59,8 @@ class VideoSource(
val bitRate = defaultBitRate(width, height, frameRate, level).let { val bitRate = defaultBitRate(width, height, frameRate, level).let {
if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it
} }
fullBitRate = bitRate
this.bitRate = bitRate
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply { fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
setInteger( setInteger(
MediaFormat.KEY_COLOR_FORMAT, MediaFormat.KEY_COLOR_FORMAT,
@@ -128,9 +138,28 @@ class VideoSource(
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") } }.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
} }
/**
* Changes the bitrate while streaming, without a new keyframe or a restart. Used to back off
* when the car (or the Wi-Fi to it) can't keep up, and to climb back once it can.
*/
fun setBitRate(bps: Int) {
val target = bps.coerceIn(fullBitRate / 4, fullBitRate)
if (target == bitRate) return
runCatching {
codec?.setParameters(Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, target) })
bitRate = target
}.onFailure { log.w("setBitRate failed: ${it.message}") }
}
fun stop() { fun stop() {
running = false running = false
worker?.join(1_000) // Stop is called from threads that are themselves being shut down. An interrupt here
// used to escape as an uncaught exception and take the whole app down with the session.
try {
worker?.join(1_000)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
runCatching { inputSurface?.release() } runCatching { inputSurface?.release() }
inputSurface = null inputSurface = null
runCatching { codec?.stop() } runCatching { codec?.stop() }
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -38,6 +39,7 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.filled.Close
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.CarAppConnection import app.seb3thehacker.gearslip.host.CarAppConnection
@@ -67,7 +69,7 @@ fun CarAppStage(
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
// The app's own drawing goes underneath; everything else is the host's rendering of // The app's own drawing goes underneath; everything else is the host's rendering of
// the template it described, laid over the top. // the template it described, laid over the top.
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover) { androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
if (status.surfaceRequested) { if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize()) AppSurface(connection, frame, template, Modifier.fillMaxSize())
} }
@@ -102,10 +104,18 @@ private fun AppSurface(
// changing size moves the visible area even though the surface itself hasn't changed. // changing size moves the visible area even though the surface itself hasn't changed.
val (width, height) = size val (width, height) = size
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) } val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
LaunchedEffect(template, size, measured) { // The card column moves the map's centre but isn't taken out of the stable area: apps size
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = CoverEdge.START) }
LaunchedEffect(template, size, measured, measuredStable) {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height, measured) val visible = visibleAreaFor(template, width, height, measured)
connection.reportAreas(visible = visible, stable = visible) val stable = if (!isContentTemplate(template)) {
visibleAreaFor(template, width, height, measuredStable)
} else {
visible
}
connection.reportAreas(visible = visible, stable = stable)
} }
} }
@@ -205,28 +215,31 @@ private fun ChromeBar(
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// The app's name and a way out, nothing else: which template is showing, or whether the
// app has drawn yet, is for the log, not the driver.
Surface( Surface(
color = Color.Black.copy(alpha = 0.55f), color = MaterialTheme.colorScheme.surfaceContainer,
contentColor = Color.White, contentColor = MaterialTheme.colorScheme.onSurface,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth().height(CHROME_BAR),
) { ) {
Row( Row(
Modifier.padding(horizontal = 16.dp, vertical = 6.dp), Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(Modifier.weight(1f)) { Text(
Text( status.app ?: "Car app",
status.app ?: "Car app", style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f),
) )
val detail = buildList { GsIconButton(
status.template?.let { add(it) } androidx.compose.material.icons.Icons.Filled.Close,
add(if (status.surfaceAttached) "drawing" else "waiting for the app") "Close ${status.app ?: "the app"}",
}.joinToString(" - ") onDisconnect,
Text(detail, style = MaterialTheme.typography.bodySmall) size = 36.dp,
} iconSize = 20.dp,
GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") } shape = androidx.compose.foundation.shape.CircleShape,
)
} }
} }
} }
@@ -236,3 +249,6 @@ private val CHROME_BAR = 44.dp
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */ /** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
private const val FLING_MIN_PX_S = 400f private const val FLING_MIN_PX_S = 400f
/** The app connection a template on screen belongs to: the map app's, or the one opened from the launcher. */
internal val LocalCarAppConnection = androidx.compose.runtime.staticCompositionLocalOf { CarServices.nav }
@@ -93,9 +93,14 @@ fun CarHome() {
if (onHome) seenHome[0] = true if (onHome) seenHome[0] = true
} }
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle()
// No padding anywhere, split screen or not: the player sits flush against the map. // No padding anywhere, split screen or not: the player sits flush against the map.
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
if (splitScreen && playerLeft) {
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
}
Box( Box(
Modifier Modifier
.weight(if (splitScreen) 2.1f else 1f) .weight(if (splitScreen) 2.1f else 1f)
@@ -103,8 +108,7 @@ fun CarHome() {
) { ) {
MapPane(navStatus, frame) MapPane(navStatus, frame)
} }
if (splitScreen) { if (splitScreen && !playerLeft) {
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
} }
} }
@@ -119,35 +119,61 @@ internal object LauncherCache {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap() context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull() }.getOrNull()
val navApps = CarAppCatalog.installed(context) val carApps = CarAppCatalog.installed(context).associateBy { it.component.packageName }
val mediaApps = MediaCatalog.installed(context) val mediaApps = MediaCatalog.installed(context).associateBy { it.component.packageName }
// Some apps (Spotify among them) ship both a Car App Library template and a legacy val messagingApps = MessagingCatalog.installed(context).associateBy { it.packageName }
// MediaBrowserService, which otherwise land as two identically-labeled tiles with no
// way to tell them apart. They're genuinely different screens - browsing versus Now
// Playing - so only the label needs disambiguating, not the tile itself.
val mediaPackages = mediaApps.map { it.component.packageName }.toSet()
val messagingApps = MessagingCatalog.installed(context) // One decision per app over everything it offers a car - a car screen, a player, car
// Google Messages ships its own car app, which won't start under Gearslip. Gearslip's // notifications - rather than a filter per kind that has to know about the others.
// own Messages screen does the same job, so a messenger gets only that tile. val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
val messagingPackages = messagingApps.map { it.packageName }.toSet() val icon by lazy { iconOf(pkg) }
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg]).map { tile ->
val nav = navApps when (tile) {
.filter { it.component.packageName !in messagingPackages } is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
// An app whose template screen is rejected but whose player works through Gearslip is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report is AppTile.Messages -> Entry(tile.app.label, icon, messaging = tile.app)
// that the car screen isn't supported. }
.filter { !(KnownApps.screenBroken(it.component.packageName) && KnownApps.playerWorks(it.component.packageName)) }
.map {
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
Entry(label, iconOf(it.component.packageName), template = it)
} }
val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) } }
// A messenger that is also a car media app keeps only its player tile. return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
val messaging = messagingApps }
.filter { it.packageName !in mediaPackages } }
.map { Entry(it.label, iconOf(it.packageName), messaging = it) }
return (nav + media + messaging).sortedBy { it.label.lowercase() }.also { flow.value = it } /** What one app gets on the launcher. */
private sealed interface AppTile {
class Screen(val app: TemplateApp, val label: String) : AppTile
class Player(val app: app.seb3thehacker.gearslip.media.MediaApp) : AppTile
class Messages(val app: app.seb3thehacker.gearslip.notify.MessagingApp) : AppTile
}
/**
* The tiles for one app, from what it offers.
*
* - A car screen that is something other than media or messaging (RF Sentinel's POI screen) is
* the app: its media service only makes sound, and its notifications are alerts, so it gets the
* one tile under its own name.
* - A media app with both a car screen and a player (Spotify) gets both, the screen labelled
* "· Browse", since they really are different screens - unless the screen is known to refuse
* Gearslip while the player works, when the Browse tile would only report that.
* - A messenger's own car screen (Google Messages) won't start under Gearslip, and Gearslip's
* Messages screen does the job, so it gets only that - and a messenger that is also a media
* app keeps only its player.
*/
private fun tilesFor(
car: TemplateApp?,
media: app.seb3thehacker.gearslip.media.MediaApp?,
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
): List<AppTile> {
if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label))
val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList()
return buildList {
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
if (car != null && !messengerScreen && !(media != null && screenRefused)) {
add(AppTile.Screen(car, if (media != null) "${car.label} · Browse" else car.label))
}
if (media != null) add(AppTile.Player(media))
else if (messaging != null) add(AppTile.Messages(messaging))
} }
} }
@@ -0,0 +1,664 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.material.icons.filled.Call
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.call.CallUiState
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.border
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.BoxScope
import androidx.car.app.model.CarIcon
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.text
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.animation.animateContentSize
import android.media.session.PlaybackState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.gsColors
import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.notify.CarNotifications
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
// The nav bar along the bottom of the car screen: home, apps, the assistant, running apps,
// the next turn or a call, the player, and the clock. Split out of CarUi.kt.
/**
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
* back at all, since a car touchscreen has no hardware button for it.
*/
@Composable
private fun BreadcrumbBar(navigator: CarNavigator) {
val trail = navigator.trail
if (trail.size <= 1) return
Row(
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex
Text(
crumb.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = if (isLast) Modifier else Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { navigator.jumpTo(index) }
.padding(horizontal = 4.dp, vertical = 2.dp),
)
if (!isLast) {
Text(
"›",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
}
}
/**
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
* more room.
*/
@Composable
internal fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val now by rememberNow()
val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data
val pinnedIds by CarSettings.pinnedApps.collectAsState()
val entries = rememberAllEntries()
val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
}
val frame by CarEnvironment.frame.collectAsState()
val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of
// these two from three screens deep in a media app shouldn't mean reading it
// first.
NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
Spacer(Modifier.width(6.dp))
NavAssistantIcon()
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
// The map app lives on the Home button, so it never gets a second icon here.
val runningEntries = running.keys
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
.mapNotNull { id -> entries.find { it.componentId == id } }
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
// Next turn first, off the map only; the player shares the space when it fits,
// and gives way to the turn when it doesn't.
// A call takes the player's place until it ends.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
if (call != null) {
NavCallPill(call, Modifier.weight(1f, fill = false))
} else if (turn == null || roomForBoth) {
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
}
}
}
// Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above.
// The clock is also the notifications button: the dashboard lists them under the
// calendar and weather, and a dot here marks anything unread.
val unread by CarNotifications.unread.collectAsState()
NavChip(
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
{ navigator.dashboard() },
) {
if (unread > 0) {
Box(
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-2).dp)
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
if (battery.percent >= 0) {
Text(
"${battery.percent}%",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (battery.charging) {
ChargingGlyph(Modifier.size(13.dp))
}
}
weatherData?.let { w ->
Text(
" · ${w.temp}°",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
/**
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
*/
@Composable
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
val selected = navigator.current == CarScreen.Home
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
GsIconBox(
{ navigator.home() },
colors = navColors(selected),
latched = selected,
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
) {
val icon = navEntry?.icon
if (icon != null) {
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
}
} else {
Icon(
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
)
}
}
}
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
@Composable
private fun NavDivider() {
Box(
Modifier
.padding(horizontal = 10.dp)
.width(1.dp)
.height(28.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/**
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
* already open; a long press opens its menu (pin, unpin, close).
*/
@Composable
private fun NavEntryIcon(
entry: Entry,
running: Map<String, RunningSlot>,
navigator: CarNavigator,
frame: CarEnvironment.Frame,
) {
val id = entry.componentId ?: return
val slot = running[id]
Box(Modifier.padding(horizontal = 3.dp)) {
NavAppIcon(
entry,
open = slot != null,
showing = slot != null && slot.isShowing(navigator.current),
onLongClick = { navigator.showAppMenu(id) },
) { openApp(entry, slot, navigator, frame) }
}
}
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
@Composable
private fun NavAppIcon(
entry: Entry,
open: Boolean,
showing: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
GsIconBox(
onClick,
Modifier.size(44.dp),
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
latched = showing,
onLongClick = onLongClick,
) {
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
if (open) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 2.dp)
.size(width = 12.dp, height = 3.dp)
.clip(RoundedCornerShape(50))
.background(scheme.primary),
)
}
}
}
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
}
}
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
@Composable
private fun navColors(selected: Boolean): GsColors {
val s = MaterialTheme.colorScheme
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
else GsColors(s.surfaceContainerHighest, s.onSurface)
}
/**
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
* two buttons beside it.
*/
@Composable
private fun NavAssistantIcon() {
val state by CarAssistant.state.collectAsState()
val active = state.phase != CarAssistant.Phase.IDLE
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
private val NAV_ITEM_PADDING = 8.dp
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
@Composable
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
while (true) {
value = System.currentTimeMillis()
delay(60_000 - value % 60_000)
}
}
/** How long the pill shows a new song's title before settling to just the controls. */
private const val TITLE_SHOWN_MS = 3_000L
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
/** The next manoeuvre, as the running nav app last described it. */
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
/**
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
* while it's running, whether or not the map is the screen being shown.
*/
@Composable
private fun rememberNextTurn(): NextTurn? {
val status by CarServices.nav.status.collectAsState()
val template by CarServices.nav.template.collectAsState()
// Read so a change of units redraws the distance straight away, not at the next update.
CarSettings.units.collectAsState().value
if (status.phase != CarAppConnection.Phase.RUNNING) return null
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
if (routing.isLoading) return null
val step = routing.currentStep ?: return null
return NextTurn(
icon = step.maneuver?.icon,
distance = routing.currentDistance.display(),
road = step.road.text().ifEmpty { step.cue.text() },
)
}
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
@Composable
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
// Same finish as every other button on the bar, in the accent colour; the text runs a step
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
GsIconBox(
onClick,
Modifier.widthIn(max = 340.dp).height(44.dp),
colors = gsColors(GsTone.Primary),
) {
Row(
Modifier.padding(NAV_ITEM_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CarGlyph(turn.icon, Modifier.size(28.dp))
if (turn.distance.isNotEmpty()) {
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
}
if (turn.road.isNotEmpty()) {
Text(
turn.road,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
@Composable
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
val context = LocalContext.current.applicationContext
val muted by CarCalls.muted.collectAsState()
val elapsed by produceState(0L, call.since) {
while (true) {
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
}
}
val scheme = MaterialTheme.colorScheme
Surface(
modifier = modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = scheme.secondaryContainer,
contentColor = scheme.onSecondaryContainer,
) {
Row(
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Text(
call.caller?.label ?: "On a call",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
"%d:%02d".format(elapsed / 60, elapsed % 60),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
)
Spacer(Modifier.width(2.dp))
GsIconButton(
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
if (muted) "Unmute" else "Mute",
CarCalls::toggleMute,
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
)
GsIconButton(
MediaIcons.CallEnd, "End call", { CarCalls.end(context) },
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
)
}
}
}
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
private fun nowPlayingLabel(title: String, artist: String): String =
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
.ifEmpty { "Nothing playing" }
/**
* The player in the nav bar, on every screen. Art and title open the full media screen;
* play/pause and skip work right here. The dock button moves the player into the column beside
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
*/
@Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return
// Docked beside the map, the player is already on screen - the pill steps aside for it.
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
// The media screen is the full player already; the pill would only repeat its controls.
if (navigator.current == CarScreen.Media) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
val position by produceState(now.currentPosition(), now) {
while (true) {
value = now.currentPosition()
if (!now.playing) break
delay(1000)
}
}
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
// goes and the pill settles to just the controls - unless the driver keeps the title up.
val keepTitle by CarSettings.playerTitle.collectAsState()
var announcing by remember { mutableStateOf(true) }
LaunchedEffect(now.title, now.artist) {
announcing = true
delay(TITLE_SHOWN_MS)
announcing = false
}
val showTitle = keepTitle || announcing
val scheme = MaterialTheme.colorScheme
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
Surface(
modifier = modifier
.animateContentSize()
// With the title up, all the room the bar has, so as much of it shows as can.
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
contentColor = onPill,
) {
Box {
Row(
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
// Art (and the title while it shows) opens the full media screen.
Row(
Modifier
.then(if (showTitle) Modifier.weight(1f) else Modifier)
.fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
if (showTitle) {
Text(
nowPlayingLabel(now.title, now.artist),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (!showTitle) {
GsIconButton(
MediaIcons.Previous, "Previous", media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
media::togglePlay,
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
)
if (!showTitle) {
GsIconButton(
MediaIcons.Next, "Next", media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
size = 36.dp, iconSize = 22.dp,
)
}
if (now.durationMs > 0) {
// matchParentSize, so the line follows the pill's width rather than widening it.
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
Box(
Modifier
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
.height(2.dp)
.background(onPill.copy(alpha = 0.7f)),
)
}
}
}
}
}
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
* bolt, so this is drawn rather than a vector asset.
*/
@Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
androidx.compose.foundation.Canvas(modifier) {
val w = size.width
val h = size.height
val bolt = androidx.compose.ui.graphics.Path().apply {
moveTo(w * 0.58f, 0f)
lineTo(w * 0.12f, h * 0.58f)
lineTo(w * 0.46f, h * 0.58f)
lineTo(w * 0.42f, h)
lineTo(w * 0.88f, h * 0.40f)
lineTo(w * 0.54f, h * 0.40f)
close()
}
drawPath(bolt, tint)
}
}
@@ -60,6 +60,13 @@ object CarServices {
private var navStopped = false private var navStopped = false
private var autoplayPending = false private var autoplayPending = false
/**
* The app that was playing when the driver opened another one. Opening an app is only a look
* at its library: until something plays there, the music carries on in this one, so leaving
* the new app's screen goes back to it, as Android Auto does.
*/
private var stillPlaying: MediaApp? = null
fun init(appContext: Context) { fun init(appContext: Context) {
context = appContext.applicationContext context = appContext.applicationContext
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext) app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
@@ -130,6 +137,8 @@ object CarServices {
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
if (_mediaApp.value?.component == app.component && live) return if (_mediaApp.value?.component == app.component && live) return
val previous = _mediaApp.value
stillPlaying = previous?.takeIf { it.component != app.component && media.now.value.playing && !autoplay }
_mediaApp.value = app _mediaApp.value = app
autoplayPending = autoplay autoplayPending = autoplay
media.connect(app) media.connect(app)
@@ -151,12 +160,26 @@ object CarServices {
return true return true
} }
/**
* The media screen closed. If nothing started in the app it showed, go back to the one that
* kept playing meanwhile, so the player and its buttons follow the music you can hear.
*/
fun mediaScreenClosed() {
val back = stillPlaying ?: return
stillPlaying = null
// A track loaded here, even one paused since, means the driver used this app.
if (media.now.value.playing || media.now.value.title.isNotEmpty()) return
app.seb3thehacker.gearslip.GearslipLog.i("media: nothing played in ${_mediaApp.value?.label}, back to ${back.label}")
openMedia(back)
}
/** Closes the media app from the car: stops what it's playing and drops the connection. */ /** Closes the media app from the car: stops what it's playing and drops the connection. */
fun closeMedia() { fun closeMedia() {
if (media.now.value.playing) media.togglePlay() if (media.now.value.playing) media.togglePlay()
media.disconnect() media.disconnect()
_mediaApp.value = null _mediaApp.value = null
autoplayPending = false autoplayPending = false
stillPlaying = null
} }
/** Called once the media app is connected: starts playback if that was asked for. */ /** Called once the media app is connected: starts playback if that was asked for. */
@@ -63,6 +63,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_LAST_MEDIA = "car_last_media" private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay" private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_PLAYER_TITLE = "car_player_title" private const val KEY_PLAYER_TITLE = "car_player_title"
private const val KEY_PLAYER_LEFT = "car_player_left"
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"
@@ -70,6 +71,8 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features" private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
private const val KEY_SYSTEM_SPEECH = "car_system_speech" private const val KEY_SYSTEM_SPEECH = "car_system_speech"
private const val KEY_CAR_SENSORS = "car_sensors_enabled" private const val KEY_CAR_SENSORS = "car_sensors_enabled"
private const val KEY_ADAPTIVE_VIDEO = "adaptive_video_enabled"
private const val KEY_DEBUG_BADGE = "debug_badge"
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning" private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
private const val KEY_UNITS = "car_units" private const val KEY_UNITS = "car_units"
private const val KEY_NAV_BAR = "car_nav_bar" private const val KEY_NAV_BAR = "car_nav_bar"
@@ -100,6 +103,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private val lastMediaFlow = MutableStateFlow<String?>(null) private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false) private val autoplayFlow = MutableStateFlow(false)
private val playerTitleFlow = MutableStateFlow(false) private val playerTitleFlow = MutableStateFlow(false)
private val playerLeftFlow = MutableStateFlow(false)
private val themeFlow = MutableStateFlow(AppTheme.PHONE) private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow<AppTheme> = themeFlow val appTheme: StateFlow<AppTheme> = themeFlow
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id) private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
@@ -111,6 +115,8 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private val experimentalFeaturesFlow = MutableStateFlow(false) private val experimentalFeaturesFlow = MutableStateFlow(false)
private val systemSpeechFlow = MutableStateFlow(false) private val systemSpeechFlow = MutableStateFlow(false)
private val carSensorsFlow = MutableStateFlow(false) private val carSensorsFlow = MutableStateFlow(false)
private val adaptiveVideoFlow = MutableStateFlow(false)
private val debugBadgeFlow = MutableStateFlow(true)
private val seenSafetyWarningFlow = MutableStateFlow(false) private val seenSafetyWarningFlow = MutableStateFlow(false)
private val unitsFlow = MutableStateFlow(Units.AUTO) private val unitsFlow = MutableStateFlow(Units.AUTO)
@@ -215,6 +221,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
*/ */
val playerTitle: StateFlow<Boolean> = playerTitleFlow val playerTitle: StateFlow<Boolean> = playerTitleFlow
/** The home screen's player sits left of the map instead of right of it. */
val playerLeft: StateFlow<Boolean> = playerLeftFlow
val scale: StateFlow<Float> = scaleFlow val scale: StateFlow<Float> = scaleFlow
val openOnConnect: StateFlow<String> = openFlow val openOnConnect: StateFlow<String> = openFlow
val nightMode: StateFlow<NightMode> = nightFlow val nightMode: StateFlow<NightMode> = nightFlow
@@ -267,6 +276,20 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
*/ */
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
/**
* Video bitrate that backs off when the car, or the Wi-Fi to it, falls behind. Takes effect
* only while [experimentalFeaturesEnabled] is on too; untested in a car so far.
*/
val adaptiveVideoEnabled: StateFlow<Boolean> = adaptiveVideoFlow
/** Debug builds only: the car screen's DEBUG BUILD badge, which screenshots for the README leave out. */
val debugBadge: StateFlow<Boolean> = debugBadgeFlow
fun setDebugBadge(on: Boolean) {
debugBadgeFlow.value = on
AppSettings.putString(app, KEY_DEBUG_BADGE, on.toString())
}
/** /**
* Whether the car screen's own "drive safely" warning has already been shown and dismissed * Whether the car screen's own "drive safely" warning has already been shown and dismissed
* once, from this same install. Separate from the phone side's own compat/safety dialogs - * once, from this same install. Separate from the phone side's own compat/safety dialogs -
@@ -292,6 +315,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id) uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true" autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true" playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true" pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's // Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
// own voice already reaches the car. // own voice already reaches the car.
@@ -308,6 +332,8 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true" experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true" systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
adaptiveVideoFlow.value = AppSettings.getString(app, KEY_ADAPTIVE_VIDEO, "false") == "true"
debugBadgeFlow.value = AppSettings.getString(app, KEY_DEBUG_BADGE, "true") == "true"
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true" seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
unitsFlow.value = runCatching { unitsFlow.value = runCatching {
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name)) Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
@@ -362,6 +388,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString()) AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
} }
fun setPlayerLeft(on: Boolean) {
playerLeftFlow.value = on
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
}
fun setAutoplay(on: Boolean) { fun setAutoplay(on: Boolean) {
autoplayFlow.value = on autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
@@ -405,6 +436,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString()) AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
} }
fun setAdaptiveVideoEnabled(on: Boolean) {
adaptiveVideoFlow.value = on
AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, on.toString())
}
fun setCarSensorsEnabled(on: Boolean) { fun setCarSensorsEnabled(on: Boolean) {
carSensorsFlow.value = on carSensorsFlow.value = on
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString()) AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -63,10 +64,12 @@ fun CarSettingsScreen() {
val locationWarning by CarSettings.locationWarning.collectAsState() val locationWarning by CarSettings.locationWarning.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState() val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState() val playerTitle by CarSettings.playerTitle.collectAsState()
val playerLeft by CarSettings.playerLeft.collectAsState()
val theme by CarSettings.appTheme.collectAsState() val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState() val uiTheme by CarSettings.uiTheme.collectAsState()
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
val carSensors by CarSettings.carSensorsEnabled.collectAsState() val carSensors by CarSettings.carSensorsEnabled.collectAsState()
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
val systemSpeech by CarSettings.systemSpeech.collectAsState() val systemSpeech by CarSettings.systemSpeech.collectAsState()
val display by CarEnvironment.display.collectAsState() val display by CarEnvironment.display.collectAsState()
val vehicle by CarEnvironment.vehicle.collectAsState() val vehicle by CarEnvironment.vehicle.collectAsState()
@@ -86,6 +89,8 @@ fun CarSettingsScreen() {
ScreenHeader("Settings") ScreenHeader("Settings")
} }
item { Group("Screen") }
item { item {
ChoiceRow( ChoiceRow(
"Open on connect", "Open on connect",
@@ -152,6 +157,8 @@ fun CarSettingsScreen() {
) { CarSettings.setScreenFit(it) } ) { CarSettings.setScreenFit(it) }
} }
item { Group("Nav bar and player") }
item { item {
ChoiceRow( ChoiceRow(
"Nav bar", "Nav bar",
@@ -172,6 +179,14 @@ fun CarSettingsScreen() {
) { CarSettings.setPlayerTitle(it) } ) { CarSettings.setPlayerTitle(it) }
} }
item {
ChoiceRow(
"Player beside the map",
listOf(false to "Right", true to "Left"),
playerLeft,
) { CarSettings.setPlayerLeft(it) }
}
item { item {
ChoiceRow( ChoiceRow(
"Autoplay on connect", "Autoplay on connect",
@@ -180,6 +195,8 @@ fun CarSettingsScreen() {
) { CarSettings.setAutoplay(it) } ) { CarSettings.setAutoplay(it) }
} }
item { Group("Sound and voice") }
item { item {
ChoiceRow( ChoiceRow(
"Car audio", "Car audio",
@@ -198,6 +215,8 @@ fun CarSettingsScreen() {
) )
} }
item { Group("Warnings") }
if (!pipeAudio) item { if (!pipeAudio) item {
ChoiceRow( ChoiceRow(
"Warn when Bluetooth isn't connected", "Warn when Bluetooth isn't connected",
@@ -241,6 +260,8 @@ fun CarSettingsScreen() {
) { CarSettings.setSystemSpeech(it) } ) { CarSettings.setSystemSpeech(it) }
} }
item { Group("More") }
item { item {
WebPageSetting() WebPageSetting()
} }
@@ -258,7 +279,7 @@ fun CarSettingsScreen() {
} }
if (experimentalFeatures) item { if (experimentalFeatures) item {
ExperimentalFeaturesList(carSensors) ExperimentalFeaturesList(carSensors, adaptiveVideo)
} }
item { item {
@@ -291,8 +312,37 @@ fun CarSettingsScreen() {
@Composable @Composable
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) { private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { // On a wide screen the name sits beside its choices, which halves how far there is to scroll.
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth >= 720.dp && options.size <= 4) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(0.3f))
Box(Modifier.weight(0.7f)) { ChoiceButtons(options, selected, onSelect) }
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
ChoiceButtons(options, selected, onSelect)
}
}
}
}
/** A group of settings: a heading, so a long list reads as a few short ones. */
@Composable
private fun Group(title: String) {
Text(
title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 12.dp),
)
}
@Composable
private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
run {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
options.forEach { (value, label) -> options.forEach { (value, label) ->
val on = value == selected val on = value == selected
@@ -319,7 +369,7 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
* so the list reads fine whether there's one entry or several. * so the list reads fine whether there's one entry or several.
*/ */
@Composable @Composable
private fun ExperimentalFeaturesList(carSensors: Boolean) { private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean) {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -332,6 +382,12 @@ private fun ExperimentalFeaturesList(carSensors: Boolean) {
"guess, not a fact, until you've watched one change for yourself.", "guess, not a fact, until you've watched one change for yourself.",
checked = carSensors, checked = carSensors,
) { CarSettings.setCarSensorsEnabled(it) } ) { CarSettings.setCarSensorsEnabled(it) }
ExperimentalFeatureRow(
title = "Adaptive video quality",
description = "Lowers the picture's quality when the car, or its Wi-Fi, can't keep " +
"up, and raises it again once it can. Takes effect on the next connection.",
checked = adaptiveVideo,
) { CarSettings.setAdaptiveVideoEnabled(it) }
} }
} }
@@ -75,6 +75,7 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.GsColors import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
import app.seb3thehacker.gearslip.car.theme.GsIconBox import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsIconButton import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsButton import app.seb3thehacker.gearslip.car.theme.GsButton
@@ -117,7 +118,7 @@ fun CarUi() {
AppTheme.LIGHT -> false AppTheme.LIGHT -> false
AppTheme.DARK -> true AppTheme.DARK -> true
} }
val colors = if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext) val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark)
val base = LocalDensity.current val base = LocalDensity.current
val insets by CarEnvironment.insets.collectAsState() val insets by CarEnvironment.insets.collectAsState()
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) } val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
@@ -316,7 +317,8 @@ fun CarUi() {
VoiceHelpOverlay(Modifier.align(Alignment.Center)) VoiceHelpOverlay(Modifier.align(Alignment.Center))
CallOverlay() CallOverlay()
AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset)) AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) { val badge by CarSettings.debugBadge.collectAsState()
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG && badge) {
// Bottom corner, not top: a screen's own header almost always puts // Bottom corner, not top: a screen's own header almost always puts
// something in the top-right (settings, search, a Liked Songs // something in the top-right (settings, search, a Liked Songs
// shortcut) and this badge used to sit right on top of it, hiding // shortcut) and this badge used to sit right on top of it, hiding
@@ -357,7 +359,7 @@ fun CarUi() {
} }
} }
private val NAV_BAR_HEIGHT = 56.dp internal val NAV_BAR_HEIGHT = 56.dp
/** /**
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows, * How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
@@ -479,568 +481,6 @@ private fun DebugBuildBadge(modifier: Modifier = Modifier) {
} }
} }
/**
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
* back at all, since a car touchscreen has no hardware button for it.
*/
@Composable
private fun BreadcrumbBar(navigator: CarNavigator) {
val trail = navigator.trail
if (trail.size <= 1) return
Row(
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex
Text(
crumb.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = if (isLast) Modifier else Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { navigator.jumpTo(index) }
.padding(horizontal = 4.dp, vertical = 2.dp),
)
if (!isLast) {
Text(
"›",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
}
}
/**
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
* more room.
*/
@Composable
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val now by rememberNow()
val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data
val pinnedIds by CarSettings.pinnedApps.collectAsState()
val entries = rememberAllEntries()
val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
}
val frame by CarEnvironment.frame.collectAsState()
val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of
// these two from three screens deep in a media app shouldn't mean reading it
// first.
NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
Spacer(Modifier.width(6.dp))
NavAssistantIcon()
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
// The map app lives on the Home button, so it never gets a second icon here.
val runningEntries = running.keys
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
.mapNotNull { id -> entries.find { it.componentId == id } }
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
// Next turn first, off the map only; the player shares the space when it fits,
// and gives way to the turn when it doesn't.
// A call takes the player's place until it ends.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
if (call != null) {
NavCallPill(call, Modifier.weight(1f, fill = false))
} else if (turn == null || roomForBoth) {
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
}
}
}
// Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above.
// The clock is also the notifications button: the dashboard lists them under the
// calendar and weather, and a dot here marks anything unread.
val unread by CarNotifications.unread.collectAsState()
NavChip(
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
{ navigator.dashboard() },
) {
if (unread > 0) {
Box(
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-2).dp)
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
if (battery.percent >= 0) {
Text(
"${battery.percent}%",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (battery.charging) {
ChargingGlyph(Modifier.size(13.dp))
}
}
weatherData?.let { w ->
Text(
" · ${w.temp}°",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
/**
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
*/
@Composable
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
val selected = navigator.current == CarScreen.Home
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
GsIconBox(
{ navigator.home() },
colors = navColors(selected),
latched = selected,
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
) {
val icon = navEntry?.icon
if (icon != null) {
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
}
} else {
Icon(
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
)
}
}
}
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
@Composable
private fun NavDivider() {
Box(
Modifier
.padding(horizontal = 10.dp)
.width(1.dp)
.height(28.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/**
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
* already open; a long press opens its menu (pin, unpin, close).
*/
@Composable
private fun NavEntryIcon(
entry: Entry,
running: Map<String, RunningSlot>,
navigator: CarNavigator,
frame: CarEnvironment.Frame,
) {
val id = entry.componentId ?: return
val slot = running[id]
Box(Modifier.padding(horizontal = 3.dp)) {
NavAppIcon(
entry,
open = slot != null,
showing = slot != null && slot.isShowing(navigator.current),
onLongClick = { navigator.showAppMenu(id) },
) { openApp(entry, slot, navigator, frame) }
}
}
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
@Composable
private fun NavAppIcon(
entry: Entry,
open: Boolean,
showing: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
GsIconBox(
onClick,
Modifier.size(44.dp),
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
latched = showing,
onLongClick = onLongClick,
) {
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
if (open) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 2.dp)
.size(width = 12.dp, height = 3.dp)
.clip(RoundedCornerShape(50))
.background(scheme.primary),
)
}
}
}
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
}
}
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
@Composable
private fun navColors(selected: Boolean): GsColors {
val s = MaterialTheme.colorScheme
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
else GsColors(s.surfaceContainerHighest, s.onSurface)
}
/**
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
* two buttons beside it.
*/
@Composable
private fun NavAssistantIcon() {
val state by CarAssistant.state.collectAsState()
val active = state.phase != CarAssistant.Phase.IDLE
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
private val NAV_ITEM_PADDING = 8.dp
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
@Composable
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
while (true) {
value = System.currentTimeMillis()
delay(60_000 - value % 60_000)
}
}
/** How long the pill shows a new song's title before settling to just the controls. */
private const val TITLE_SHOWN_MS = 3_000L
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
/** The next manoeuvre, as the running nav app last described it. */
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
/**
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
* while it's running, whether or not the map is the screen being shown.
*/
@Composable
private fun rememberNextTurn(): NextTurn? {
val status by CarServices.nav.status.collectAsState()
val template by CarServices.nav.template.collectAsState()
// Read so a change of units redraws the distance straight away, not at the next update.
CarSettings.units.collectAsState().value
if (status.phase != CarAppConnection.Phase.RUNNING) return null
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
if (routing.isLoading) return null
val step = routing.currentStep ?: return null
return NextTurn(
icon = step.maneuver?.icon,
distance = routing.currentDistance.display(),
road = step.road.text().ifEmpty { step.cue.text() },
)
}
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
@Composable
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
// Same finish as every other button on the bar, in the accent colour; the text runs a step
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
GsIconBox(
onClick,
Modifier.widthIn(max = 340.dp).height(44.dp),
colors = gsColors(GsTone.Primary),
) {
Row(
Modifier.padding(NAV_ITEM_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CarGlyph(turn.icon, Modifier.size(28.dp))
if (turn.distance.isNotEmpty()) {
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
}
if (turn.road.isNotEmpty()) {
Text(
turn.road,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
@Composable
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
val context = LocalContext.current.applicationContext
val muted by CarCalls.muted.collectAsState()
val elapsed by produceState(0L, call.since) {
while (true) {
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
}
}
val scheme = MaterialTheme.colorScheme
Surface(
modifier = modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = scheme.secondaryContainer,
contentColor = scheme.onSecondaryContainer,
) {
Row(
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Text(
call.caller?.label ?: "On a call",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
"%d:%02d".format(elapsed / 60, elapsed % 60),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
)
Spacer(Modifier.width(2.dp))
GsIconButton(
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
if (muted) "Unmute" else "Mute",
CarCalls::toggleMute,
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
)
GsIconButton(
MediaIcons.CallEnd, "End call", { CarCalls.end(context) },
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
)
}
}
}
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
private fun nowPlayingLabel(title: String, artist: String): String =
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
.ifEmpty { "Nothing playing" }
/**
* The player in the nav bar, on every screen. Art and title open the full media screen;
* play/pause and skip work right here. The dock button moves the player into the column beside
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
*/
@Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return
// Docked beside the map, the player is already on screen - the pill steps aside for it.
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
// The media screen is the full player already; the pill would only repeat its controls.
if (navigator.current == CarScreen.Media) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
val position by produceState(now.currentPosition(), now) {
while (true) {
value = now.currentPosition()
if (!now.playing) break
delay(1000)
}
}
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
// goes and the pill settles to just the controls - unless the driver keeps the title up.
val keepTitle by CarSettings.playerTitle.collectAsState()
var announcing by remember { mutableStateOf(true) }
LaunchedEffect(now.title, now.artist) {
announcing = true
delay(TITLE_SHOWN_MS)
announcing = false
}
val showTitle = keepTitle || announcing
val scheme = MaterialTheme.colorScheme
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
Surface(
modifier = modifier
.animateContentSize()
// With the title up, all the room the bar has, so as much of it shows as can.
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
contentColor = onPill,
) {
Box {
Row(
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
// Art (and the title while it shows) opens the full media screen.
Row(
Modifier
.then(if (showTitle) Modifier.weight(1f) else Modifier)
.fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
if (showTitle) {
Text(
nowPlayingLabel(now.title, now.artist),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (!showTitle) {
GsIconButton(
MediaIcons.Previous, "Previous", media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
media::togglePlay,
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
)
if (!showTitle) {
GsIconButton(
MediaIcons.Next, "Next", media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
size = 36.dp, iconSize = 22.dp,
)
}
if (now.durationMs > 0) {
// matchParentSize, so the line follows the pill's width rather than widening it.
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
Box(
Modifier
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
.height(2.dp)
.background(onPill.copy(alpha = 0.7f)),
)
}
}
}
}
}
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
* bolt, so this is drawn rather than a vector asset.
*/
@Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
androidx.compose.foundation.Canvas(modifier) {
val w = size.width
val h = size.height
val bolt = androidx.compose.ui.graphics.Path().apply {
moveTo(w * 0.58f, 0f)
lineTo(w * 0.12f, h * 0.58f)
lineTo(w * 0.46f, h * 0.58f)
lineTo(w * 0.42f, h)
lineTo(w * 0.88f, h * 0.40f)
lineTo(w * 0.54f, h * 0.40f)
close()
}
drawPath(bolt, tint)
}
}
/** Stands in for the phone's vibration motor on the car screen: does nothing. */ /** Stands in for the phone's vibration motor on the car screen: does nothing. */
private object NoHaptics : HapticFeedback { private object NoHaptics : HapticFeedback {
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {} override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
@@ -0,0 +1,232 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import android.provider.Settings
import android.content.Intent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaArt
import app.seb3thehacker.gearslip.media.LyricsResult
import app.seb3thehacker.gearslip.media.Lyrics
import app.seb3thehacker.gearslip.media.MediaEntry
import app.seb3thehacker.gearslip.media.NowPlaying
import app.seb3thehacker.gearslip.media.QueueTrack
import android.media.session.PlaybackState
import kotlinx.coroutines.delay
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
// The media screen's browse list, up-next queue, rows and icons. Split out of MediaScreen.kt.
@Composable
internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
Column(modifier) {
// At the library root there's no trail and nowhere to go up to, so there's nothing this
// row could say that the app's name in the top-left corner hasn't already - showing it
// again here (as "Browse", or as the app's own name) was just the same label twice.
if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
}
browse.trail.lastOrNull()?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
when {
browse.loading -> Notice("Loading…", null)
browse.unavailable -> Notice(
"$appLabel keeps its library to itself.",
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
)
browse.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
}
}
}
}
// --- up next -----------------------------------------------------------------------------------
/**
* Whatever the app has queued up, starting right after the track that's playing now - not the
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/
@Composable
internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
val upNext = remember(queue, now.activeQueueItemId) {
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
if (current >= 0) queue.drop(current + 1) else queue
}
Column(modifier) {
if (upNext.isEmpty()) {
Notice(
"Nothing queued.",
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
"not every app tells Gearslip what comes next.",
)
} else {
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
}
}
}
}
@Composable
private fun QueueRow(track: QueueTrack, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(track.iconBitmap, track.iconUri) {
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
}
Surface(
onClick = { media.playQueueItem(track) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (entry.browsable && entry.playable) {
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
}
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
}
/** A row's artwork, or a folder or note in its place when the app sends none. */
@Composable
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
Box(
Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else Icon(fallback, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(30.dp))
}
}
/** Material's transport icons, from their published path data (they live in the extended set). */
internal object MediaIcons {
val MusicNote = icon("MusicNote", "M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z")
val Folder = icon("Folder", "M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z")
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
/** The 3x3 app grid; not in material-icons-core. */
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
/** A screen with a filled right-hand panel: "put the player beside the map". */
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
val CallEnd = icon("CallEnd", "M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z")
val Mic = icon("Mic", "M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z")
val MicOff = icon("MicOff", "M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z")
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f,
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
}
@@ -93,6 +93,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
// The connection outlives this screen: the home screen's player is the same one. // The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) } LaunchedEffect(app) { CarServices.openMedia(app) }
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { CarServices.mediaScreenClosed() } }
val art = rememberArt(now) val art = rememberArt(now)
@@ -444,7 +445,7 @@ private sealed interface LyricsState {
} }
@Composable @Composable
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) { internal fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
Column( Column(
Modifier.fillMaxSize().padding(horizontal = 24.dp), Modifier.fillMaxSize().padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -491,7 +492,14 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
shadowElevation = 6.dp, shadowElevation = 6.dp,
modifier = Modifier.size(minOf(maxWidth, maxHeight)), modifier = Modifier.size(minOf(maxWidth, maxHeight)),
) { ) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } if (art != null) {
Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} else {
// No cover (or nothing playing): a note, so the square reads as a cover, not a hole.
Box(contentAlignment = Alignment.Center) {
Icon(MediaIcons.MusicNote, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxSize(0.35f))
}
}
} }
} }
} }
@@ -518,6 +526,12 @@ private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modif
maxLines = 1, overflow = TextOverflow.Ellipsis, maxLines = 1, overflow = TextOverflow.Ellipsis,
) )
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) } now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.title.isEmpty() && now.error == null) {
Text(
"Press play to pick up where you left off, or find something in Browse.",
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
PlayerProgress(now, media, Modifier.fillMaxWidth()) PlayerProgress(now, media, Modifier.fillMaxWidth())
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth()) TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
@@ -555,147 +569,3 @@ private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier)
// --- browsing -------------------------------------------------------------------------------- // --- browsing --------------------------------------------------------------------------------
@Composable
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
Column(modifier) {
// At the library root there's no trail and nowhere to go up to, so there's nothing this
// row could say that the app's name in the top-left corner hasn't already - showing it
// again here (as "Browse", or as the app's own name) was just the same label twice.
if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
}
browse.trail.lastOrNull()?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
when {
browse.loading -> Notice("Loading…", null)
browse.unavailable -> Notice(
"$appLabel keeps its library to itself.",
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
)
browse.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
}
}
}
}
// --- up next -----------------------------------------------------------------------------------
/**
* Whatever the app has queued up, starting right after the track that's playing now - not the
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/
@Composable
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
val upNext = remember(queue, now.activeQueueItemId) {
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
if (current >= 0) queue.drop(current + 1) else queue
}
Column(modifier) {
if (upNext.isEmpty()) {
Notice(
"Nothing queued.",
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
"not every app tells Gearslip what comes next.",
)
} else {
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
}
}
}
}
@Composable
private fun QueueRow(track: QueueTrack, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(track.iconBitmap, track.iconUri) {
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
}
Surface(
onClick = { media.playQueueItem(track) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (entry.browsable && entry.playable) {
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
}
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
}
/** Material's transport icons, from their published path data (they live in the extended set). */
internal object MediaIcons {
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
/** The 3x3 app grid; not in material-icons-core. */
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
/** A screen with a filled right-hand panel: "put the player beside the map". */
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
val CallEnd = icon("CallEnd", "M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z")
val Mic = icon("Mic", "M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z")
val MicOff = icon("MicOff", "M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z")
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f,
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
}
@@ -133,7 +133,7 @@ private const val TAB_KEYPAD = 1
@Composable @Composable
fun PhoneDialerScreen() { fun PhoneDialerScreen() {
val context = LocalContext.current val context = LocalContext.current
val hasPermissions = hasCallPermissions(context) var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
var tab by remember { mutableStateOf(TAB_CONTACTS) } var tab by remember { mutableStateOf(TAB_CONTACTS) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
@@ -148,7 +148,7 @@ fun PhoneDialerScreen() {
} }
if (!hasPermissions) { if (!hasPermissions) {
MissingPermission(context) MissingPermission(context) { hasPermissions = true }
return@Column return@Column
} }
@@ -159,19 +159,23 @@ fun PhoneDialerScreen() {
} }
@Composable @Composable
private fun MissingPermission(context: Context) { private fun MissingPermission(context: Context, onGranted: () -> Unit) {
// Android's dialog can't show on the car screen, so it opens on the phone; checking again
// every second picks the answer up without the driver having to come back in.
androidx.compose.runtime.LaunchedEffect(Unit) {
while (!hasCallPermissions(context)) kotlinx.coroutines.delay(1_000)
onGranted()
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " + "Gearslip needs the Phone and Contacts permissions to dial from here. Allow them on " +
"from the app's settings.", "the phone when you're parked.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
GsButton(tone = GsTone.Tonal, onClick = { GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity( app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS)
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)), }) { Text("Ask on the phone") }
)
}) { Text("Open app settings") }
} }
} }
@@ -103,13 +103,13 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect?
is NavigationTemplate -> measured ?: Rect(0, 0, width, height) is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate, is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate, is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, measured?.bottom ?: height) -> measured ?: Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height)
null -> Rect(0, 0, width, height) null -> Rect(0, 0, width, height)
else -> Rect(0, 0, 0, 0) else -> Rect(0, 0, 0, 0)
} }
/** The side of the map a piece of chrome sits against, and so the side it shrinks the visible area from. */ /** The side of the map a piece of chrome sits against, and so the side it shrinks the visible area from. */
internal enum class CoverEdge { TOP, BOTTOM, END } internal enum class CoverEdge { TOP, BOTTOM, START, END }
/** /**
* Where the chrome over the map actually landed, in window pixels. The map buttons count even * Where the chrome over the map actually landed, in window pixels. The map buttons count even
@@ -129,15 +129,17 @@ internal class ChromeCover {
} }
/** What is left of a [width] x [height] surface at [surface] (window pixels), in surface pixels. */ /** What is left of a [width] x [height] surface at [surface] (window pixels), in surface pixels. */
fun visibleWithin(surface: ComposeRect, width: Int, height: Int): Rect { fun visibleWithin(surface: ComposeRect, width: Int, height: Int, skip: CoverEdge? = null): Rect {
var left = 0f var left = 0f
var top = 0f var top = 0f
var right = width.toFloat() var right = width.toFloat()
var bottom = height.toFloat() var bottom = height.toFloat()
pieces.values.forEach { (edge, b) -> pieces.values.forEach { (edge, b) ->
if (edge == skip) return@forEach
when (edge) { when (edge) {
CoverEdge.TOP -> top = maxOf(top, b.bottom - surface.top) CoverEdge.TOP -> top = maxOf(top, b.bottom - surface.top)
CoverEdge.BOTTOM -> bottom = minOf(bottom, b.top - surface.top) CoverEdge.BOTTOM -> bottom = minOf(bottom, b.top - surface.top)
CoverEdge.START -> left = maxOf(left, b.right - surface.left)
CoverEdge.END -> right = minOf(right, b.left - surface.left) CoverEdge.END -> right = minOf(right, b.left - surface.left)
} }
} }
@@ -171,7 +173,8 @@ internal fun Modifier.covers(key: String, edge: CoverEdge): Modifier {
@Composable @Composable
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) { private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
val pan by CarServices.nav.panMode.collectAsState() val connection = LocalCarAppConnection.current
val pan by connection.panMode.collectAsState()
// Pan mode clears the map for looking around: only the buttons stay. // Pan mode clears the map for looking around: only the buttons stay.
val routing = (template.navigationInfo as? RoutingInfo)?.takeUnless { pan } val routing = (template.navigationInfo as? RoutingInfo)?.takeUnless { pan }
val estimate = template.destinationTravelEstimate?.takeUnless { pan } val estimate = template.destinationTravelEstimate?.takeUnless { pan }
@@ -179,10 +182,10 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
} }
/** /**
* The turn in one short bar across the top, the arrival time in another along the bottom, so the * Cards down the left, map on the right: the turn on top, the trip along the bottom, the map
* map keeps its full width. Neither grows past [NAV_BAR_MAX_WIDTH]. The bottom one is short and * buttons at the right edge. The whole column counts as covered, so the app centres its map in
* left out of the visible area on purpose, so a speedometer in the app's bottom corner sits level * what is left over and the car sits right of the cards rather than under them. Pan mode drops
* with it rather than stacked above it. The map buttons sit between them on the right. * the cards and gives the map the whole width.
*/ */
@Composable @Composable
private fun NavigationBars( private fun NavigationBars(
@@ -191,85 +194,104 @@ private fun NavigationBars(
estimate: TravelEstimate?, estimate: TravelEstimate?,
modifier: Modifier, modifier: Modifier,
) { ) {
val pan by CarServices.nav.panMode.collectAsState() val connection = LocalCarAppConnection.current
Column(modifier.fillMaxSize()) { val pan by connection.panMode.collectAsState()
routing?.let { androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
RoutingBar( val column = cardColumnWidth(maxWidth)
it, Row(Modifier.fillMaxSize()) {
Modifier.covers("nav-top", CoverEdge.TOP).padding(start = 12.dp, top = 12.dp, end = 12.dp) if (routing != null || estimate != null) {
.widthIn(max = NAV_BAR_MAX_WIDTH).fillMaxWidth(), Column(
) Modifier.width(column).fillMaxHeight().covers("nav-cards", CoverEdge.START).padding(12.dp),
} verticalArrangement = Arrangement.spacedBy(12.dp),
Row(Modifier.weight(1f).fillMaxWidth()) { ) {
Box(Modifier.weight(1f).fillMaxHeight()) { routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
estimate?.let { Spacer(Modifier.weight(1f))
TravelEstimateBar( estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
it,
Modifier.align(Alignment.BottomStart).padding(12.dp)
.widthIn(max = NAV_BAR_MAX_WIDTH),
)
} }
} }
Spacer(Modifier.weight(1f))
RightEdgeControls( RightEdgeControls(
if (pan) null else template.actionStrip, if (pan) null else template.actionStrip,
template.mapActionStrip, template.mapActionStrip,
Modifier.padding(12.dp), Modifier.padding(12.dp),
// Only the column: the app's own buttons are a wide row up top, over the map's
// edge rather than where a speedometer or the like would go.
mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END), mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END),
) )
} }
} }
} }
/** The manoeuvre in one row: arrow, distance, what to do, onto what road, and lanes or the next turn. */ /**
* The manoeuvre, big enough to read at a glance: the arrow and the distance to it, what to do
* and onto which road, the lanes to be in, and the turn after it along the bottom.
*/
@Composable @Composable
private fun RoutingBar(info: RoutingInfo, modifier: Modifier = Modifier) { private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
if (info.isLoading) { if (info.isLoading) {
ChromeSurface(modifier) { ChromeSurface(modifier) {
Text("Finding a route…", Modifier.padding(horizontal = 16.dp, vertical = 12.dp), style = ChromeType.title) Text("Finding a route…", Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
} }
return return
} }
val step = info.currentStep ?: return val step = info.currentStep ?: return
val scheme = MaterialTheme.colorScheme
ChromeSurface(modifier) { ChromeSurface(modifier) {
Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { Column {
Row(verticalAlignment = Alignment.CenterVertically) { Column(
CarGlyph(step.maneuver?.icon, Modifier.size(40.dp)) Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
Spacer(Modifier.width(12.dp)) verticalArrangement = Arrangement.spacedBy(8.dp),
val distance = info.currentDistance.display() ) {
if (distance.isNotEmpty()) { androidx.compose.runtime.CompositionLocalProvider(
Text(distance, style = ChromeType.headline, maxLines = 1) androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
Spacer(Modifier.width(14.dp)) ) {
} Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
Text(step.cue.text(), style = ChromeType.title, maxLines = 1, overflow = TextOverflow.Ellipsis) Spacer(Modifier.width(14.dp))
val distance = info.currentDistance.display()
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold),
maxLines = 1,
)
}
}
val cue = step.cue.text()
if (cue.isNotEmpty()) {
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
val road = step.road.text() val road = step.road.text()
if (road.isNotEmpty()) { // "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
Text( Text(
road, road,
style = ChromeType.body, style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
} if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
val next = info.nextStep LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) { }
LaneGuidance(step, Modifier.padding(start = 12.dp).widthIn(max = 200.dp).heightIn(max = 48.dp)) // Rare, and only where an arrow can't explain the junction: worth the card growing for.
} else if (next != null) { info.junctionImage?.let { JunctionImage(it) }
Spacer(Modifier.width(12.dp))
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(24.dp))
} }
} }
// Rare, and only where an arrow can't explain the junction: worth the bar growing for. info.nextStep?.let { next ->
info.junctionImage?.let { junction -> Row(
Spacer(Modifier.height(8.dp)) Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
JunctionImage(junction) verticalAlignment = Alignment.CenterVertically,
) {
Text("Then", style = ChromeType.label, color = scheme.onSurfaceVariant)
Spacer(Modifier.width(10.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp))
val nextText = next.cue.text().ifEmpty { next.road.text() }
if (nextText.isNotEmpty()) {
Spacer(Modifier.width(10.dp))
Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
} }
} }
} }
@@ -345,68 +367,59 @@ private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
} }
/** /**
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them, * The trip at a glance, stacked: time left on top, then the clock time it ends at and the
* where a thumb reaches - the app's own way to stop navigating. One short line along the bottom, * distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
* stopping short of the map buttons. The close button is Gearslip's own, not the app's: an * app's: an actionStrip's actions carry no flag saying which one (if any) means "stop
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the * navigating" - the first button turned out to be a mute toggle, not an exit - so this ends
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way * navigation the way going Home already does rather than guess at the app's own controls.
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
*/ */
@Composable @Composable
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) { private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) {
val connection = LocalCarAppConnection.current
val time = remainingTime(estimate.remainingTimeSeconds) val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display() val distance = estimate.remainingDistance.display()
val arrival = estimate.arrivalTimeAtDestination.clockTime() val arrival = estimate.arrivalTimeAtDestination.clockTime()
val trip = estimate.tripText.text() val trip = estimate.tripText.text()
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
val detail = listOfNotNull( ChromeSurface(modifier) {
arrival.takeIf { it.isNotEmpty() },
distance.takeIf { it.isNotEmpty() },
trip.takeIf { it.isNotEmpty() },
).joinToString(" • ")
// The same solid card the manoeuvre bar above it uses - one surface for the whole map
// chrome, rather than this one spot styled on its own as a bar over the map.
ChromeSurface(modifier, shadowElevation = 8.dp) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
Row( Row(
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp), Modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(Modifier.weight(1f)) {
if (time.isNotEmpty()) {
Text(
time,
style = ChromeType.headline,
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
maxLines = 1,
)
}
val detail = listOfNotNull(
arrival.takeIf { it.isNotEmpty() }?.let { "Arrive $it" },
distance.takeIf { it.isNotEmpty() },
).joinToString(" • ")
if (detail.isNotEmpty()) {
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (trip.isNotEmpty()) {
Text(trip, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
// Not a disconnect: the connection stays up and the app decides its own next screen. // Not a disconnect: the connection stays up and the app decides its own next screen.
// Ending navigation is its own call, separate from a back press - a back press only // Ending navigation is its own call, separate from a back press - a back press only
// pops the on-screen card, leaving the app still believing it is navigating (so a // pops the on-screen card, leaving the app still believing it is navigating.
// freshly picked destination silently does nothing); this is the call that actually
// clears that flag on the app's side.
GsIconButton( GsIconButton(
Icons.Filled.Close, Icons.Filled.Close,
"Stop navigating", "Stop navigating",
{ CarServices.nav.stopNavigating() }, { connection.stopNavigating() },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
size = 40.dp, size = 52.dp,
iconSize = 20.dp, iconSize = 26.dp,
shape = CircleShape, shape = CircleShape,
) )
Spacer(Modifier.width(10.dp))
if (time.isNotEmpty()) {
Text(
time,
style = ChromeType.headline,
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
maxLines = 1,
)
}
if (detail.isNotEmpty()) {
Spacer(Modifier.width(16.dp))
Text(
detail,
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
} }
} }
@@ -428,16 +441,18 @@ private fun MapPaneLayout(
fullHeight: Boolean = false, fullHeight: Boolean = false,
pane: @Composable () -> Unit, pane: @Composable () -> Unit,
) { ) {
val connection = LocalCarAppConnection.current
// Pan mode hands the whole screen to the map, leaving only its own controls. // Pan mode hands the whole screen to the map, leaving only its own controls.
val pan by CarServices.nav.panMode.collectAsState() val pan by connection.panMode.collectAsState()
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet() val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) { androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
if (!pan) { if (!pan) {
ChromeSurface( ChromeSurface(
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.covers("map-pane", CoverEdge.START)
.padding(12.dp) .padding(12.dp)
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp)) .width(cardColumnWidth(maxWidth) - 24.dp)
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) }, .let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
shadowElevation = 8.dp, shadowElevation = 8.dp,
@@ -558,12 +573,16 @@ private fun ListPane(
} }
} }
/** Fraction of the surface the content pane covers beside a map. */
private const val CONTENT_PANE_FRACTION = 0.42f
/** Past this the bars only spread the same words further apart, across a map left with no music beside it. */ /**
private val NAV_BAR_MAX_WIDTH = 460.dp * The width of the cards beside the map, the same for a place card and for navigation so the
* map doesn't jump sideways when a route starts.
*/
internal fun cardColumnWidth(maxWidth: androidx.compose.ui.unit.Dp): androidx.compose.ui.unit.Dp =
(maxWidth * CARD_COLUMN_FRACTION).coerceIn(280.dp, 440.dp).coerceAtMost(maxWidth)
private const val CARD_COLUMN_FRACTION = 0.4f
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */ /** Search, sign-in and the like are laid out for a whole column, so they keep one. */
private fun Template?.needsFullHeight() = private fun Template?.needsFullHeight() =
@@ -517,7 +517,8 @@ private val sectionCache = android.util.LruCache<String, List<Item>>(64)
*/ */
@Composable @Composable
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") { private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") {
val appPackage = CarServices.nav.appPackage.orEmpty() val connection = LocalCarAppConnection.current
val appPackage = connection.appPackage.orEmpty()
ContentSurface(modifier) { ContentSurface(modifier) {
// The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from // The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from
// template.actions, not the header - but a bottom-of-content row is wrong for a driver // template.actions, not the header - but a bottom-of-content row is wrong for a driver
@@ -106,7 +106,8 @@ internal fun ChromeSurface(
) { ) {
Surface( Surface(
shadowElevation = shadowElevation, shadowElevation = shadowElevation,
color = MaterialTheme.colorScheme.surface, // The nav bar's own container colour, so cards over the map match the rest of the car UI.
color = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
shape = shape, shape = shape,
modifier = modifier, modifier = modifier,
@@ -217,6 +218,9 @@ internal fun headerTitle(header: Header?, fallback: String): String =
* actions in a row along the top, its map controls stacked under them. Keeping them in one * actions in a row along the top, its map controls stacked under them. Keeping them in one
* column is what stops them colliding on a screen only 480px tall. * column is what stops them colliding on a screen only 480px tall.
*/ */
/** How visible the map buttons stay once they fade: enough to know they're there. */
private const val RESTING_ALPHA = 0.35f
@Composable @Composable
internal fun RightEdgeControls( internal fun RightEdgeControls(
actionStrip: ActionStrip?, actionStrip: ActionStrip?,
@@ -225,22 +229,23 @@ internal fun RightEdgeControls(
/** For the stack of map buttons alone, without the row of the app's own above it. */ /** For the stack of map buttons alone, without the row of the app's own above it. */
mapActionsModifier: Modifier = Modifier, mapActionsModifier: Modifier = Modifier,
) { ) {
val connection = LocalCarAppConnection.current
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() } val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() } val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
if (actions.isEmpty() && mapActions.isEmpty()) return if (actions.isEmpty() && mapActions.isEmpty()) return
// A template that stops offering Pan must not leave the screen stuck in pan mode. // A template that stops offering Pan must not leave the screen stuck in pan mode.
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN } val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) CarServices.nav.setPanMode(false) } androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) connection.setPanMode(false) }
// Faded out when the screen has been left alone; then they are only a memory of buttons, so // Faded out when the screen has been left alone; then they are only a memory of buttons, so
// the first touch on one wakes them rather than pressing it. // the first touch on one wakes them rather than pressing it.
val awake = LocalMapControlsWake.current.awake val awake = LocalMapControlsWake.current.awake
val pan by CarServices.nav.panMode.collectAsState() val pan by connection.panMode.collectAsState()
// Kept up while the knob is on one of them, or the ring would fade out along with the button. // Kept up while the knob is on one of them, or the ring would fade out along with the button.
var holdsFocus by remember { mutableStateOf(false) } var holdsFocus by remember { mutableStateOf(false) }
val visible = awake || pan || holdsFocus val visible = awake || pan || holdsFocus
val alpha by androidx.compose.animation.core.animateFloatAsState( val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (visible) 1f else 0f, targetValue = if (visible) 1f else RESTING_ALPHA,
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500), animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
label = "mapControls", label = "mapControls",
) )
@@ -288,7 +293,7 @@ internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, lar
} }
/** A row with nothing to show: no words, no picture, nothing to tap. */ /** A row with nothing to show: no words, no picture, nothing to tap. */
private fun CarRow.isBlank(): Boolean = internal fun CarRow.isBlank(): Boolean =
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null && title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty() toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
@@ -298,13 +303,14 @@ private fun CarRow.isBlank(): Boolean =
*/ */
@Composable @Composable
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) { internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
val connection = LocalCarAppConnection.current
if (action.type == Action.TYPE_APP_ICON) { if (action.type == Action.TYPE_APP_ICON) {
AppIconBadge() AppIconBadge()
return return
} }
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val title = action.title.text() val title = action.title.text()
val panOn by CarServices.nav.panMode.collectAsState() val panOn by connection.panMode.collectAsState()
val active = action.type == Action.TYPE_PAN && panOn val active = action.type == Action.TYPE_PAN && panOn
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
@@ -319,8 +325,8 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
GsIconBox( GsIconBox(
onClick = { onClick = {
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed() if (action.type == Action.TYPE_BACK) connection.backPressed()
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn) else if (action.type == Action.TYPE_PAN) connection.setPanMode(!panOn)
else action.onClickDelegate?.click("action") else action.onClickDelegate?.click("action")
}, },
modifier = modifier, modifier = modifier,
@@ -431,8 +437,9 @@ private const val AUTO_START_MS = 5_000L
/** The connected app's own launcher icon, which is what the app-icon action stands for. */ /** The connected app's own launcher icon, which is what the app-icon action stands for. */
@Composable @Composable
private fun AppIconBadge() { private fun AppIconBadge() {
val connection = LocalCarAppConnection.current
val context = LocalContext.current val context = LocalContext.current
val pkg = CarServices.nav.appPackage val pkg = connection.appPackage
val icon = remember(pkg) { val icon = remember(pkg) {
pkg?.let { pkg?.let {
runCatching { runCatching {
@@ -507,315 +514,6 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
// --- lists, grids and panes --------------------------------------------------------------------- // --- lists, grids and panes ---------------------------------------------------------------------
@Composable
internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
style = ChromeType.title,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@Composable
internal fun ItemListColumn(
single: ItemList?,
sectioned: List<ItemList>? = null,
modifier: Modifier = Modifier,
) {
val rows = buildList {
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
val onSelected = single?.onSelectedDelegate
val chosen = single?.selectedIndex ?: -1
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
itemsIndexed(rows) { index, row ->
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
}
}
}
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
@Composable
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return
LazyVerticalGrid(
columns = GridCells.Adaptive(84.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items) { item -> GridTile(item, iconSize = 36.dp) }
}
}
/**
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
* instead of wrapping into a grid and wants its full-size cover art.
*/
@Composable
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
Column(
modifier
.clickable { item.onClickDelegate.click("grid item") }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CarGlyph(item.image, Modifier.size(iconSize))
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
style = ChromeType.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = item.text.text()
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
* a single action counts itself down rather than waiting to be pressed, on the theory that a
* driver glancing at the car screen has already decided. It never applies to more than one
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
*/
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
val card = PlaceCard.of(rows)
Column(modifier) {
card.heading?.let { PlaceHeading(it) }
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
if (card.heading == null && card.buttons.isEmpty()) {
card.details.forEach { RowItem(it, large = true) }
} else {
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
}
}
}
/**
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
* heading and no buttons, and draws exactly as a plain list of rows.
*/
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
companion object {
fun of(rows: List<CarRow>): PlaceCard {
// A short tappable row with an icon and nothing under it is a button in all but name:
// MAPS.ME's "Let's go" and "Save".
val buttons = rows.filter {
it.onClickDelegate != null && it.toggle == null && it.image != null &&
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
// A first row that can't be tapped and has no icon is the place's name and kind.
val heading = rows.firstOrNull()?.takeIf {
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
}
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
}
}
}
@Composable
private fun PlaceHeading(row: CarRow) {
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
row.texts.orEmpty().take(2).forEach {
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
@Composable
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEachIndexed { i, row ->
GsIconBox(
onClick = { row.onClickDelegate.click("row") },
modifier = Modifier.weight(1f),
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
enabled = row.isEnabled,
shape = RoundedCornerShape(24.dp),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CarGlyph(row.image, Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/**
* One fact about a place, tighter than a list row: a small label over its value, or just the
* value when the row has no label. A phone number gets a Call button that asks first.
*/
@Composable
private fun DetailRow(row: CarRow, placeName: String) {
val context = LocalContext.current
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
val label = row.title.text().takeIf { texts.isNotEmpty() }
val values = texts.ifEmpty { listOf(row.title.text()) }
val number = values.firstOrNull { it.isPhoneNumber() }
val canCall = number != null &&
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
Row(
Modifier
.fillMaxWidth()
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
row.image?.let {
CarGlyph(it, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
label?.let {
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
values.take(3).forEach {
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (canCall) {
Spacer(Modifier.width(8.dp))
GsIconBox(
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(24.dp),
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Call", style = ChromeType.body)
}
}
}
ActionRow(row.actions.orEmpty())
}
}
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
private fun String.isPhoneNumber(): Boolean {
val t = trim()
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
return t.count { it.isDigit() } in 7..15
}
@Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle
// Shown at once and corrected when the app answers with its own template, so the switch
// never lags a tap; keyed on what the app last said so its answer wins.
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
Row(
Modifier
.fillMaxWidth()
.let {
if (!clickable) it else it.clickable {
when {
toggle != null -> {
checked = !checked
toggle.onCheckedChangeDelegate.checkedChanged(checked)
}
selection != null -> selection.onPick()
else -> row.onClickDelegate.click("row")
}
}
}
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selection != null) {
RadioButton(selected = selection.selected, onClick = null)
Spacer(Modifier.width(12.dp))
}
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
// large only changes how much room a row gets (more lines, a bigger icon above) - the
// type itself is the same scale as every other row, so a pane's one row and a plain
// list's tenth row read as the same kind of text.
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = ChromeType.title,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text(
line.text(),
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
if (toggle != null) {
Spacer(Modifier.width(8.dp))
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
}
}
}
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
/**
* Whether this colour stands out against the background that [content] was chosen for: at least
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
*/
private fun Color.readableOn(content: Color): Boolean {
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
val a = luminance() + 0.05f
val b = background.luminance() + 0.05f
return maxOf(a, b) / minOf(a, b) >= 3f
}
internal fun Color.luminanceIsDark(): Boolean = internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
@@ -0,0 +1,391 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.foundation.Image
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import app.seb3thehacker.gearslip.host.selected
import app.seb3thehacker.gearslip.host.checkedChanged
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.draw.alpha
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.Pane
import androidx.car.app.model.Row as CarRow
import androidx.car.app.model.Template
import app.seb3thehacker.gearslip.host.click
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.image
import app.seb3thehacker.gearslip.host.isDrawable
import app.seb3thehacker.gearslip.host.standardType
import app.seb3thehacker.gearslip.host.text
// Lists, grids, panes and rows of templated apps. Split out of TemplateParts.kt.
@Composable
internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
style = ChromeType.title,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@Composable
internal fun ItemListColumn(
single: ItemList?,
sectioned: List<ItemList>? = null,
modifier: Modifier = Modifier,
) {
val rows = buildList {
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
val onSelected = single?.onSelectedDelegate
val chosen = single?.selectedIndex ?: -1
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
itemsIndexed(rows) { index, row ->
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
}
}
}
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
@Composable
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return
LazyVerticalGrid(
columns = GridCells.Adaptive(84.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items) { item -> GridTile(item, iconSize = 36.dp) }
}
}
/**
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
* instead of wrapping into a grid and wants its full-size cover art.
*/
@Composable
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
Column(
modifier
.clickable { item.onClickDelegate.click("grid item") }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CarGlyph(item.image, Modifier.size(iconSize))
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
style = ChromeType.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = item.text.text()
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
* a single action counts itself down rather than waiting to be pressed, on the theory that a
* driver glancing at the car screen has already decided. It never applies to more than one
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
*/
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
val card = PlaceCard.of(rows)
Column(modifier) {
card.heading?.let { PlaceHeading(it) }
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
if (card.heading == null && card.buttons.isEmpty()) {
card.details.forEach { RowItem(it, large = true) }
} else {
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
}
}
}
/**
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
* heading and no buttons, and draws exactly as a plain list of rows.
*/
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
companion object {
fun of(rows: List<CarRow>): PlaceCard {
// A short tappable row with an icon and nothing under it is a button in all but name:
// MAPS.ME's "Let's go" and "Save".
val buttons = rows.filter {
it.onClickDelegate != null && it.toggle == null && it.image != null &&
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
// A first row that can't be tapped and has no icon is the place's name and kind.
val heading = rows.firstOrNull()?.takeIf {
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
}
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
}
}
}
@Composable
private fun PlaceHeading(row: CarRow) {
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
row.texts.orEmpty().take(2).forEach {
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
@Composable
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEachIndexed { i, row ->
GsIconBox(
onClick = { row.onClickDelegate.click("row") },
modifier = Modifier.weight(1f),
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
enabled = row.isEnabled,
shape = RoundedCornerShape(24.dp),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CarGlyph(row.image, Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/**
* One fact about a place, tighter than a list row: a small label over its value, or just the
* value when the row has no label. A phone number gets a Call button that asks first.
*/
@Composable
private fun DetailRow(row: CarRow, placeName: String) {
val context = LocalContext.current
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
val label = row.title.text().takeIf { texts.isNotEmpty() }
val values = texts.ifEmpty { listOf(row.title.text()) }
val number = values.firstOrNull { it.isPhoneNumber() }
val canCall = number != null &&
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
Row(
Modifier
.fillMaxWidth()
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
row.image?.let {
CarGlyph(it, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
label?.let {
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
values.take(3).forEach {
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (canCall) {
Spacer(Modifier.width(8.dp))
GsIconBox(
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(24.dp),
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Call", style = ChromeType.body)
}
}
}
ActionRow(row.actions.orEmpty())
}
}
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
private fun String.isPhoneNumber(): Boolean {
val t = trim()
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
return t.count { it.isDigit() } in 7..15
}
@Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle
// Shown at once and corrected when the app answers with its own template, so the switch
// never lags a tap; keyed on what the app last said so its answer wins.
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
Row(
Modifier
.fillMaxWidth()
.let {
if (!clickable) it else it.clickable {
when {
toggle != null -> {
checked = !checked
toggle.onCheckedChangeDelegate.checkedChanged(checked)
}
selection != null -> selection.onPick()
else -> row.onClickDelegate.click("row")
}
}
}
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selection != null) {
RadioButton(selected = selection.selected, onClick = null)
Spacer(Modifier.width(12.dp))
}
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
// large only changes how much room a row gets (more lines, a bigger icon above) - the
// type itself is the same scale as every other row, so a pane's one row and a plain
// list's tenth row read as the same kind of text.
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = ChromeType.title,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text(
line.text(),
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
if (toggle != null) {
Spacer(Modifier.width(8.dp))
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
}
}
}
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
/**
* Whether this colour stands out against the background that [content] was chosen for: at least
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
*/
internal fun Color.readableOn(content: Color): Boolean {
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
val a = luminance() + 0.05f
val b = background.luminance() + 0.05f
return maxOf(a, b) / minOf(a, b) >= 3f
}
@@ -31,6 +31,10 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import org.json.JSONObject import org.json.JSONObject
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.automirrored.filled.ArrowForward
/** /**
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one. * A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
@@ -56,6 +60,11 @@ fun WebApp(content: String) {
// the video's then leaves full screen too, instead of stranding them in it. // the video's then leaves full screen too, instead of stranding them in it.
var videoMadeFullscreen by remember { mutableStateOf(false) } var videoMadeFullscreen by remember { mutableStateOf(false) }
val fullscreen = CarFullscreen.active val fullscreen = CarFullscreen.active
// What the header shows and what its buttons can do, refreshed as pages load.
var pageTitle by remember { mutableStateOf("") }
var canGoForward by remember { mutableStateOf(false) }
// The address being typed, or null while the header shows the page title.
var address by remember { mutableStateOf<String?>(null) }
// Leaving the browser ends full screen, whoever started it. // Leaving the browser ends full screen, whoever started it.
DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } } DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } }
@@ -86,7 +95,18 @@ fun WebApp(content: String) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
// Back steps through the page's history first (the interceptor above), then leaves. // Back steps through the page's history first (the interceptor above), then leaves.
if (!fullscreen) { if (!fullscreen) {
ScreenHeader("Browser", Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { ScreenHeader(pageTitle.ifEmpty { "Browser" }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, "Search or type an address", {
fieldText = null
address = ""
})
if (canGoForward) {
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, "Forward", { web?.goForward() })
}
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, "Reload", { web?.reload() })
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, "Start page", {
web?.let { view -> loadContent(view, content) }
})
GsIconButton(MediaIcons.Fullscreen, "Full screen", CarFullscreen::enter) GsIconButton(MediaIcons.Fullscreen, "Full screen", CarFullscreen::enter)
} }
} }
@@ -99,8 +119,15 @@ fun WebApp(content: String) {
settings.domStorageEnabled = true settings.domStorageEnabled = true
settings.loadWithOverviewMode = true settings.loadWithOverviewMode = true
settings.useWideViewPort = true settings.useWideViewPort = true
webViewClient = LoggingClient(ring) webViewClient = LoggingClient(ring) { view ->
canGoForward = view.canGoForward()
pageTitle = view.title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
}
webChromeClient = object : WebChromeClient() { webChromeClient = object : WebChromeClient() {
override fun onReceivedTitle(view: WebView?, title: String?) {
pageTitle = title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
}
override fun onShowCustomView(view: View, callback: CustomViewCallback) { override fun onShowCustomView(view: View, callback: CustomViewCallback) {
videoMadeFullscreen = !CarFullscreen.active videoMadeFullscreen = !CarFullscreen.active
pageFullscreen = view to callback pageFullscreen = view to callback
@@ -117,11 +144,7 @@ fun WebApp(content: String) {
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }), FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
"GearslipField", "GearslipField",
) )
if (content.startsWith("http://") || content.startsWith("https://")) { loadContent(this, content)
loadUrl(content)
} else {
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
}
web = this web = this
} }
}, },
@@ -145,6 +168,17 @@ fun WebApp(content: String) {
} }
} }
} }
address?.let { typed ->
CarKeyboard(
text = typed,
onTextChange = { address = it },
onSubmit = {
web?.loadUrl(addressToUrl(typed))
address = null
},
onDismiss = { address = null },
)
}
fieldText?.let { text -> fieldText?.let { text ->
CarKeyboard( CarKeyboard(
text = text, text = text,
@@ -162,9 +196,29 @@ fun WebApp(content: String) {
} }
} }
private class LoggingClient(private val ring: String) : WebViewClient() { /** The start page: a web address, or a page of HTML Gearslip made itself. */
private fun loadContent(view: WebView, content: String) {
if (content.startsWith("http://") || content.startsWith("https://")) {
view.loadUrl(content)
} else {
view.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
}
}
/** Something that looks like an address opens as one; anything else is searched for. */
private fun addressToUrl(typed: String): String {
val text = typed.trim()
return when {
text.startsWith("http://") || text.startsWith("https://") -> text
' ' !in text && '.' in text -> "https://$text"
else -> "https://duckduckgo.com/?q=" + java.net.URLEncoder.encode(text, "UTF-8")
}
}
private class LoggingClient(private val ring: String, private val onLoaded: (WebView) -> Unit) : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
GearslipLog.i("car web page loaded: $url") GearslipLog.i("car web page loaded: $url")
view?.let(onLoaded)
view?.evaluateJavascript(FOCUS_TRACKING_JS, null) view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
view?.evaluateJavascript(focusRingJs(ring), null) view?.evaluateJavascript(focusRingJs(ring), null)
} }
@@ -0,0 +1,29 @@
package app.seb3thehacker.gearslip.car.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
/**
* The phone's Material You colours, with its accent washed over the surfaces. Material You's own
* surfaces are nearly neutral, which reads as flat grey on a car screen; tinting them with the
* phone's primary colour (purple on a purple phone) gives the whole UI the phone's hue, the way
* Material 3's surface tint does, and it changes with the wallpaper like everything else.
* Higher containers take more of the tint, so cards still stand off the background.
*/
fun ColorScheme.withTintedSurfaces(dark: Boolean): ColorScheme {
val base = if (dark) 0.03f else 0.02f
fun tint(own: Color, step: Int) = primary.copy(alpha = base + step * (if (dark) 0.015f else 0.01f)).compositeOver(own)
return copy(
background = tint(background, 0),
surface = tint(surface, 0),
surfaceDim = tint(surfaceDim, 0),
surfaceContainerLowest = tint(surfaceContainerLowest, 0),
surfaceContainerLow = tint(surfaceContainerLow, 1),
surfaceContainer = tint(surfaceContainer, 2),
surfaceContainerHigh = tint(surfaceContainerHigh, 3),
surfaceContainerHighest = tint(surfaceContainerHighest, 4),
surfaceBright = tint(surfaceBright, 4),
surfaceVariant = tint(surfaceVariant, 3),
)
}
@@ -13,6 +13,9 @@ class TemplateApp(
) { ) {
val isNavigation: Boolean get() = CarAppService.CATEGORY_NAVIGATION_APP in categories val isNavigation: Boolean get() = CarAppService.CATEGORY_NAVIGATION_APP in categories
/** A media car screen, or one that names no category (older media apps). */
val isMediaScreen: Boolean get() = kind == "media" || kind == "app"
/** "navigation", "media", ... - what the app told the system it is. */ /** "navigation", "media", ... - what the app told the system it is. */
val kind: String val kind: String
get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) } get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) }
@@ -403,6 +403,8 @@ class CarAppConnection(private val context: Context) {
// failure worth surfacing. // failure worth surfacing.
runCatching { runCatching {
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() { app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
// Car App Library 1.8 versions its host interfaces; each answers with the version it was built from.
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
override fun onSuccess(response: Bundleable?) { override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() } val value = response?.let { runCatching { it.get() }.getOrNull() }
navigationManager = when (value) { navigationManager = when (value) {
@@ -626,6 +628,7 @@ class CarAppConnection(private val context: Context) {
// --- host interfaces the app calls back into ----------------------------------------- // --- host interfaces the app calls back into -----------------------------------------
private fun createAppHost(owner: Any) = object : IAppHost.Stub() { private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
override fun getInterfaceVersion(): Int = IAppHost.VERSION
override fun invalidate() { override fun invalidate() {
GearslipLog.i("host: app invalidated its template") GearslipLog.i("host: app invalidated its template")
main.post { requestTemplate() } main.post { requestTemplate() }
@@ -664,6 +667,7 @@ class CarAppConnection(private val context: Context) {
val trip: StateFlow<androidx.car.app.navigation.model.Trip?> = _trip.asStateFlow() val trip: StateFlow<androidx.car.app.navigation.model.Trip?> = _trip.asStateFlow()
private val navigationHost = object : INavigationHost.Stub() { private val navigationHost = object : INavigationHost.Stub() {
override fun getInterfaceVersion(): Int = INavigationHost.VERSION
override fun navigationStarted() { override fun navigationStarted() {
GearslipLog.i("host: navigation started") GearslipLog.i("host: navigation started")
navigating = true navigating = true
@@ -691,6 +695,7 @@ class CarAppConnection(private val context: Context) {
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it. * IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
*/ */
private val hardwareHost = object : ICarHardwareHost.Stub() { private val hardwareHost = object : ICarHardwareHost.Stub() {
override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION
override fun getCarHardwareResult( override fun getCarHardwareResult(
type: Int, type: Int,
params: Bundleable?, params: Bundleable?,
@@ -721,6 +726,7 @@ class CarAppConnection(private val context: Context) {
* Library documents for a projected head unit; they are about the screen, not about us. * Library documents for a projected head unit; they are about the screen, not about us.
*/ */
private val constraintHost = object : IConstraintHost.Stub() { private val constraintHost = object : IConstraintHost.Stub() {
override fun getInterfaceVersion(): Int = IConstraintHost.VERSION
override fun getContentLimit(type: Int): Int = when (type) { override fun getContentLimit(type: Int): Int = when (type) {
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12 ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4 ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
@@ -739,10 +745,12 @@ class CarAppConnection(private val context: Context) {
* that pushes suggestions shouldn't fail because its host doesn't surface them. * that pushes suggestions shouldn't fail because its host doesn't surface them.
*/ */
private val suggestionHost = object : ISuggestionHost.Stub() { private val suggestionHost = object : ISuggestionHost.Stub() {
override fun getInterfaceVersion(): Int = ISuggestionHost.VERSION
override fun updateSuggestions(suggestions: Bundleable?) = Unit override fun updateSuggestions(suggestions: Bundleable?) = Unit
} }
private fun createCarHost(owner: Any) = object : ICarHost.Stub() { private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
override fun getInterfaceVersion(): Int = ICarHost.VERSION
private val appHost = createAppHost(owner) private val appHost = createAppHost(owner)
override fun getHost(type: String?): IBinder? = when (type) { override fun getHost(type: String?): IBinder? = when (type) {
@@ -775,6 +783,7 @@ class CarAppConnection(private val context: Context) {
*/ */
private fun call(name: String, onValue: (Any?) -> Unit, send: (IOnDoneCallback) -> Unit) { private fun call(name: String, onValue: (Any?) -> Unit, send: (IOnDoneCallback) -> Unit) {
val callback = object : IOnDoneCallback.Stub() { val callback = object : IOnDoneCallback.Stub() {
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
override fun onSuccess(response: Bundleable?) { override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() } val value = response?.let { runCatching { it.get() }.getOrNull() }
main.post { onValue(value) } main.post { onValue(value) }
@@ -790,6 +799,7 @@ class CarAppConnection(private val context: Context) {
} }
private fun noop(name: String) = object : IOnDoneCallback.Stub() { private fun noop(name: String) = object : IOnDoneCallback.Stub() {
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
override fun onSuccess(response: Bundleable?) = Unit override fun onSuccess(response: Bundleable?) = Unit
override fun onFailure(response: Bundleable?) = GearslipLog.w("host: $name failed") override fun onFailure(response: Bundleable?) = GearslipLog.w("host: $name failed")
} }
@@ -31,7 +31,41 @@ import kotlin.math.roundToInt
*/ */
/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */ /** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */
fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty() fun CarText?.text(): String {
val chars = this?.takeIf { !it.isEmpty }?.toCharSequence() ?: return ""
return chars.withSpansFilledIn()
}
/**
* Apps write a route's time and length as spans over placeholder characters ("\u0000 · \u0000"
* with a DurationSpan and a DistanceSpan), leaving the host to format them. Plain toString()
* keeps only the placeholders, which is how a route option ended up reading "·".
*/
private fun CharSequence.withSpansFilledIn(): String {
if (this !is android.text.Spanned) return toString()
val spans = getSpans(0, length, androidx.car.app.model.CarSpan::class.java)
.filter { it is androidx.car.app.model.DurationSpan || it is androidx.car.app.model.DistanceSpan }
.sortedBy { getSpanStart(it) }
if (spans.isEmpty()) return toString()
val out = StringBuilder()
var at = 0
spans.forEach { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
if (start < at || end > length) return@forEach
out.append(this, at, start)
out.append(
when (span) {
is androidx.car.app.model.DurationSpan -> remainingTime(span.durationSeconds)
is androidx.car.app.model.DistanceSpan -> span.distance.display()
else -> ""
},
)
at = end
}
out.append(this, at, length)
return out.toString().replace("\u0000", "").trim()
}
/** /**
* Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch * Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch
@@ -53,7 +87,7 @@ private val iconCache = android.util.LruCache<String, ImageBitmap>(200)
fun CarIcon?.image(context: Context): ImageBitmap? { fun CarIcon?.image(context: Context): ImageBitmap? {
val icon = this?.icon ?: return null val icon = this?.icon ?: return null
val key = icon.cacheKey(context) val key = icon.cacheKey(context)
iconCache.get(key)?.let { return it } key?.let { k -> iconCache.get(k)?.let { return it } }
val drawable: Drawable = runCatching { icon.loadDrawable(context) } val drawable: Drawable = runCatching { icon.loadDrawable(context) }
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") } .onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
.getOrNull() .getOrNull()
@@ -73,7 +107,7 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() } val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() }
.onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") } .onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
.getOrNull() ?: return null .getOrNull() ?: return null
iconCache.put(key, bitmap) key?.let { iconCache.put(it, bitmap) }
return bitmap return bitmap
} }
@@ -83,7 +117,15 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
* so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's * so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's
* new Saved button came up as its search glyph). * new Saved button came up as its search glyph).
*/ */
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String { /**
* Null for an icon that carries its own pixels: two different bitmaps describe the same, so a
* key would hand one app's turn arrow the picture of whatever bitmap was cached first.
*/
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String? {
if (type == androidx.core.graphics.drawable.IconCompat.TYPE_BITMAP ||
type == androidx.core.graphics.drawable.IconCompat.TYPE_ADAPTIVE_BITMAP ||
type == androidx.core.graphics.drawable.IconCompat.TYPE_DATA
) return null
if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe() if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe()
val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L) val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L)
return "${describe()}@$installed" return "${describe()}@$installed"
@@ -24,6 +24,7 @@ object KnownApps {
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well "nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
"com.mapquest.android.ace", // MapQuest: map, routing, navigation "com.mapquest.android.ace", // MapQuest: map, routing, navigation
"com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation "com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation
"app.vela", // Vela Maps: map, search, route options, turn-by-turn with lanes
) )
fun works(packageName: String): Boolean = packageName in working fun works(packageName: String): Boolean = packageName in working
@@ -99,7 +100,6 @@ object KnownApps {
* call working. Wins over [works] and [playerWorks] for the badge. * call working. Wins over [works] and [playerWorks] for the badge.
*/ */
private val partial = setOf( private val partial = setOf(
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
"com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first "com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first
"deezer.android.app", // Deezer: the same - it has to be opened on the phone first "deezer.android.app", // Deezer: the same - it has to be opened on the phone first
) )
@@ -37,7 +37,7 @@ import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider import app.seb3thehacker.gearslip.CertProvider
import app.seb3thehacker.gearslip.stats.UsageStats import app.seb3thehacker.gearslip.stats.UsageStats
private enum class Screen { INTRO, HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS } private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS, CAR_GROUP }
/** Three screens and a back stack of depth one: no navigation library needed. */ /** Three screens and a back stack of depth one: no navigation library needed. */
@Composable @Composable
@@ -47,8 +47,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context) !AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
} }
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) } val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
// Where launch goes once the intro (if any) is done. // Where launch goes: setup until it's done, then Home.
val afterIntro = remember { val start = remember {
when { when {
needsCertSetup -> Screen.CERT_SETUP needsCertSetup -> Screen.CERT_SETUP
needsSetupGuide -> Screen.SETUP_GUIDE needsSetupGuide -> Screen.SETUP_GUIDE
@@ -57,14 +57,14 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
else -> Screen.HOME else -> Screen.HOME
} }
} }
var screen by rememberSaveable { mutableStateOf(if (Intro.due(context)) Screen.INTRO else afterIntro) } var screen by rememberSaveable { mutableStateOf(start) }
// A replay from About goes back to Settings; the launch one carries on into setup.
var introReturn by rememberSaveable { mutableStateOf(afterIntro) }
// Home's "it worked" card opens Settings at usage notes rather than at the top. // Home's "it worked" card opens Settings at usage notes rather than at the top.
var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) } var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
// What's new and Connection help open from Home and from Settings; back returns to whichever. // What's new and Connection help open from Home and from Settings; back returns to whichever.
var backTo by rememberSaveable { mutableStateOf(Screen.SETTINGS) } var backTo by rememberSaveable { mutableStateOf(Screen.SETTINGS) }
BackHandler(enabled = screen != Screen.HOME && screen != Screen.INTRO) { // Which of the car's setting groups Home opened.
var carGroup by rememberSaveable { mutableStateOf(CarGroup.SCREEN) }
BackHandler(enabled = screen != Screen.HOME) {
screen = when (screen) { screen = when (screen) {
Screen.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS Screen.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS
Screen.HELP, Screen.WHATS_NEW -> backTo Screen.HELP, Screen.WHATS_NEW -> backTo
@@ -101,11 +101,10 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
.windowInsetsPadding(WindowInsets.statusBars) .windowInsetsPadding(WindowInsets.statusBars)
.consumeWindowInsets(WindowInsets.statusBars), .consumeWindowInsets(WindowInsets.statusBars),
) { ) {
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW && screen != Screen.INTRO) DebugBuildBanner() if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW) DebugBuildBanner()
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
when (screen) { when (screen) {
Screen.INTRO -> IntroScreen(onDone = { screen = introReturn })
Screen.HOME -> HomeScreen( Screen.HOME -> HomeScreen(
onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS }, onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS },
onOpenHelp = { backTo = Screen.HOME; screen = Screen.HELP }, onOpenHelp = { backTo = Screen.HOME; screen = Screen.HELP },
@@ -113,7 +112,9 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
onOpenCarPreview = { backTo = Screen.HOME; screen = Screen.CAR_PREVIEW }, onOpenCarPreview = { backTo = Screen.HOME; screen = Screen.CAR_PREVIEW },
onOpenLogs = { backTo = Screen.HOME; screen = Screen.LOGS }, onOpenLogs = { backTo = Screen.HOME; screen = Screen.LOGS },
onDisconnect = onDisconnect, onDisconnect = onDisconnect,
onOpenCarGroup = { carGroup = it; screen = Screen.CAR_GROUP },
) )
Screen.CAR_GROUP -> CarGroupScreen(carGroup, onBack = { screen = Screen.HOME })
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = backTo }) Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = backTo })
Screen.LOGS -> LogsScreen(onBack = { screen = backTo }) Screen.LOGS -> LogsScreen(onBack = { screen = backTo })
Screen.SETTINGS -> SettingsScreen( Screen.SETTINGS -> SettingsScreen(
@@ -6,11 +6,16 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
/** Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists). */ /**
* Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists),
* with the same accent tint over the surfaces as the car screen, so the two look like one app.
*/
@Composable @Composable
fun GearslipTheme(content: @Composable () -> Unit) { fun GearslipTheme(content: @Composable () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val scheme = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) val dark = isSystemInDarkTheme()
val scheme = (if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)).withTintedSurfaces(dark)
MaterialTheme(colorScheme = scheme, content = content) MaterialTheme(colorScheme = scheme, content = content)
} }
@@ -0,0 +1,335 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.CompositionLocalProvider
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.atan2
import kotlin.math.sqrt
import kotlin.math.sin
import kotlinx.coroutines.delay
/**
* The setup guide's tones, taken from the phone's own Material You colours like the rest of the app.
* Every size is taken from the screen's width, so the guide scales from small phones to tablets.
*/
internal class IntroColors(
val surface: Color, val onSurface: Color, val onVariant: Color, val outline: Color,
val primary: Color, val onPrimary: Color, val primaryC: Color, val onPrimaryC: Color,
val tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
)
@Composable
internal fun introColors(): IntroColors {
val m = MaterialTheme.colorScheme
return IntroColors(
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
primary = m.primary, onPrimary = m.onPrimary, primaryC = m.primaryContainer, onPrimaryC = m.onPrimaryContainer,
tertiaryC = m.tertiaryContainer, onTertiaryC = m.onTertiaryContainer,
secondaryC = m.secondaryContainer, onSecondaryC = m.onSecondaryContainer,
)
}
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
@Composable
internal fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
val a = remember(key) { Animatable(0f) }
LaunchedEffect(key) {
delay(delayMs.toLong())
a.animateTo(1f, tween(durationMs, easing = LinearEasing))
}
return a.value
}
/** Slides up and fades in, like the mockup's `up`. */
internal fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
val e = Rise.transform(p)
alpha = e
translationY = (1 - e) * distance.toPx()
}
/** Grows in with a bounce, like the mockup's `bounce`. */
internal fun Modifier.pop(p: Float) = graphicsLayer {
val e = Pop.transform(p)
alpha = p.coerceAtMost(1f)
scaleX = 0.5f + 0.5f * e
scaleY = 0.5f + 0.5f * e
}
/**
* The car's button styles side by side, each drawn in its own style, and a row of real car
* controls below that redraws in whichever is picked. Picking saves it, so the car shows it too.
*/
@Composable
internal fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
val chosenId by CarSettings.uiTheme.collectAsState()
val chosen = GsThemes.byId(chosenId)
// Flat first: it's the default.
val themes = listOf(GsThemes.Flat) + GsThemes.builtIn.filter { it != GsThemes.Flat }
Column(
Modifier.padding(top = cq(7f)).fillMaxWidth().up(entrance(key, 500, 700), cq(7f)),
verticalArrangement = Arrangement.spacedBy(cq(6f)),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
themes.forEach { theme ->
CompositionLocalProvider(LocalGsTheme provides theme) {
GsButton(
onClick = { CarSettings.setUiTheme(theme.id) },
modifier = Modifier.weight(1f),
tone = if (theme == chosen) GsTone.Primary else GsTone.Neutral,
contentPadding = PaddingValues(horizontal = cq(1f), vertical = cq(3f)),
) {
Text(
if (theme == chosen) "✓ ${theme.name}" else theme.name,
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.Bold),
maxLines = 1,
)
}
}
}
}
// What the car shows: the player's controls and a full-width action.
CompositionLocalProvider(LocalGsTheme provides chosen) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(cq(3f)),
verticalAlignment = Alignment.CenterVertically,
) {
GsIconButton(Icons.Filled.Search, "Search", onClick = {}, size = cq(15f), iconSize = cq(7f))
GsIconButton(Icons.Filled.PlayArrow, "Play", onClick = {}, tone = GsTone.Primary, size = cq(15f), iconSize = cq(8f))
GsButton(
onClick = {},
modifier = Modifier.weight(1f).height(cq(15f)),
tone = GsTone.Tonal,
) { Text("Navigate", style = TextStyle(fontSize = cqSp(4.6f), fontWeight = FontWeight.Bold)) }
}
}
}
}
/** Three keys of a keypad, pressing one after another. */
@Composable
internal fun ButtonsIcon(color: Color) {
val t by rememberInfiniteTransition(label = "keys").animateFloat(
0f, 3f, infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "keys",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
listOf(6f, 19f, 32f).forEachIndexed { i, x ->
// Each key dips for a moment while its turn comes round.
val dip = (1f - kotlin.math.abs(t - i - 0.5f) * 2f).coerceIn(0f, 1f) * 4f
drawRoundRect(color.copy(alpha = 0.45f), Offset(x, 16f + 4f), Size(10f, 16f),
androidx.compose.ui.geometry.CornerRadius(3f))
drawRoundRect(color, Offset(x, 16f + dip), Size(10f, 16f), androidx.compose.ui.geometry.CornerRadius(3f))
}
}
}
}
/** Material's scalloped "cookie": a circle whose radius waves around its edge. */
@Composable
internal fun Cookie(color: Color, modifier: Modifier) {
Canvas(modifier) {
val unit = size.minDimension / 100f
val path = Path()
var deg = 0
while (deg <= 360) {
val a = deg * PI / 180
val r = (44 + 4.5 * cos(9 * a)) * unit
val x = center.x + (r * cos(a)).toFloat()
val y = center.y + (r * sin(a)).toFloat()
if (deg == 0) path.moveTo(x, y) else path.lineTo(x, y)
deg += 3
}
path.close()
drawPath(path, color)
}
}
/** Draws in the mockup's 48-unit icon box. */
private inline fun DrawScope.inIconBox(block: DrawScope.() -> Unit) {
val s = size.minDimension / 48f
scale(s, s, Offset.Zero) { block() }
}
@Composable
internal fun WifiIcon(color: Color) {
val t = rememberInfiniteTransition(label = "wifi")
val waves = (0..2).map { i ->
t.animateFloat(
0.35f, 1f,
infiniteRepeatable(tween(800, delayMillis = 0, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 200)),
label = "wave$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val stroke = Stroke(5f, cap = StrokeCap.Round)
// The mockup's three arcs, inner to outer: radius r through (24 ± half, y).
listOf(Triple(8f, 6f, 34f), Triple(18f, 13f, 26f), Triple(28f, 20f, 18f)).forEachIndexed { i, (r, half, y) ->
val cy = y + sqrt(r * r - half * half)
val start = Math.toDegrees(atan2((y - cy).toDouble(), (-half).toDouble())).toFloat()
val end = Math.toDegrees(atan2((y - cy).toDouble(), half.toDouble())).toFloat()
drawArc(color.copy(alpha = waves[i].value), start, (end - start + 360f) % 360f, false,
Offset(24f - r, cy - r), Size(2 * r, 2 * r), style = stroke)
}
drawCircle(color, 3.6f, Offset(24f, 41f))
}
}
}
@Composable
internal fun MicIcon(color: Color) {
val t = rememberInfiniteTransition(label = "mic")
val waves = (0..1).map { i ->
t.animateFloat(
0.25f, 1f,
infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)),
label = "mic$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f))
val stroke = Stroke(4f, cap = StrokeCap.Round)
drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke)
drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round)
// Sound waves either side, pulsing as if someone's talking.
listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) ->
val r = if (i == 0) 15f else 21f
drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false,
Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke)
}
}
}
}
@Composable
internal fun StarIcon(color: Color) {
val turn by rememberInfiniteTransition(label = "star").animateFloat(
-8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val star = Path()
for (i in 0 until 10) {
val r = if (i % 2 == 0) 20f else 8.5f
val a = (-90 + i * 36) * PI / 180
val x = 24f + (r * cos(a)).toFloat()
val y = 25f + (r * sin(a)).toFloat()
if (i == 0) star.moveTo(x, y) else star.lineTo(x, y)
}
star.close()
rotate(turn, Offset(24f, 25f)) { drawPath(star, color) }
}
}
}
@Composable
internal fun HeartIcon(color: Color) {
val beat by rememberInfiniteTransition(label = "beat").animateFloat(
1f, 1f,
infiniteRepeatable(keyframes {
durationMillis = 1400
1f at 0; 1.16f at 210; 1f at 420; 1.1f at 630; 1f at 840
}),
label = "beat",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
scale(beat, beat, Offset(24f, 24f)) {
val heart = Path().apply {
moveTo(24f, 41f)
cubicTo(10f, 31f, 5f, 23f, 5f, 16f)
cubicTo(5f, 10f, 10f, 6f, 15f, 6f)
cubicTo(19f, 6f, 22f, 8f, 24f, 12f)
cubicTo(26f, 8f, 29f, 6f, 33f, 6f)
cubicTo(38f, 6f, 43f, 10f, 43f, 16f)
cubicTo(43f, 23f, 38f, 31f, 24f, 41f)
close()
}
drawPath(heart, color)
}
}
}
}
@Composable
internal fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
repeat(count) { i ->
val on = i == current
val width by animateDpAsState(if (on) cq(7f) else cq(2f), tween(300), label = "dot")
Box(
Modifier
.size(width, cq(2f))
.clip(RoundedCornerShape(50))
.background(if (on) c.primary else c.outline.copy(alpha = 0.45f)),
)
}
}
}
@@ -0,0 +1,245 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.AppTheme
import app.seb3thehacker.gearslip.car.CarApps
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.NavBarMode
import app.seb3thehacker.gearslip.car.NightMode
import app.seb3thehacker.gearslip.car.Units
import app.seb3thehacker.gearslip.car.theme.GsThemes
/** The car's setting groups, each opening its own screen from the phone's home screen. */
enum class CarGroup(val title: String) {
SCREEN("Screen"),
NAV_BAR("Nav bar and player"),
SOUND("Sound and voice"),
WARNINGS("Warnings"),
}
private val sizeChoices = listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large")
private val themeChoices = listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark")
private val nightChoices = listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night")
private val unitChoices = listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial", Units.METRIC to "Metric")
private val navBarChoices = listOf(
NavBarMode.SHOW to "Always show",
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
NavBarMode.HIDE to "Hide until tapped",
)
private fun openChoices() = listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }
/**
* The car's settings on the phone's home screen: one card per group with a summary of where it
* stands, each opening a screen of its own. They are the same settings as the car's own Settings
* app, so a change in either shows in both; the phone is just the easier place when parked.
*/
@Composable
internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Modifier) {
val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState()
val navBar by CarSettings.navBarMode.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
val locationWarning by CarSettings.locationWarning.collectAsState()
val playerLeft by CarSettings.playerLeft.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
fun summary(group: CarGroup) = when (group) {
CarGroup.SCREEN -> listOf(openChoices().label(open), sizeChoices.label(scale), themeChoices.label(theme))
CarGroup.NAV_BAR -> listOf(navBarChoices.label(navBar), "Player on the ${if (playerLeft) "left" else "right"}")
CarGroup.SOUND -> listOf(if (pipeAudio) "USB audio" else "Bluetooth audio", "Directions ${if (speakDirections) "on" else "off"}")
CarGroup.WARNINGS -> listOf(
"Bluetooth ${if (bluetoothWarning && !pipeAudio) "on" else "off"}",
"Location ${if (locationWarning) "on" else "off"}",
)
}.filter { it.isNotEmpty() }.joinToString(" · ")
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Car",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 4.dp),
)
SettingsCard {
CarGroup.entries.forEachIndexed { i, group ->
if (i > 0) SettingsDivider()
SettingsRow(
group.title,
Modifier.clickable { onOpen(group) },
subtitle = summary(group),
trailing = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) },
)
}
}
}
}
/** One group of the car's settings, on a screen of its own. */
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
androidx.compose.material3.Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
androidx.compose.material3.TopAppBar(
title = { Text(group.title) },
navigationIcon = {
androidx.compose.material3.IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp)
.verticalScroll(androidx.compose.foundation.rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingsCard { GroupRows(group) }
Text(
"These are the same settings as Settings in the car. A change here shows there too.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
@Composable
private fun GroupRows(group: CarGroup) {
val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState()
val navBar by CarSettings.navBarMode.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState()
val systemSpeech by CarSettings.systemSpeech.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
val locationWarning by CarSettings.locationWarning.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState()
val playerLeft by CarSettings.playerLeft.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState()
when (group) {
CarGroup.SCREEN -> {
Choice("Open on connect", openChoices(), open, CarSettings::setOpenOnConnect)
SettingsDivider()
Choice("Size", sizeChoices, scale, CarSettings::setScale)
SettingsDivider()
Choice("App theme", themeChoices, theme, CarSettings::setAppTheme)
SettingsDivider()
Choice("Buttons", GsThemes.builtIn.map { it.id to it.name }, uiTheme, CarSettings::setUiTheme)
SettingsDivider()
Choice("Map day/night", nightChoices, night, CarSettings::setNightMode)
SettingsDivider()
Choice("Units", unitChoices, units, CarSettings::setUnits)
}
CarGroup.NAV_BAR -> {
Choice("Nav bar", navBarChoices, navBar, CarSettings::setNavBarMode)
SettingsDivider()
Choice("Player in the nav bar", listOf(false to "Skip buttons", true to "Song title"), playerTitle, CarSettings::setPlayerTitle)
SettingsDivider()
Choice("Player beside the map", listOf(false to "Right", true to "Left"), playerLeft, CarSettings::setPlayerLeft)
SettingsDivider()
Toggle("Autoplay on connect", autoplay, CarSettings::setAutoplay)
}
CarGroup.SOUND -> {
Choice("Car audio", listOf(false to "Bluetooth", true to "USB"), pipeAudio, CarSettings::setPipeAudio)
SettingsDivider()
Toggle(
"Spoken directions",
speakDirections,
CarSettings::setSpeakDirections,
note = "Turn it off if your map app's own voice already plays in the car.",
)
SettingsDivider()
Toggle(
"Use the phone's speech recognition",
systemSpeech,
CarSettings::setSystemSpeech,
note = "On most phones that's Google's, which may send your voice to Google.",
)
}
CarGroup.WARNINGS -> {
if (!pipeAudio) {
Toggle("Warn when Bluetooth isn't connected", bluetoothWarning, CarSettings::setBluetoothWarning)
SettingsDivider()
}
Toggle("Warn when location is off", locationWarning, CarSettings::setLocationWarning)
}
}
}
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()
/** A setting with a few named choices: its current one shown, the rest a tap away. */
@Composable
private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
var menu by remember { mutableStateOf(false) }
SettingsRow(
title,
Modifier.clickable { menu = true },
trailing = {
// The menu hangs from the value it changes, at the row's end.
Box {
Text(choices.label(selected), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
DropdownMenu(menu, onDismissRequest = { menu = false }) {
choices.forEach { (value, label) ->
DropdownMenuItem(
text = { Text(label, fontWeight = if (value == selected) FontWeight.Bold else FontWeight.Normal) },
onClick = {
onSelect(value)
menu = false
},
)
}
}
}
},
)
}
@Composable
private fun Toggle(title: String, on: Boolean, onChange: (Boolean) -> Unit, note: String? = null) {
SettingsRow(
title,
Modifier.clickable { onChange(!on) },
subtitle = note,
trailing = { Switch(checked = on, onCheckedChange = null) },
)
}
@@ -68,6 +68,7 @@ fun HomeScreen(
onOpenCarPreview: () -> Unit, onOpenCarPreview: () -> Unit,
onOpenLogs: () -> Unit, onOpenLogs: () -> Unit,
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
onOpenCarGroup: (CarGroup) -> Unit,
) { ) {
val status by SessionStatus.state.collectAsStateWithLifecycle() val status by SessionStatus.state.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
@@ -125,6 +126,9 @@ fun HomeScreen(
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
UpdateCard() UpdateCard()
Spacer(Modifier.height(24.dp))
HomeCarSettings(onOpenCarGroup)
Spacer(Modifier.height(16.dp))
} }
// Sits at the bottom, where a thumb reaches it. // Sits at the bottom, where a thumb reaches it.
@@ -1,872 +0,0 @@
package app.seb3thehacker.gearslip.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider
import app.seb3thehacker.gearslip.AppSettings
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.CompositionLocalProvider
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
import app.seb3thehacker.gearslip.speech.SpeechModels
import app.seb3thehacker.gearslip.stats.UsageStats
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.atan2
import kotlin.math.sqrt
import kotlin.math.sin
import kotlin.random.Random
import kotlinx.coroutines.delay
/**
* The release intro: a few screens shown once after updating to a new release, before setup. New
* installs skip it. Its look follows the 0.3 mockup: Material shapes in the phone's own colours,
* with every size taken from the screen's width so it scales from small phones to tablets.
*/
object Intro {
private const val PREFS = "intro"
private const val KEY_SEEN = "seen_release"
private const val KEY_STEP = "step"
/** "0.3" from "0.3.00-alpha": the intro belongs to a release, not to each patch. */
val release: String = BuildConfig.VERSION_NAME.split('.').take(2).joinToString(".")
/**
* True once per release for people who used Gearslip before. A new install, or one with its
* data cleared, gets the setup guide instead: there's nothing "new" yet, so this release
* counts as seen and the next one shows. Install dates can't tell those apart, since
* clearing data keeps them, so this goes by what setup left behind; 0.2 saved the same.
*/
fun due(context: Context): Boolean {
if (context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_SEEN, null) == release) return false
val usedBefore = AppSettings.hasSeenPermissionsSetup(context) ||
AppSettings.hasSkippedCertSetup(context) || CertProvider.hasAnyCert(context)
if (!usedBefore) {
markSeen(context)
return false
}
return true
}
fun markSeen(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_SEEN, release)
.remove(KEY_STEP)
.apply()
}
/** The page the intro was on, so an app restart part-way through resumes there. */
internal fun savedStep(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_STEP, null)
internal fun saveStep(context: Context, step: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_STEP, step).apply()
}
}
/** The intro's tones, taken from the phone's own Material You colours like the rest of the app. */
internal class IntroColors(
val surface: Color, val onSurface: Color, val onVariant: Color, val outline: Color,
val primary: Color, val onPrimary: Color, val primaryC: Color, val onPrimaryC: Color,
val tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
)
@Composable
internal fun introColors(): IntroColors {
val m = MaterialTheme.colorScheme
return IntroColors(
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
primary = m.primary, onPrimary = m.onPrimary, primaryC = m.primaryContainer, onPrimaryC = m.onPrimaryContainer,
tertiaryC = m.tertiaryContainer, onTertiaryC = m.onTertiaryContainer,
secondaryC = m.secondaryContainer, onSecondaryC = m.onSecondaryContainer,
)
}
/** In the order shown: what's new, what to pick, housekeeping, then the people behind it. */
private enum class IntroStep { WELCOME, WIRELESS, KNOBS, VOICE, THEME, APPS, GRAPHENE, CHAT, THANKS }
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
@Composable
fun IntroScreen(onDone: () -> Unit) {
val context = LocalContext.current
val c = introColors()
// The GrapheneOS screen is only news to people running it.
val steps = remember {
IntroStep.entries.filter { it != IntroStep.GRAPHENE || UsageStats.osName(context) == "GrapheneOS" }
}
// Turning exploit protection back on restarts the app, so the intro picks up where it was.
var index by remember {
mutableIntStateOf(steps.indexOfFirst { it.name == Intro.savedStep(context) }.coerceAtLeast(0))
}
val step = steps[index]
LaunchedEffect(step) { Intro.saveStep(context, step.name) }
val finish = {
Intro.markSeen(context)
onDone()
}
BackHandler(enabled = index > 0) { index-- }
BoxWithConstraints(Modifier.fillMaxSize().background(c.surface)) {
val w = maxWidth
val h = maxHeight
// The mockup's cqw: one percent of the screen's width.
fun cq(n: Float): Dp = w * (n / 100f)
val density = LocalDensity.current
fun cqSp(n: Float): TextUnit = with(density) { cq(n).toSp() }
// The step on top, Back and Next under it, the same as the setup guide.
Column(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f).fillMaxWidth()) {
when (step) {
IntroStep.WELCOME -> Welcome(c, ::cq, ::cqSp, h)
IntroStep.WIRELESS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Wireless.",
"If your car does wireless Android Auto, pair once and leave the cable at home.", step) { WifiIcon(c.onSecondaryC) }
IntroStep.KNOBS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Knobs work.",
"Turn, push, done. No touchscreen? Gearslip doesn't need one anymore.", step) { DialIcon(c.onTertiaryC) }
IntroStep.VOICE -> VoiceStep(c, ::cq, ::cqSp, step)
IntroStep.GRAPHENE -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Protections back on.",
"On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode. If you " +
"turned it on for Gearslip, turn it off to get full protection back.", step,
action = "Open app settings" to {
runCatching {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
}
},
outlined = true,
) { ShieldIcon(c.onSecondaryC, c.secondaryC) }
IntroStep.THANKS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Thank you.",
"Thanks to your usage notes, Gearslip is confirmed to work in cars newer than 2020. " +
"Each note shows which cars work and which still fail, so I know what to fix next.", step,
action = "See which cars work" to { GearslipLinks.open(context, GearslipLinks.CARS) },
outlined = true,
secondAction = "Support on Liberapay" to { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
) { HeartIcon(c.onTertiaryC) }
IntroStep.CHAT -> BadgeStep(c, ::cq, ::cqSp, c.primaryC, "Come say hi.",
"Gearslip has a chat room on Matrix. Talk with other drivers about cars, apps, and " +
"where Gearslip is headed.", step,
action = "Join on Matrix" to { GearslipLinks.open(context, GearslipLinks.MATRIX) },
outlined = true,
) { ChatIcon(c.onPrimaryC, c.primaryC) }
IntroStep.APPS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Good company.",
"Free, open-source apps that work well in the car. The first in each row works best. Tap one to see it on GitHub.", step,
extra = { AppPicks(c, ::cq, ::cqSp, step) }) { StarIcon(c.onSecondaryC) }
IntroStep.THEME -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Your buttons.",
"Pick how the car's buttons look. Change it any time in the car's settings.", step,
extra = { ThemePicker(c, ::cq, ::cqSp, step) }) { ButtonsIcon(c.onTertiaryC) }
}
}
Column(Modifier.fillMaxWidth().padding(horizontal = cq(8f))) {
Dots(c, steps.size, index, ::cq, Modifier.align(Alignment.CenterHorizontally))
Spacer(Modifier.height(cq(5f)))
PillRow(
c, ::cq, ::cqSp,
onBack = if (index > 0) ({ index-- }) else null,
label = if (index == steps.lastIndex) "Start" else "Next",
) { if (index < steps.lastIndex) index++ else finish() }
Spacer(Modifier.height(cq(6f)))
}
}
}
}
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
@Composable
internal fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
val a = remember(key) { Animatable(0f) }
LaunchedEffect(key) {
delay(delayMs.toLong())
a.animateTo(1f, tween(durationMs, easing = LinearEasing))
}
return a.value
}
/** Slides up and fades in, like the mockup's `up`. */
internal fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
val e = Rise.transform(p)
alpha = e
translationY = (1 - e) * distance.toPx()
}
/** Grows in with a bounce, like the mockup's `bounce`. */
internal fun Modifier.pop(p: Float) = graphicsLayer {
val e = Pop.transform(p)
alpha = p.coerceAtMost(1f)
scaleX = 0.5f + 0.5f * e
scaleY = 0.5f + 0.5f * e
}
@Composable
private fun Welcome(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, height: Dp) {
val key = IntroStep.WELCOME
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
)
Box(Modifier.fillMaxSize()) {
Cookie(c.primaryC, Modifier.size(cq(120f)).offset(x = cq(28f), y = -cq(22f)).align(Alignment.TopEnd).graphicsLayer { rotationZ = spin; alpha = 0.9f })
Ticker(c.secondaryC, c.onSecondaryC, "OPEN SOURCE ✦ NO ACCOUNT ✦ YOUR CAR, YOUR PHONE ✦ ", cq, cqSp, reverse = true, tilt = 3f,
key = key, modifier = Modifier.align(Alignment.TopCenter).padding(top = height * 0.11f))
Column(Modifier.align(Alignment.CenterStart).padding(horizontal = cq(8f))) {
Text("Welcome to", color = c.onVariant, style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold),
modifier = Modifier.up(entrance(key, 100), cq(7f)))
Text(
Intro.release, color = c.primary,
style = TextStyle(fontSize = cqSp(44f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-2.4f), lineHeight = cqSp(38f)),
modifier = Modifier.offset(x = -cq(1.5f)).pop(entrance(key, 250, 900)),
)
Text(
"alpha · the big one", color = c.onTertiaryC,
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
modifier = Modifier.padding(top = cq(4f)).up(entrance(key, 700), cq(7f))
.clip(RoundedCornerShape(50)).background(c.tertiaryC).padding(horizontal = cq(4.4f), vertical = cq(2.2f)),
)
}
Confetti(c, key, Modifier.fillMaxSize())
}
}
/** A tilted band of words scrolling past, slid in from the side when the step opens. */
@Composable
private fun Ticker(
bg: Color, fg: Color, words: String, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit,
reverse: Boolean, tilt: Float, key: Any, modifier: Modifier,
) {
val enter = entrance(key, 400, 800)
val scroll by rememberInfiniteTransition(label = "ticker").animateFloat(
0f, 1f, infiniteRepeatable(tween(if (reverse) 20_000 else 16_000, easing = LinearEasing)), label = "ticker",
)
Box(
modifier
.fillMaxWidth()
.graphicsLayer {
rotationZ = tilt
scaleX = 1.2f
val e = Rise.transform(enter)
alpha = e
translationX = (1 - e) * -cq(30f).toPx()
}
.background(bg)
.padding(vertical = cq(2.6f)),
) {
Text(
words.repeat(4),
color = fg, maxLines = 1, softWrap = false,
style = TextStyle(fontSize = cqSp(5f), fontWeight = FontWeight.ExtraBold),
modifier = Modifier
.wrapContentWidth(Alignment.Start, unbounded = true)
.graphicsLayer {
// The text holds the words four times; sliding by half loops cleanly.
val shift = size.width / 2f * (if (reverse) 1 - scroll else scroll)
translationX = -shift
},
)
}
}
/** Material shapes thrown once from behind the version number. */
@Composable
private fun Confetti(c: IntroColors, key: Any, modifier: Modifier) {
val p = entrance(key, 450, 1300)
val bits = remember {
val r = Random(7)
List(22) { i ->
val a = i / 22f * 2 * PI.toFloat() + r.nextFloat() * 0.4f
val d = 30f + r.nextFloat() * 45f
Bit(cos(a) * d, sin(a) * d * 1.2f, r.nextInt(-270, 270).toFloat(), i % 4, i % 3)
}
}
if (p <= 0f || p >= 1f) return
val tones = listOf(c.primary, c.primaryC, c.tertiaryC, c.secondaryC)
Canvas(modifier) {
val unit = size.width / 100f
val e = CubicBezierEasing(0.15f, 0.7f, 0.3f, 1f).transform(p)
val alpha = if (p < 0.7f) 1f else 1f - (p - 0.7f) / 0.3f
val origin = Offset(size.width * 0.3f, size.height * 0.42f)
bits.forEach { b ->
val at = origin + Offset(b.dx * unit * e, b.dy * unit * e)
val s = 3.6f * unit * (0.3f + 0.7f * e)
rotate(b.spin * e, at) {
val color = tones[b.tone].copy(alpha = alpha)
when (b.shape) {
0 -> drawCircle(color, s / 2, at)
1 -> drawRoundRect(color, at - Offset(s / 2, s / 2), Size(s, s), androidx.compose.ui.geometry.CornerRadius(unit))
else -> drawRoundRect(color, at - Offset(s / 2, s / 4), Size(s, s / 2), androidx.compose.ui.geometry.CornerRadius(s / 4))
}
}
}
}
}
private class Bit(val dx: Float, val dy: Float, val spin: Float, val tone: Int, val shape: Int)
/** One idea per screen: a spinning cookie with an icon, a headline, a line of text, maybe a button. */
@Composable
private fun BadgeStep(
c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, badge: Color,
title: String, body: String, key: Any,
action: Pair<String, () -> Unit>? = null, outlined: Boolean = false,
secondAction: Pair<String, () -> Unit>? = null,
extra: (@Composable () -> Unit)? = null,
icon: @Composable () -> Unit,
) {
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
)
Column(
Modifier.fillMaxSize().padding(horizontal = cq(8f)),
verticalArrangement = Arrangement.Center,
) {
Box(Modifier.size(cq(34f)).pop(entrance(key, 50, 800)), contentAlignment = Alignment.Center) {
Cookie(badge, Modifier.fillMaxSize().graphicsLayer { rotationZ = spin })
Box(Modifier.size(cq(15f))) { icon() }
}
Spacer(Modifier.height(cq(8f)))
Text(
title, color = c.onSurface,
style = TextStyle(fontSize = cqSp(13.5f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-0.6f), lineHeight = cqSp(12.5f)),
modifier = Modifier.up(entrance(key, 200, 700), cq(7f)),
)
Spacer(Modifier.height(cq(4f)))
Text(
body, color = c.onVariant,
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
modifier = Modifier.up(entrance(key, 350, 700), cq(7f)),
)
extra?.invoke()
listOfNotNull(action, secondAction).forEachIndexed { i, (label, onClick) ->
Spacer(Modifier.height(if (i > 0) cq(3f) else if (outlined) cq(7f) else cq(9f)))
Text(
label,
color = if (outlined) c.primary else c.onPrimary,
style = TextStyle(fontSize = cqSp(if (outlined) 4.4f else 4.6f), fontWeight = FontWeight.Bold),
modifier = Modifier
.up(entrance(key, 500 + i * 150, 700), cq(7f))
.clip(RoundedCornerShape(50))
.then(
if (outlined) Modifier.border(BorderStroke(2.dp, c.outline), RoundedCornerShape(50))
else Modifier.background(c.primary),
)
.clickable(onClick = onClick)
.padding(horizontal = if (outlined) cq(7f) else cq(9f), vertical = if (outlined) cq(3.6f) else cq(4.2f)),
)
}
}
}
/**
* Voice: a few things to say, drifting past, and a button that fetches both speech models so the
* assistant works the first time someone tries it in the car.
*/
@Composable
private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
val context = LocalContext.current
remember { SpeechModels.init(context) }
val needed = SpeechModels.RECOMMENDED
val states = needed.map { SpeechModels.state(it).collectAsState().value }
val ready = states.all { it == SpeechModels.State.Ready }
val label = when {
states.any { it is SpeechModels.State.Downloading } ->
"Downloading, ${needed.zip(states).sumOf { (m, st) -> m.downloadMb * percentOf(st) } / needed.sumOf { it.downloadMb }}%"
states.any { it is SpeechModels.State.Failed } -> "Download failed. Try again"
else -> "Download voice, ${needed.sumOf { it.downloadMb }} MB"
}
BadgeStep(
c, cq, cqSp, c.primaryC, "Just say it.",
"Tap the mic, or press the talk button on your wheel, and speak. Your phone turns speech " +
"into text by itself, and nothing you say leaves it. Say \"help\" in the car for the full list.",
key,
action = if (ready) null else label to { SpeechModels.downloadRecommended(context) },
outlined = true,
extra = { CommandDrift(c, cq, cqSp, key) },
) { MicIcon(c.onPrimaryC) }
}
private fun percentOf(state: SpeechModels.State) = when (state) {
SpeechModels.State.Ready -> 100
is SpeechModels.State.Downloading -> state.percent
else -> 0
}
private val SAMPLE_COMMANDS = listOf(
"Play Daft Punk", "What's playing?", "Next song",
"Call Mom", "Open Organic Maps", "Pause", "Help",
)
/** Example commands rising slowly through a window that fades at both edges. */
@Composable
private fun CommandDrift(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
val scroll by rememberInfiniteTransition(label = "drift").animateFloat(
0f, 1f, infiniteRepeatable(tween(SAMPLE_COMMANDS.size * 2_200, easing = LinearEasing)), label = "drift",
)
Box(
Modifier
.padding(top = cq(6f))
.fillMaxWidth()
.height(cq(34f))
.up(entrance(key, 500, 700), cq(7f))
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(0f to Color.Transparent, 0.25f to Color.Black, 0.75f to Color.Black, 1f to Color.Transparent),
blendMode = BlendMode.DstIn,
)
}
.clipToBounds(),
) {
Column(
Modifier
.wrapContentHeight(Alignment.Top, unbounded = true)
// The list is there twice; rising by half loops cleanly.
.graphicsLayer { translationY = -size.height / 2f * scroll },
verticalArrangement = Arrangement.spacedBy(cq(2.4f)),
) {
repeat(2) {
SAMPLE_COMMANDS.forEach { say ->
Text(
"\u201c$say\u201d", color = c.onSecondaryC,
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.SemiBold),
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(c.secondaryC)
.padding(horizontal = cq(4.4f), vertical = cq(2f)),
)
}
}
}
}
}
/** Each kind of app with its picks, the first recommended most. */
private val APP_PICKS = listOf(
"Maps" to listOf(
"Organic Maps" to "https://github.com/organicmaps/organicmaps",
"Vela" to "https://github.com/PimpinPumpkin/Vela",
),
"Music" to listOf(
"ViVi Music" to "https://github.com/vivizzz007/vivi-music",
"Metrolist" to "https://github.com/MetrolistGroup/Metrolist",
),
"Podcasts" to listOf(
"AntennaPod" to "https://github.com/AntennaPod/AntennaPod",
),
)
/** One row per kind, with a chip per app opening its GitHub page; the rows arrive one after another. */
@Composable
private fun AppPicks(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
val context = LocalContext.current
Column(Modifier.padding(top = cq(6f)), verticalArrangement = Arrangement.spacedBy(cq(2.6f))) {
APP_PICKS.forEachIndexed { i, (kind, apps) ->
Column(
Modifier
.fillMaxWidth()
.up(entrance(key, 500 + i * 120, 700), cq(7f))
.clip(RoundedCornerShape(cq(5f)))
.background(c.secondaryC)
.padding(horizontal = cq(4f), vertical = cq(3f)),
) {
Text(kind.uppercase(), color = c.onSecondaryC.copy(alpha = 0.7f),
style = TextStyle(fontSize = cqSp(3.2f), fontWeight = FontWeight.Bold, letterSpacing = cqSp(0.3f)),
modifier = Modifier.padding(start = cq(1f), bottom = cq(1.6f)))
Row(horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
apps.forEach { (name, url) ->
Text(
"$name ↗", color = c.onSecondaryC,
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.ExtraBold),
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(50))
.border(BorderStroke(1.5.dp, c.onSecondaryC.copy(alpha = 0.35f)), RoundedCornerShape(50))
.clickable {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
.padding(horizontal = cq(3.6f), vertical = cq(2f)),
)
}
}
}
}
}
}
/**
* The car's button styles side by side, each drawn in its own style, and a row of real car
* controls below that redraws in whichever is picked. Picking saves it, so the car shows it too.
*/
@Composable
private fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
val chosenId by CarSettings.uiTheme.collectAsState()
val chosen = GsThemes.byId(chosenId)
// Flat first: it's the default.
val themes = listOf(GsThemes.Flat) + GsThemes.builtIn.filter { it != GsThemes.Flat }
Column(
Modifier.padding(top = cq(7f)).fillMaxWidth().up(entrance(key, 500, 700), cq(7f)),
verticalArrangement = Arrangement.spacedBy(cq(6f)),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
themes.forEach { theme ->
CompositionLocalProvider(LocalGsTheme provides theme) {
GsButton(
onClick = { CarSettings.setUiTheme(theme.id) },
modifier = Modifier.weight(1f),
tone = if (theme == chosen) GsTone.Primary else GsTone.Neutral,
contentPadding = PaddingValues(horizontal = cq(1f), vertical = cq(3f)),
) {
Text(
if (theme == chosen) "✓ ${theme.name}" else theme.name,
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.Bold),
maxLines = 1,
)
}
}
}
}
// What the car shows: the player's controls and a full-width action.
CompositionLocalProvider(LocalGsTheme provides chosen) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(cq(3f)),
verticalAlignment = Alignment.CenterVertically,
) {
GsIconButton(Icons.Filled.Search, "Search", onClick = {}, size = cq(15f), iconSize = cq(7f))
GsIconButton(Icons.Filled.PlayArrow, "Play", onClick = {}, tone = GsTone.Primary, size = cq(15f), iconSize = cq(8f))
GsButton(
onClick = {},
modifier = Modifier.weight(1f).height(cq(15f)),
tone = GsTone.Tonal,
) { Text("Navigate", style = TextStyle(fontSize = cqSp(4.6f), fontWeight = FontWeight.Bold)) }
}
}
}
}
/** Three keys of a keypad, pressing one after another. */
@Composable
private fun ButtonsIcon(color: Color) {
val t by rememberInfiniteTransition(label = "keys").animateFloat(
0f, 3f, infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "keys",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
listOf(6f, 19f, 32f).forEachIndexed { i, x ->
// Each key dips for a moment while its turn comes round.
val dip = (1f - kotlin.math.abs(t - i - 0.5f) * 2f).coerceIn(0f, 1f) * 4f
drawRoundRect(color.copy(alpha = 0.45f), Offset(x, 16f + 4f), Size(10f, 16f),
androidx.compose.ui.geometry.CornerRadius(3f))
drawRoundRect(color, Offset(x, 16f + dip), Size(10f, 16f), androidx.compose.ui.geometry.CornerRadius(3f))
}
}
}
}
/** Material's scalloped "cookie": a circle whose radius waves around its edge. */
@Composable
internal fun Cookie(color: Color, modifier: Modifier) {
Canvas(modifier) {
val unit = size.minDimension / 100f
val path = Path()
var deg = 0
while (deg <= 360) {
val a = deg * PI / 180
val r = (44 + 4.5 * cos(9 * a)) * unit
val x = center.x + (r * cos(a)).toFloat()
val y = center.y + (r * sin(a)).toFloat()
if (deg == 0) path.moveTo(x, y) else path.lineTo(x, y)
deg += 3
}
path.close()
drawPath(path, color)
}
}
/** Draws in the mockup's 48-unit icon box. */
private inline fun DrawScope.inIconBox(block: DrawScope.() -> Unit) {
val s = size.minDimension / 48f
scale(s, s, Offset.Zero) { block() }
}
@Composable
internal fun WifiIcon(color: Color) {
val t = rememberInfiniteTransition(label = "wifi")
val waves = (0..2).map { i ->
t.animateFloat(
0.35f, 1f,
infiniteRepeatable(tween(800, delayMillis = 0, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 200)),
label = "wave$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val stroke = Stroke(5f, cap = StrokeCap.Round)
// The mockup's three arcs, inner to outer: radius r through (24 ± half, y).
listOf(Triple(8f, 6f, 34f), Triple(18f, 13f, 26f), Triple(28f, 20f, 18f)).forEachIndexed { i, (r, half, y) ->
val cy = y + sqrt(r * r - half * half)
val start = Math.toDegrees(atan2((y - cy).toDouble(), (-half).toDouble())).toFloat()
val end = Math.toDegrees(atan2((y - cy).toDouble(), half.toDouble())).toFloat()
drawArc(color.copy(alpha = waves[i].value), start, (end - start + 360f) % 360f, false,
Offset(24f - r, cy - r), Size(2 * r, 2 * r), style = stroke)
}
drawCircle(color, 3.6f, Offset(24f, 41f))
}
}
}
@Composable
private fun DialIcon(color: Color) {
val angle by rememberInfiniteTransition(label = "dial").animateFloat(
-70f, -70f,
infiniteRepeatable(keyframes {
durationMillis = 2600
-70f at 0 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
70f at 1040
70f at 1430 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
-70f at 2470
}),
label = "dial",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
drawCircle(color, 19f, Offset(24f, 24f), style = Stroke(5f))
rotate(angle, Offset(24f, 24f)) {
drawRoundRect(color, Offset(21.5f, 7f), Size(5f, 13f), androidx.compose.ui.geometry.CornerRadius(2.5f))
}
}
}
}
@Composable
internal fun MicIcon(color: Color) {
val t = rememberInfiniteTransition(label = "mic")
val waves = (0..1).map { i ->
t.animateFloat(
0.25f, 1f,
infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)),
label = "mic$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f))
val stroke = Stroke(4f, cap = StrokeCap.Round)
drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke)
drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round)
// Sound waves either side, pulsing as if someone's talking.
listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) ->
val r = if (i == 0) 15f else 21f
drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false,
Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke)
}
}
}
}
@Composable
internal fun StarIcon(color: Color) {
val turn by rememberInfiniteTransition(label = "star").animateFloat(
-8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val star = Path()
for (i in 0 until 10) {
val r = if (i % 2 == 0) 20f else 8.5f
val a = (-90 + i * 36) * PI / 180
val x = 24f + (r * cos(a)).toFloat()
val y = 25f + (r * sin(a)).toFloat()
if (i == 0) star.moveTo(x, y) else star.lineTo(x, y)
}
star.close()
rotate(turn, Offset(24f, 25f)) { drawPath(star, color) }
}
}
}
@Composable
internal fun ShieldIcon(color: Color, tick: Color) {
val p = entrance(IntroStep.GRAPHENE, 800, 500)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val shield = Path().apply {
moveTo(24f, 4f); lineTo(40f, 10f); lineTo(40f, 22f)
cubicTo(40f, 32f, 33f, 40f, 24f, 44f)
cubicTo(15f, 40f, 8f, 32f, 8f, 22f)
lineTo(8f, 10f); close()
}
drawPath(shield, color)
drawPartialStroke(listOf(Offset(16f, 24f), Offset(22f, 30f), Offset(33f, 18f)), p, tick, 4.5f)
}
}
}
/** A speech bubble with three dots that pulse, like someone typing. */
@Composable
private fun ChatIcon(color: Color, dots: Color) {
val t = rememberInfiniteTransition(label = "typing")
val pulse = (0..2).map { i ->
t.animateFloat(
0.35f, 1f,
infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 170)),
label = "dot$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
drawRoundRect(color, Offset(4f, 7f), Size(40f, 28f), CornerRadius(10f, 10f))
drawPath(Path().apply { moveTo(12f, 33f); lineTo(10f, 43f); lineTo(22f, 33f); close() }, color)
pulse.forEachIndexed { i, a -> drawCircle(dots.copy(alpha = a.value), 3.2f, Offset(15f + i * 9f, 21f)) }
}
}
}
@Composable
internal fun HeartIcon(color: Color) {
val beat by rememberInfiniteTransition(label = "beat").animateFloat(
1f, 1f,
infiniteRepeatable(keyframes {
durationMillis = 1400
1f at 0; 1.16f at 210; 1f at 420; 1.1f at 630; 1f at 840
}),
label = "beat",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
scale(beat, beat, Offset(24f, 24f)) {
val heart = Path().apply {
moveTo(24f, 41f)
cubicTo(10f, 31f, 5f, 23f, 5f, 16f)
cubicTo(5f, 10f, 10f, 6f, 15f, 6f)
cubicTo(19f, 6f, 22f, 8f, 24f, 12f)
cubicTo(26f, 8f, 29f, 6f, 33f, 6f)
cubicTo(38f, 6f, 43f, 10f, 43f, 16f)
cubicTo(43f, 23f, 38f, 31f, 24f, 41f)
close()
}
drawPath(heart, color)
}
}
}
}
/** A polyline drawn up to [progress] of its length, so a tick can sweep in. */
private fun DrawScope.drawPartialStroke(points: List<Offset>, progress: Float, color: Color, width: Float) {
if (progress <= 0f) return
val lengths = points.zipWithNext { a, b -> (b - a).getDistance() }
var left = lengths.sum() * FastOutSlowInEasing.transform(progress.coerceAtMost(1f))
val path = Path().apply { moveTo(points[0].x, points[0].y) }
for (i in lengths.indices) {
if (left <= 0f) break
val f = (left / lengths[i]).coerceAtMost(1f)
val to = points[i] + (points[i + 1] - points[i]) * f
path.lineTo(to.x, to.y)
left -= lengths[i]
}
drawPath(path, color, style = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round))
}
@Composable
internal fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
repeat(count) { i ->
val on = i == current
val width by animateDpAsState(if (on) cq(7f) else cq(2f), tween(300), label = "dot")
Box(
Modifier
.size(width, cq(2f))
.clip(RoundedCornerShape(50))
.background(if (on) c.primary else c.outline.copy(alpha = 0.45f)),
)
}
}
}
@@ -57,6 +57,9 @@ private val recommended = listOf(
"Podcasts" to listOf( "Podcasts" to listOf(
RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", "Podcasts, chapters and all."), RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", "Podcasts, chapters and all."),
), ),
"Other cool apps" to listOf(
RecommendedApp("RF Sentinel", "com.rfsentinel.app", "https://github.com/CIS-C0/RFSentinel", "Spots trackers and cameras nearby."),
),
) )
/** Opened from Settings: the same list the setup guide ends with. */ /** Opened from Settings: the same list the setup guide ends with. */
@@ -135,11 +138,13 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
if (icon != null) { if (icon != null) {
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize()) Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
} else { } else {
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
} }
} }
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) { Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
Text(app.name, style = MaterialTheme.typography.titleMedium) // Colours set outright: the setup guide draws this on its own background, with no
// Surface to hand down a content colour, and unset text came out dark on dark.
Text(app.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (icon != null) { if (icon != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
@@ -200,6 +200,14 @@ private fun DebugSection() {
subtitle = "Posts a message notification to read on the car", subtitle = "Posts a message notification to read on the car",
icon = Icons.AutoMirrored.Filled.Send, icon = Icons.AutoMirrored.Filled.Send,
) )
SettingsDivider()
val badge by CarSettings.debugBadge.collectAsState()
SettingsRow(
"Debug badge on the car screen",
Modifier.clickable { CarSettings.setDebugBadge(!badge) },
subtitle = "Turn it off for screenshots",
trailing = { Switch(checked = badge, onCheckedChange = null) },
)
} }
} }
@@ -221,7 +229,7 @@ private fun CarAudioSection() {
SettingsRow( SettingsRow(
"USB", "USB",
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) }, Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
subtitle = "Clearer sound with no delay. Android asks to share audio once each drive", subtitle = "Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound",
trailing = { RadioButton(selected = usb, onClick = null) }, trailing = { RadioButton(selected = usb, onClick = null) },
) )
} }
@@ -93,6 +93,9 @@ import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
import app.seb3thehacker.gearslip.stats.UsageStats import app.seb3thehacker.gearslip.stats.UsageStats
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.layout.offset
/** /**
* One page of the guide. [permissions] drives a system permission dialog; [command] is an adb * One page of the guide. [permissions] drives a system permission dialog; [command] is an adb
@@ -100,6 +103,9 @@ import androidx.compose.runtime.DisposableEffect
* settings screen) without by itself finishing the page. Everything is skippable: none of this * settings screen) without by itself finishing the page. Everything is skippable: none of this
* can be verified as done, only offered. * can be verified as done, only offered.
*/ */
/** The guide's colours and its one-percent-of-the-width scale, for content that sizes itself the same way. */
internal class GuideScale(val c: IntroColors, val cq: (Float) -> Dp, val cqSp: (Float) -> TextUnit)
private class GuidePage( private class GuidePage(
val title: String, val title: String,
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */ /** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
@@ -112,8 +118,8 @@ private class GuidePage(
val command: String? = null, val command: String? = null,
val actionLabel: String? = null, val actionLabel: String? = null,
val onAction: (() -> Unit)? = null, val onAction: (() -> Unit)? = null,
/** Drawn under [explanation]. */ /** Drawn under [explanation], sized off the same screen-width scale as the page. */
val content: (@Composable () -> Unit)? = null, val content: (@Composable (GuideScale) -> Unit)? = null,
/** Only shown when the driver picked USB audio on the car audio page. */ /** Only shown when the driver picked USB audio on the car audio page. */
val usbOnly: Boolean = false, val usbOnly: Boolean = false,
) )
@@ -365,20 +371,24 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
) )
} }
// --- Apps worth installing, once everything above is set up ----------------------- // --- At the end, every time: apps worth installing, how the car's buttons look, and last
if (SHOW_RECOMMENDED_APPS) { // the usage notes. On a replay, the switch shows what was chosen before.
add( add(
GuidePage( GuidePage(
title = "Good company.", title = "Good company.",
explanation = RECOMMENDED_INTRO, explanation = RECOMMENDED_INTRO,
icon = { StarIcon(it) }, icon = { StarIcon(it) },
content = { RecommendedAppsList() }, content = { RecommendedAppsList() },
), ),
) )
} add(
GuidePage(
// --- The very end, every time: usage notes. On a replay, the switch shows what was title = "Your buttons.",
// chosen before. explanation = "Pick how the car's buttons look. Change it any time in the car's settings.",
icon = { ButtonsIcon(it) },
content = { ThemePicker(it.c, it.cq, it.cqSp, key = "guide-buttons") },
),
)
add(usageNotesPage(context)) add(usageNotesPage(context))
} }
@@ -417,7 +427,7 @@ private fun AudioChoice() {
) { CarSettings.setPipeAudio(false) } ) { CarSettings.setPipeAudio(false) }
AudioCard( AudioCard(
"USB", null, "USB", null,
"Clearer sound with no delay. Android asks to share audio once each drive.", "Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound.",
selected = usb, selected = usb,
) { CarSettings.setPipeAudio(true) } ) { CarSettings.setPipeAudio(true) }
} }
@@ -509,10 +519,11 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
// pushes them off the screen. Short pages sit in the middle. // pushes them off the screen. Short pages sit in the middle.
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
key(index) { key(index) {
val scroll = rememberScrollState()
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(scroll)
.heightIn(min = maxHeight) .heightIn(min = maxHeight)
.padding(vertical = cq(6f)), .padding(vertical = cq(6f)),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -550,7 +561,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)), style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
) )
page.command?.let { CopyableCommand(it) } page.command?.let { CopyableCommand(it) }
page.content?.invoke() page.content?.invoke(GuideScale(c, ::cq, ::cqSp))
if (page.permissions != null && alreadyGranted) { if (page.permissions != null && alreadyGranted) {
Text( Text(
"Already allowed.", color = c.primary, "Already allowed.", color = c.primary,
@@ -559,6 +570,9 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
} }
} }
} }
// A long page (the app list, the usage notes) says so: a fade over the
// last lines and a nudge to keep going, until the bottom is in view.
if (scroll.canScrollForward) ScrollHint(c, ::cq, ::cqSp, Modifier.align(Alignment.BottomCenter))
} }
} }
@@ -585,6 +599,28 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
} }
} }
/** A fade into the page's background with "Scroll for more" over it, at the bottom of a page that goes on. */
@Composable
private fun ScrollHint(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, modifier: Modifier = Modifier) {
val bob by rememberInfiniteTransition(label = "hint").animateFloat(
0f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "bob",
)
Box(
modifier
.fillMaxWidth()
.height(cq(22f))
.background(androidx.compose.ui.graphics.Brush.verticalGradient(listOf(c.surface.copy(alpha = 0f), c.surface))),
contentAlignment = Alignment.BottomCenter,
) {
Text(
"Scroll for more ↓",
color = c.primary,
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = cq(2f)).offset(y = cq(1.2f) * bob),
)
}
}
internal enum class PillStyle { FILLED, OUTLINED, TEXT } internal enum class PillStyle { FILLED, OUTLINED, TEXT }
/** The intro's rounded buttons: filled for the step's main action, outlined beside it, bare text to skip. */ /** The intro's rounded buttons: filled for the step's main action, outlined beside it, bare text to skip. */
@@ -87,6 +87,8 @@ object WirelessLink {
val busy: Boolean get() = worker?.isAlive == true val busy: Boolean get() = worker?.isAlive == true
/** Set when Android drops the car's network mid-try, so the rest don't bind to a dead one. */ /** Set when Android drops the car's network mid-try, so the rest don't bind to a dead one. */
@Volatile private var lostNetwork: Network? = null @Volatile private var lostNetwork: Network? = null
/** The car network asked for or joined, until Android says it's gone or never came. */
@Volatile private var activeSsid: String? = null
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */ /** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */
@Volatile private var tcpRun = 0 @Volatile private var tcpRun = 0
private val writeLock = Any() private val writeLock = Any()
@@ -131,6 +133,7 @@ object WirelessLink {
runCatching { context.getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(cb) } runCatching { context.getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(cb) }
} }
network = null network = null
activeSsid = null
// Stopped before the car's session began, so nothing else resets Home's card. // Stopped before the car's session began, so nothing else resets Home's card.
if (starting) { if (starting) {
log.i("wireless start-up stopped") log.i("wireless start-up stopped")
@@ -223,6 +226,14 @@ object WirelessLink {
val bssid = text(3) val bssid = text(3)
val security = Wire.varint(f, 4) ?: -1 val security = Wire.varint(f, 4) ?: -1
log.i("<- InfoResponse: ssid=\"$ssid\" bssid=$bssid security=$security password=${if (password.isEmpty()) "none" else "(${password.length} chars)"}") log.i("<- InfoResponse: ssid=\"$ssid\" bssid=$bssid security=$security password=${if (password.isEmpty()) "none" else "(${password.length} chars)"}")
// ZLink head units send the Wi-Fi details again once the stream is up.
// Asking Android for the network again releases the one carrying the
// stream, which aborts its socket; a phone running Android Auto just
// carries on, so the repeat is ignored while that network is in use.
if (ssid == activeSsid) {
log.i("InfoResponse again for \"$ssid\", which the phone is already on - ignoring it")
continue
}
val join = { ip: String, port: Int -> joinWifi(context, output, name, ssid, password, bssid, ip, port) } val join = { ip: String, port: Int -> joinWifi(context, output, name, ssid, password, bssid, ip, port) }
val known = target val known = target
if (known != null) { if (known != null) {
@@ -332,6 +343,7 @@ object WirelessLink {
} }
override fun onUnavailable() { override fun onUnavailable() {
if (activeSsid == ssid) activeSsid = null
log.w("Wi-Fi: couldn't join \"$ssid\" (declined, out of range, or wrong password)") log.w("Wi-Fi: couldn't join \"$ssid\" (declined, out of range, or wrong password)")
runCatching { send(output, MSG_CONNECTION_STATUS, Protobuf.varintField(1, -1)) } runCatching { send(output, MSG_CONNECTION_STATUS, Protobuf.varintField(1, -1)) }
SessionStatus.failed( SessionStatus.failed(
@@ -356,6 +368,7 @@ object WirelessLink {
override fun onLost(net: Network) { override fun onLost(net: Network) {
lostNetwork = net lostNetwork = net
if (activeSsid == ssid) activeSsid = null
log.w( log.w(
"Wi-Fi: lost \"$ssid\" - Android dropped the car's network. Picking it by hand " + "Wi-Fi: lost \"$ssid\" - Android dropped the car's network. Picking it by hand " +
"in the Wi-Fi list does this; the remaining tries go over the phone's own Wi-Fi", "in the Wi-Fi list does this; the remaining tries go over the phone's own Wi-Fi",
@@ -364,6 +377,7 @@ object WirelessLink {
} }
network?.let { runCatching { manager.unregisterNetworkCallback(it) } } network?.let { runCatching { manager.unregisterNetworkCallback(it) } }
network = callback network = callback
activeSsid = ssid
manager.requestNetwork(request, callback, WIFI_WAIT_MS) manager.requestNetwork(request, callback, WIFI_WAIT_MS)
} }
+8
View File
@@ -9,4 +9,12 @@
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
</style> </style>
<!-- For PermissionActivity: only Android's own permission dialog shows, over whatever was there. -->
<style name="Theme.Gearslip.Invisible" parent="Theme.Gearslip">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowAnimationStyle">@null</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources> </resources>
+1 -1
View File
@@ -8,7 +8,7 @@ espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
composeBom = "2026.05.01" composeBom = "2026.05.01"
carApp = "1.7.0" carApp = "1.8.0-rc01"
appcompat = "1.7.1" appcompat = "1.7.1"
material = "1.14.0" material = "1.14.0"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"