Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2344169af | ||
|
|
dc287a7fa2 | ||
|
|
2fcd586e67 | ||
|
|
184e4b70c8 | ||
|
|
30b185e86a | ||
|
|
c888d41d51 | ||
|
|
b0ffcf2b54 | ||
|
|
feab4b4e96 | ||
|
|
88e871c65e | ||
|
|
dc7018f394 | ||
|
|
e920093fa8 | ||
|
|
b71905c8c6 | ||
|
|
0a5acc012e | ||
|
|
8d673d855b | ||
|
|
6751951308 | ||
|
|
0f2582f638 | ||
|
|
bb07b95a9f | ||
|
|
9796320b58 | ||
|
|
3cea17d178 | ||
|
|
795963f178 | ||
|
|
ca33b5d01c | ||
|
|
aaa8babd09 | ||
|
|
636e40d88a | ||
|
|
1481bd9c52 | ||
|
|
b57bfb5324 | ||
|
|
e2e0d3f8bb | ||
|
|
d90c39f50a | ||
|
|
8a6c358257 | ||
|
|
de01448f1b | ||
|
|
f414a3c2c8 | ||
|
|
ea45bf994b | ||
|
|
f8aa0a259a | ||
|
|
0cf28b875c | ||
|
|
1c4b472ce0 | ||
|
|
e54fb08fc9 | ||
|
|
0afe857420 |
No files matched your search
@@ -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
|
||||
- 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.
|
||||
- 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 browser goes full screen, for the whole page or a video in it.
|
||||
- The nav bar can hide while you navigate, or until you tap the screen.
|
||||
- Directions sit in short bars, so map apps keep their maps clear of them.
|
||||
- Share a log from an earlier launch, not just the current one.
|
||||
- In the car preview, you type with the phone's keyboard.
|
||||
- Navigation cards sit down the left side, with a bigger turn card that shows your lane. The map keeps the right side.
|
||||
- The car screen and the phone app take their colors from your phone's Material You palette.
|
||||
- The car's settings sit on the phone's home screen, each group on its own screen.
|
||||
- Put the home screen's player left or right of the map.
|
||||
- The browser shows the page title, and it has an address bar, forward, reload, and a start page.
|
||||
- If Gearslip lacks call permissions, the car's Phone screen asks for them on the phone.
|
||||
|
||||
## Changed
|
||||
- Help, Preview, and Logs sit in a row at the bottom of the phone's home screen.
|
||||
- What's new, at the top of Settings, opens the release notes here on GitHub.
|
||||
- Gearslip uses Car App Library 1.8. Map apps show the right turn arrows and route times, and Vela works fully.
|
||||
- Setup ends with recommended apps, then the button picker, then usage notes.
|
||||
|
||||
## Fixed
|
||||
- Before each spoken turn, Gearslip asks the car for its speakers, as Android Auto does. Some head units rebooted without this.
|
||||
- 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.
|
||||
- Head units that take only AAC music get one start message, not two. A 2014 aftermarket unit dropped the connection over the second.
|
||||
- With music on Bluetooth, the car keeps its Bluetooth source.
|
||||
- Head units offering Android Auto 4.x and 6.x get a version they accept.
|
||||
- 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.
|
||||
- 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.
|
||||
- Gearslip no longer crashes when a wireless connection ends.
|
||||
- Opening a second music app without playing anything keeps the player on the song that's still playing.
|
||||
- Vela's speed sign is full size again.
|
||||
- RF Sentinel and other non-media car apps get their own tile in the launcher.
|
||||
@@ -31,25 +31,27 @@ screen-sharing mode.
|
||||
|---|---|
|
||||
|  |  |
|
||||
| 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 in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
||||
|  |  |
|
||||
| The weather, your next event, and what you missed. | Browse the web while parked. |
|
||||
|  |  |
|
||||
| Ask for a place, a song, or an app. | Find coffee nearby without leaving the map. |
|
||||
|  |  |
|
||||
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
||||
|  |  |
|
||||
| The weather, your week, and what you missed. | Browse the web while parked. |
|
||||
|  |  |
|
||||
| Ask for a place, a song, or an app. | Pick a route to coffee nearby. Each one shows its time and distance. |
|
||||
|
||||
## Features
|
||||
|
||||
- **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.
|
||||
Organic Maps, OsmAnd, and Flitsmeister work today. Vela runs, with rough
|
||||
edges.
|
||||
Organic Maps, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you
|
||||
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
|
||||
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
||||
play the best match.
|
||||
- **The web.** A browser template with its own on-screen keyboard, for anything that
|
||||
doesn't ship a car interface of its own.
|
||||
- **The web.** A browser with an address bar, back and forward, and its own on-screen
|
||||
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
|
||||
the current and coming weather.
|
||||
- **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,
|
||||
messaging, and the rest. Press and hold an app to move it.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
- **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
|
||||
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,
|
||||
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.
|
||||
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
||||
Library defines, and it draws its own keyboard for search, sign-in, and text fields.
|
||||
It has been tested against several real navigation and media apps.
|
||||
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car
|
||||
screen, and it draws its own keyboard for search, sign-in, and text fields. It has been
|
||||
tested against several real navigation and media apps.
|
||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||
used in homemade car dashboards.
|
||||
- **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
|
||||
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
|
||||
car to hear calls through its speakers.
|
||||
- **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
|
||||
too. USB sounds a little clearer, with no delay, but Android shows a screen-share
|
||||
consent dialog once per drive. Gearslip never reads the screen; capturing playback is
|
||||
too. USB sounds a little clearer, with no delay, but each drive Android asks you to
|
||||
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.
|
||||
- **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
|
||||
|
||||
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 569 KiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 116 KiB |
@@ -38,9 +38,9 @@ android {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 11
|
||||
versionCode = 12
|
||||
// -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"
|
||||
// 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.
|
||||
@@ -106,9 +106,10 @@ android {
|
||||
|
||||
}
|
||||
|
||||
// Renames the produced APK and moves it to the root build/ folder (<root>/build/Gearslip-debug.apk)
|
||||
// after the fact rather than fighting AGP's variant-output API across versions - simpler and
|
||||
// doesn't break when that API moves again.
|
||||
// Copies the produced APK to the root build/ folder (<root>/build/Gearslip-debug.apk) after the
|
||||
// fact rather than fighting AGP's variant-output API across versions - simpler and doesn't break
|
||||
// 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") {
|
||||
doLast {
|
||||
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
|
||||
@@ -118,7 +119,6 @@ tasks.register("renameApk") {
|
||||
.forEach { apk ->
|
||||
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
|
||||
apk.copyTo(renamed, overwrite = true)
|
||||
apk.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,12 @@
|
||||
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.
|
||||
-->
|
||||
<activity
|
||||
android:name=".PermissionActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@style/Theme.Gearslip.Invisible" />
|
||||
|
||||
<activity-alias
|
||||
android:name=".GearslipUsbAccessory"
|
||||
android:targetActivity=".GearslipActivity"
|
||||
|
||||
@@ -14,7 +14,6 @@ import android.os.ParcelFileDescriptor
|
||||
import android.provider.Settings
|
||||
import android.view.MotionEvent
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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
|
||||
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
||||
enableEdgeToEdge()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
GearslipLog.init(this)
|
||||
GearslipLog.installCrashHandler()
|
||||
|
||||
@@ -1089,11 +1089,17 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
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(
|
||||
width = width,
|
||||
height = height,
|
||||
// 60fps only over a cable: over Wi-Fi it doubles the data for little the driver sees.
|
||||
frameRate = if (config.frameRate == 1 && !wireless) 60 else 30,
|
||||
frameRate = videoFrameRate,
|
||||
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
||||
wireless = wireless,
|
||||
onCodecConfig = { csd ->
|
||||
@@ -1378,8 +1384,10 @@ class GearslipRunner(
|
||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen")
|
||||
log.i(
|
||||
"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
|
||||
// of whether we think one is owed. Protects against any future case where
|
||||
@@ -1394,7 +1402,66 @@ class GearslipRunner(
|
||||
}.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 {
|
||||
/** 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
|
||||
const val MSG_VERSION_REQUEST = 1
|
||||
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 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
|
||||
@Volatile private var running = false
|
||||
private var frameIndex = 0L
|
||||
@@ -51,6 +59,8 @@ class VideoSource(
|
||||
val bitRate = defaultBitRate(width, height, frameRate, level).let {
|
||||
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 {
|
||||
setInteger(
|
||||
MediaFormat.KEY_COLOR_FORMAT,
|
||||
@@ -128,9 +138,28 @@ class VideoSource(
|
||||
}.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() {
|
||||
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() }
|
||||
inputSurface = null
|
||||
runCatching { codec?.stop() }
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
@@ -67,7 +69,7 @@ fun CarAppStage(
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// The app's own drawing goes underneath; everything else is the host's rendering of
|
||||
// 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) {
|
||||
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.
|
||||
val (width, height) = size
|
||||
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) {
|
||||
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,
|
||||
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(
|
||||
color = Color.Black.copy(alpha = 0.55f),
|
||||
contentColor = Color.White,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = modifier.fillMaxWidth().height(CHROME_BAR),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
Modifier.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
status.app ?: "Car app",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val detail = buildList {
|
||||
status.template?.let { add(it) }
|
||||
add(if (status.surfaceAttached) "drawing" else "waiting for the app")
|
||||
}.joinToString(" - ")
|
||||
Text(detail, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") }
|
||||
Text(
|
||||
status.app ?: "Car app",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsIconButton(
|
||||
androidx.compose.material.icons.Icons.Filled.Close,
|
||||
"Close ${status.app ?: "the app"}",
|
||||
onDisconnect,
|
||||
size = 36.dp,
|
||||
iconSize = 20.dp,
|
||||
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. */
|
||||
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
|
||||
}
|
||||
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.
|
||||
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(
|
||||
Modifier
|
||||
.weight(if (splitScreen) 2.1f else 1f)
|
||||
@@ -103,8 +108,7 @@ fun CarHome() {
|
||||
) {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
if (splitScreen) {
|
||||
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
|
||||
if (splitScreen && !playerLeft) {
|
||||
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()
|
||||
}.getOrNull()
|
||||
|
||||
val navApps = CarAppCatalog.installed(context)
|
||||
val mediaApps = MediaCatalog.installed(context)
|
||||
// Some apps (Spotify among them) ship both a Car App Library template and a legacy
|
||||
// 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 carApps = CarAppCatalog.installed(context).associateBy { it.component.packageName }
|
||||
val mediaApps = MediaCatalog.installed(context).associateBy { it.component.packageName }
|
||||
val messagingApps = MessagingCatalog.installed(context).associateBy { it.packageName }
|
||||
|
||||
val messagingApps = MessagingCatalog.installed(context)
|
||||
// Google Messages ships its own car app, which won't start under Gearslip. Gearslip's
|
||||
// own Messages screen does the same job, so a messenger gets only that tile.
|
||||
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
||||
|
||||
val nav = navApps
|
||||
.filter { it.component.packageName !in messagingPackages }
|
||||
// An app whose template screen is rejected but whose player works through Gearslip
|
||||
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report
|
||||
// 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)
|
||||
// One decision per app over everything it offers a car - a car screen, a player, car
|
||||
// notifications - rather than a filter per kind that has to know about the others.
|
||||
val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
|
||||
val icon by lazy { iconOf(pkg) }
|
||||
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg]).map { tile ->
|
||||
when (tile) {
|
||||
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
|
||||
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
|
||||
is AppTile.Messages -> Entry(tile.app.label, icon, messaging = tile.app)
|
||||
}
|
||||
}
|
||||
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.
|
||||
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 }
|
||||
}
|
||||
return tiles.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 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) {
|
||||
context = appContext.applicationContext
|
||||
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
||||
@@ -130,6 +137,8 @@ object CarServices {
|
||||
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
||||
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
|
||||
autoplayPending = autoplay
|
||||
media.connect(app)
|
||||
@@ -151,12 +160,26 @@ object CarServices {
|
||||
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. */
|
||||
fun closeMedia() {
|
||||
if (media.now.value.playing) media.togglePlay()
|
||||
media.disconnect()
|
||||
_mediaApp.value = null
|
||||
autoplayPending = false
|
||||
stillPlaying = null
|
||||
}
|
||||
|
||||
/** 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_AUTOPLAY = "car_autoplay"
|
||||
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_UI_THEME = "car_ui_theme"
|
||||
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_SYSTEM_SPEECH = "car_system_speech"
|
||||
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_UNITS = "car_units"
|
||||
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 autoplayFlow = MutableStateFlow(false)
|
||||
private val playerTitleFlow = MutableStateFlow(false)
|
||||
private val playerLeftFlow = MutableStateFlow(false)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||
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 systemSpeechFlow = MutableStateFlow(false)
|
||||
private val carSensorsFlow = MutableStateFlow(false)
|
||||
private val adaptiveVideoFlow = MutableStateFlow(false)
|
||||
private val debugBadgeFlow = MutableStateFlow(true)
|
||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||
|
||||
@@ -215,6 +221,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
*/
|
||||
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 openOnConnect: StateFlow<String> = openFlow
|
||||
val nightMode: StateFlow<NightMode> = nightFlow
|
||||
@@ -267,6 +276,20 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
*/
|
||||
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
|
||||
* 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)
|
||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "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"
|
||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||
// 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"
|
||||
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "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"
|
||||
unitsFlow.value = runCatching {
|
||||
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())
|
||||
}
|
||||
|
||||
fun setPlayerLeft(on: Boolean) {
|
||||
playerLeftFlow.value = on
|
||||
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
||||
}
|
||||
|
||||
fun setAutoplay(on: Boolean) {
|
||||
autoplayFlow.value = on
|
||||
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())
|
||||
}
|
||||
|
||||
fun setAdaptiveVideoEnabled(on: Boolean) {
|
||||
adaptiveVideoFlow.value = on
|
||||
AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, on.toString())
|
||||
}
|
||||
|
||||
fun setCarSensorsEnabled(on: Boolean) {
|
||||
carSensorsFlow.value = on
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -63,10 +64,12 @@ fun CarSettingsScreen() {
|
||||
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()
|
||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
|
||||
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||
val display by CarEnvironment.display.collectAsState()
|
||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||
@@ -86,6 +89,8 @@ fun CarSettingsScreen() {
|
||||
ScreenHeader("Settings")
|
||||
}
|
||||
|
||||
item { Group("Screen") }
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Open on connect",
|
||||
@@ -152,6 +157,8 @@ fun CarSettingsScreen() {
|
||||
) { CarSettings.setScreenFit(it) }
|
||||
}
|
||||
|
||||
item { Group("Nav bar and player") }
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Nav bar",
|
||||
@@ -172,6 +179,14 @@ fun CarSettingsScreen() {
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Player beside the map",
|
||||
listOf(false to "Right", true to "Left"),
|
||||
playerLeft,
|
||||
) { CarSettings.setPlayerLeft(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Autoplay on connect",
|
||||
@@ -180,6 +195,8 @@ fun CarSettingsScreen() {
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
item { Group("Sound and voice") }
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Car audio",
|
||||
@@ -198,6 +215,8 @@ fun CarSettingsScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
item { Group("Warnings") }
|
||||
|
||||
if (!pipeAudio) item {
|
||||
ChoiceRow(
|
||||
"Warn when Bluetooth isn't connected",
|
||||
@@ -241,6 +260,8 @@ fun CarSettingsScreen() {
|
||||
) { CarSettings.setSystemSpeech(it) }
|
||||
}
|
||||
|
||||
item { Group("More") }
|
||||
|
||||
item {
|
||||
WebPageSetting()
|
||||
}
|
||||
@@ -258,7 +279,7 @@ fun CarSettingsScreen() {
|
||||
}
|
||||
|
||||
if (experimentalFeatures) item {
|
||||
ExperimentalFeaturesList(carSensors)
|
||||
ExperimentalFeaturesList(carSensors, adaptiveVideo)
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -291,8 +312,37 @@ fun CarSettingsScreen() {
|
||||
|
||||
@Composable
|
||||
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
|
||||
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)) {
|
||||
options.forEach { (value, label) ->
|
||||
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.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExperimentalFeaturesList(carSensors: Boolean) {
|
||||
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
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.",
|
||||
checked = carSensors,
|
||||
) { 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 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
|
||||
@@ -117,7 +118,7 @@ fun CarUi() {
|
||||
AppTheme.LIGHT -> false
|
||||
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 insets by CarEnvironment.insets.collectAsState()
|
||||
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
|
||||
@@ -316,7 +317,8 @@ fun CarUi() {
|
||||
VoiceHelpOverlay(Modifier.align(Alignment.Center))
|
||||
CallOverlay()
|
||||
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
|
||||
// something in the top-right (settings, search, a Liked Songs
|
||||
// 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,
|
||||
@@ -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. */
|
||||
private object NoHaptics : HapticFeedback {
|
||||
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.
|
||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { CarServices.mediaScreenClosed() } }
|
||||
|
||||
val art = rememberArt(now)
|
||||
|
||||
@@ -444,7 +445,7 @@ private sealed interface LyricsState {
|
||||
}
|
||||
|
||||
@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(
|
||||
Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
@@ -491,7 +492,14 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||
shadowElevation = 6.dp,
|
||||
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,
|
||||
)
|
||||
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())
|
||||
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
|
||||
@@ -555,147 +569,3 @@ private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier)
|
||||
|
||||
// --- 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
|
||||
fun PhoneDialerScreen() {
|
||||
val context = LocalContext.current
|
||||
val hasPermissions = hasCallPermissions(context)
|
||||
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
|
||||
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
@@ -148,7 +148,7 @@ fun PhoneDialerScreen() {
|
||||
}
|
||||
|
||||
if (!hasPermissions) {
|
||||
MissingPermission(context)
|
||||
MissingPermission(context) { hasPermissions = true }
|
||||
return@Column
|
||||
}
|
||||
|
||||
@@ -159,19 +159,23 @@ fun PhoneDialerScreen() {
|
||||
}
|
||||
|
||||
@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)) {
|
||||
Text(
|
||||
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " +
|
||||
"from the app's settings.",
|
||||
"Gearslip needs the Phone and Contacts permissions to dial from here. Allow them on " +
|
||||
"the phone when you're parked.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(tone = GsTone.Tonal, onClick = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
}) { Text("Open app settings") }
|
||||
app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS)
|
||||
}) { Text("Ask on the phone") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,13 +103,13 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect?
|
||||
is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
|
||||
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
|
||||
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)
|
||||
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. */
|
||||
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
|
||||
@@ -129,15 +129,17 @@ internal class ChromeCover {
|
||||
}
|
||||
|
||||
/** 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 top = 0f
|
||||
var right = width.toFloat()
|
||||
var bottom = height.toFloat()
|
||||
pieces.values.forEach { (edge, b) ->
|
||||
if (edge == skip) return@forEach
|
||||
when (edge) {
|
||||
CoverEdge.TOP -> top = maxOf(top, b.bottom - 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)
|
||||
}
|
||||
}
|
||||
@@ -171,7 +173,8 @@ internal fun Modifier.covers(key: String, edge: CoverEdge): Modifier {
|
||||
|
||||
@Composable
|
||||
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.
|
||||
val routing = (template.navigationInfo as? RoutingInfo)?.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
|
||||
* map keeps its full width. Neither grows past [NAV_BAR_MAX_WIDTH]. The bottom one is short and
|
||||
* left out of the visible area on purpose, so a speedometer in the app's bottom corner sits level
|
||||
* with it rather than stacked above it. The map buttons sit between them on the right.
|
||||
* Cards down the left, map on the right: the turn on top, the trip along the bottom, the map
|
||||
* buttons at the right edge. The whole column counts as covered, so the app centres its map in
|
||||
* what is left over and the car sits right of the cards rather than under them. Pan mode drops
|
||||
* the cards and gives the map the whole width.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavigationBars(
|
||||
@@ -191,85 +194,104 @@ private fun NavigationBars(
|
||||
estimate: TravelEstimate?,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
Column(modifier.fillMaxSize()) {
|
||||
routing?.let {
|
||||
RoutingBar(
|
||||
it,
|
||||
Modifier.covers("nav-top", CoverEdge.TOP).padding(start = 12.dp, top = 12.dp, end = 12.dp)
|
||||
.widthIn(max = NAV_BAR_MAX_WIDTH).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
estimate?.let {
|
||||
TravelEstimateBar(
|
||||
it,
|
||||
Modifier.align(Alignment.BottomStart).padding(12.dp)
|
||||
.widthIn(max = NAV_BAR_MAX_WIDTH),
|
||||
)
|
||||
val connection = LocalCarAppConnection.current
|
||||
val pan by connection.panMode.collectAsState()
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val column = cardColumnWidth(maxWidth)
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (routing != null || estimate != null) {
|
||||
Column(
|
||||
Modifier.width(column).fillMaxHeight().covers("nav-cards", CoverEdge.START).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
RightEdgeControls(
|
||||
if (pan) null else template.actionStrip,
|
||||
template.mapActionStrip,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
private fun RoutingBar(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
if (info.isLoading) {
|
||||
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
|
||||
}
|
||||
|
||||
val step = info.currentStep ?: return
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
ChromeSurface(modifier) {
|
||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(step.maneuver?.icon, Modifier.size(40.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(distance, style = ChromeType.headline, maxLines = 1)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(step.cue.text(), style = ChromeType.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Column {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
|
||||
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()
|
||||
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(
|
||||
road,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val next = info.nextStep
|
||||
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
|
||||
LaneGuidance(step, Modifier.padding(start = 12.dp).widthIn(max = 200.dp).heightIn(max = 48.dp))
|
||||
} else if (next != null) {
|
||||
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))
|
||||
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
|
||||
LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
|
||||
}
|
||||
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
|
||||
info.junctionImage?.let { JunctionImage(it) }
|
||||
}
|
||||
}
|
||||
// Rare, and only where an arrow can't explain the junction: worth the bar growing for.
|
||||
info.junctionImage?.let { junction ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
JunctionImage(junction)
|
||||
info.nextStep?.let { next ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
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,
|
||||
* where a thumb reaches - the app's own way to stop navigating. One short line along the bottom,
|
||||
* stopping short of the map buttons. The close button is Gearslip's own, not the app's: an
|
||||
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
|
||||
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
|
||||
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
|
||||
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the
|
||||
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
|
||||
* app's: an actionStrip's actions carry no flag saying which one (if any) means "stop
|
||||
* navigating" - the first button turned out to be a mute toggle, not an exit - so this ends
|
||||
* navigation the way going Home already does rather than guess at the app's own controls.
|
||||
*/
|
||||
@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 distance = estimate.remainingDistance.display()
|
||||
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
||||
val trip = estimate.tripText.text()
|
||||
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||
|
||||
val detail = listOfNotNull(
|
||||
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) {
|
||||
ChromeSurface(modifier) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
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,
|
||||
) {
|
||||
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.
|
||||
// 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
|
||||
// freshly picked destination silently does nothing); this is the call that actually
|
||||
// clears that flag on the app's side.
|
||||
// pops the on-screen card, leaving the app still believing it is navigating.
|
||||
GsIconButton(
|
||||
Icons.Filled.Close,
|
||||
"Stop navigating",
|
||||
{ CarServices.nav.stopNavigating() },
|
||||
{ connection.stopNavigating() },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
size = 40.dp,
|
||||
iconSize = 20.dp,
|
||||
size = 52.dp,
|
||||
iconSize = 26.dp,
|
||||
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,
|
||||
pane: @Composable () -> Unit,
|
||||
) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
// 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()
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
if (!pan) {
|
||||
ChromeSurface(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.covers("map-pane", CoverEdge.START)
|
||||
.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) },
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.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. */
|
||||
private fun Template?.needsFullHeight() =
|
||||
|
||||
@@ -517,7 +517,8 @@ private val sectionCache = android.util.LruCache<String, List<Item>>(64)
|
||||
*/
|
||||
@Composable
|
||||
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) {
|
||||
// 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
|
||||
|
||||
@@ -106,7 +106,8 @@ internal fun ChromeSurface(
|
||||
) {
|
||||
Surface(
|
||||
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,
|
||||
shape = shape,
|
||||
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
|
||||
* 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
|
||||
internal fun RightEdgeControls(
|
||||
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. */
|
||||
mapActionsModifier: Modifier = Modifier,
|
||||
) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
if (actions.isEmpty() && mapActions.isEmpty()) return
|
||||
// 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 }
|
||||
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
|
||||
// the first touch on one wakes them rather than pressing it.
|
||||
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.
|
||||
var holdsFocus by remember { mutableStateOf(false) }
|
||||
val visible = awake || pan || holdsFocus
|
||||
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),
|
||||
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. */
|
||||
private fun CarRow.isBlank(): Boolean =
|
||||
internal fun CarRow.isBlank(): Boolean =
|
||||
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
|
||||
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
|
||||
|
||||
@@ -298,13 +303,14 @@ private fun CarRow.isBlank(): Boolean =
|
||||
*/
|
||||
@Composable
|
||||
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
if (action.type == Action.TYPE_APP_ICON) {
|
||||
AppIconBadge()
|
||||
return
|
||||
}
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
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 scheme = MaterialTheme.colorScheme
|
||||
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(
|
||||
onClick = {
|
||||
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
|
||||
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
|
||||
if (action.type == Action.TYPE_BACK) connection.backPressed()
|
||||
else if (action.type == Action.TYPE_PAN) connection.setPanMode(!panOn)
|
||||
else action.onClickDelegate?.click("action")
|
||||
},
|
||||
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. */
|
||||
@Composable
|
||||
private fun AppIconBadge() {
|
||||
val connection = LocalCarAppConnection.current
|
||||
val context = LocalContext.current
|
||||
val pkg = CarServices.nav.appPackage
|
||||
val pkg = connection.appPackage
|
||||
val icon = remember(pkg) {
|
||||
pkg?.let {
|
||||
runCatching {
|
||||
@@ -507,315 +514,6 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
|
||||
|
||||
// --- 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 =
|
||||
(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 app.seb3thehacker.gearslip.GearslipLog
|
||||
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.
|
||||
@@ -56,6 +60,11 @@ fun WebApp(content: String) {
|
||||
// the video's then leaves full screen too, instead of stranding them in it.
|
||||
var videoMadeFullscreen by remember { mutableStateOf(false) }
|
||||
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.
|
||||
DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } }
|
||||
@@ -86,7 +95,18 @@ fun WebApp(content: String) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -99,8 +119,15 @@ fun WebApp(content: String) {
|
||||
settings.domStorageEnabled = true
|
||||
settings.loadWithOverviewMode = 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() {
|
||||
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) {
|
||||
videoMadeFullscreen = !CarFullscreen.active
|
||||
pageFullscreen = view to callback
|
||||
@@ -117,11 +144,7 @@ fun WebApp(content: String) {
|
||||
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||
"GearslipField",
|
||||
)
|
||||
if (content.startsWith("http://") || content.startsWith("https://")) {
|
||||
loadUrl(content)
|
||||
} else {
|
||||
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||
}
|
||||
loadContent(this, content)
|
||||
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 ->
|
||||
CarKeyboard(
|
||||
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?) {
|
||||
GearslipLog.i("car web page loaded: $url")
|
||||
view?.let(onLoaded)
|
||||
view?.evaluateJavascript(FOCUS_TRACKING_JS, 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
|
||||
|
||||
/** 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. */
|
||||
val kind: String
|
||||
get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) }
|
||||
|
||||
@@ -403,6 +403,8 @@ class CarAppConnection(private val context: Context) {
|
||||
// failure worth surfacing.
|
||||
runCatching {
|
||||
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?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
navigationManager = when (value) {
|
||||
@@ -626,6 +628,7 @@ class CarAppConnection(private val context: Context) {
|
||||
// --- host interfaces the app calls back into -----------------------------------------
|
||||
|
||||
private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = IAppHost.VERSION
|
||||
override fun invalidate() {
|
||||
GearslipLog.i("host: app invalidated its template")
|
||||
main.post { requestTemplate() }
|
||||
@@ -664,6 +667,7 @@ class CarAppConnection(private val context: Context) {
|
||||
val trip: StateFlow<androidx.car.app.navigation.model.Trip?> = _trip.asStateFlow()
|
||||
|
||||
private val navigationHost = object : INavigationHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = INavigationHost.VERSION
|
||||
override fun navigationStarted() {
|
||||
GearslipLog.i("host: navigation started")
|
||||
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.
|
||||
*/
|
||||
private val hardwareHost = object : ICarHardwareHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION
|
||||
override fun getCarHardwareResult(
|
||||
type: Int,
|
||||
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.
|
||||
*/
|
||||
private val constraintHost = object : IConstraintHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = IConstraintHost.VERSION
|
||||
override fun getContentLimit(type: Int): Int = when (type) {
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12
|
||||
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.
|
||||
*/
|
||||
private val suggestionHost = object : ISuggestionHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = ISuggestionHost.VERSION
|
||||
override fun updateSuggestions(suggestions: Bundleable?) = Unit
|
||||
}
|
||||
|
||||
private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = ICarHost.VERSION
|
||||
private val appHost = createAppHost(owner)
|
||||
|
||||
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) {
|
||||
val callback = object : IOnDoneCallback.Stub() {
|
||||
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
|
||||
override fun onSuccess(response: Bundleable?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
main.post { onValue(value) }
|
||||
@@ -790,6 +799,7 @@ class CarAppConnection(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun noop(name: String) = object : IOnDoneCallback.Stub() {
|
||||
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
|
||||
override fun onSuccess(response: Bundleable?) = Unit
|
||||
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. */
|
||||
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
|
||||
@@ -53,7 +87,7 @@ private val iconCache = android.util.LruCache<String, ImageBitmap>(200)
|
||||
fun CarIcon?.image(context: Context): ImageBitmap? {
|
||||
val icon = this?.icon ?: return null
|
||||
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) }
|
||||
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
||||
.getOrNull()
|
||||
@@ -73,7 +107,7 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
|
||||
val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() }
|
||||
.onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
||||
.getOrNull() ?: return null
|
||||
iconCache.put(key, bitmap)
|
||||
key?.let { iconCache.put(it, 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
|
||||
* 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()
|
||||
val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L)
|
||||
return "${describe()}@$installed"
|
||||
|
||||
@@ -24,6 +24,7 @@ object KnownApps {
|
||||
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
|
||||
"com.mapquest.android.ace", // MapQuest: map, routing, 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
|
||||
@@ -99,7 +100,6 @@ object KnownApps {
|
||||
* call working. Wins over [works] and [playerWorks] for the badge.
|
||||
*/
|
||||
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
|
||||
"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.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. */
|
||||
@Composable
|
||||
@@ -47,8 +47,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
|
||||
}
|
||||
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
|
||||
// Where launch goes once the intro (if any) is done.
|
||||
val afterIntro = remember {
|
||||
// Where launch goes: setup until it's done, then Home.
|
||||
val start = remember {
|
||||
when {
|
||||
needsCertSetup -> Screen.CERT_SETUP
|
||||
needsSetupGuide -> Screen.SETUP_GUIDE
|
||||
@@ -57,14 +57,14 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
else -> Screen.HOME
|
||||
}
|
||||
}
|
||||
var screen by rememberSaveable { mutableStateOf(if (Intro.due(context)) Screen.INTRO else afterIntro) }
|
||||
// A replay from About goes back to Settings; the launch one carries on into setup.
|
||||
var introReturn by rememberSaveable { mutableStateOf(afterIntro) }
|
||||
var screen by rememberSaveable { mutableStateOf(start) }
|
||||
// Home's "it worked" card opens Settings at usage notes rather than at the top.
|
||||
var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
|
||||
// What's new and Connection help open from Home and from Settings; back returns to whichever.
|
||||
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.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS
|
||||
Screen.HELP, Screen.WHATS_NEW -> backTo
|
||||
@@ -101,11 +101,10 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
.windowInsetsPadding(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)) {
|
||||
when (screen) {
|
||||
Screen.INTRO -> IntroScreen(onDone = { screen = introReturn })
|
||||
Screen.HOME -> HomeScreen(
|
||||
onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS },
|
||||
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 },
|
||||
onOpenLogs = { backTo = Screen.HOME; screen = Screen.LOGS },
|
||||
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.LOGS -> LogsScreen(onBack = { screen = backTo })
|
||||
Screen.SETTINGS -> SettingsScreen(
|
||||
|
||||
@@ -6,11 +6,16 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
fun GearslipTheme(content: @Composable () -> Unit) {
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
onOpenLogs: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onOpenCarGroup: (CarGroup) -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
@@ -125,6 +126,9 @@ fun HomeScreen(
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
UpdateCard()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
HomeCarSettings(onOpenCarGroup)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// 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(
|
||||
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. */
|
||||
@@ -135,11 +138,13 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
|
||||
if (icon != null) {
|
||||
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
|
||||
} 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)) {
|
||||
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)
|
||||
if (icon != null) {
|
||||
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",
|
||||
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(
|
||||
"USB",
|
||||
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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
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
|
||||
@@ -100,6 +103,9 @@ import androidx.compose.runtime.DisposableEffect
|
||||
* settings screen) without by itself finishing the page. Everything is skippable: none of this
|
||||
* 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(
|
||||
val title: String,
|
||||
/** 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 actionLabel: String? = null,
|
||||
val onAction: (() -> Unit)? = null,
|
||||
/** Drawn under [explanation]. */
|
||||
val content: (@Composable () -> Unit)? = null,
|
||||
/** Drawn under [explanation], sized off the same screen-width scale as the page. */
|
||||
val content: (@Composable (GuideScale) -> Unit)? = null,
|
||||
/** Only shown when the driver picked USB audio on the car audio page. */
|
||||
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 -----------------------
|
||||
if (SHOW_RECOMMENDED_APPS) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Good company.",
|
||||
explanation = RECOMMENDED_INTRO,
|
||||
icon = { StarIcon(it) },
|
||||
content = { RecommendedAppsList() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- The very end, every time: usage notes. On a replay, the switch shows what was
|
||||
// chosen before.
|
||||
// --- At the end, every time: apps worth installing, how the car's buttons look, and last
|
||||
// the usage notes. On a replay, the switch shows what was chosen before.
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Good company.",
|
||||
explanation = RECOMMENDED_INTRO,
|
||||
icon = { StarIcon(it) },
|
||||
content = { RecommendedAppsList() },
|
||||
),
|
||||
)
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Your buttons.",
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -417,7 +427,7 @@ private fun AudioChoice() {
|
||||
) { CarSettings.setPipeAudio(false) }
|
||||
AudioCard(
|
||||
"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,
|
||||
) { 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.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
key(index) {
|
||||
val scroll = rememberScrollState()
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.verticalScroll(scroll)
|
||||
.heightIn(min = maxHeight)
|
||||
.padding(vertical = cq(6f)),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
@@ -550,7 +561,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
|
||||
)
|
||||
page.command?.let { CopyableCommand(it) }
|
||||
page.content?.invoke()
|
||||
page.content?.invoke(GuideScale(c, ::cq, ::cqSp))
|
||||
if (page.permissions != null && alreadyGranted) {
|
||||
Text(
|
||||
"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 }
|
||||
|
||||
/** 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
|
||||
/** 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
|
||||
/** 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. */
|
||||
@Volatile private var tcpRun = 0
|
||||
private val writeLock = Any()
|
||||
@@ -131,6 +133,7 @@ object WirelessLink {
|
||||
runCatching { context.getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(cb) }
|
||||
}
|
||||
network = null
|
||||
activeSsid = null
|
||||
// Stopped before the car's session began, so nothing else resets Home's card.
|
||||
if (starting) {
|
||||
log.i("wireless start-up stopped")
|
||||
@@ -223,6 +226,14 @@ object WirelessLink {
|
||||
val bssid = text(3)
|
||||
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)"}")
|
||||
// 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 known = target
|
||||
if (known != null) {
|
||||
@@ -332,6 +343,7 @@ object WirelessLink {
|
||||
}
|
||||
|
||||
override fun onUnavailable() {
|
||||
if (activeSsid == ssid) activeSsid = null
|
||||
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)) }
|
||||
SessionStatus.failed(
|
||||
@@ -356,6 +368,7 @@ object WirelessLink {
|
||||
|
||||
override fun onLost(net: Network) {
|
||||
lostNetwork = net
|
||||
if (activeSsid == ssid) activeSsid = null
|
||||
log.w(
|
||||
"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",
|
||||
@@ -364,6 +377,7 @@ object WirelessLink {
|
||||
}
|
||||
network?.let { runCatching { manager.unregisterNetworkCallback(it) } }
|
||||
network = callback
|
||||
activeSsid = ssid
|
||||
manager.requestNetwork(request, callback, WIFI_WAIT_MS)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,4 +9,12 @@
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="windowActionBar">false</item>
|
||||
</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>
|
||||
@@ -8,7 +8,7 @@ espressoCore = "3.7.0"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.05.01"
|
||||
carApp = "1.7.0"
|
||||
carApp = "1.8.0-rc01"
|
||||
appcompat = "1.7.1"
|
||||
material = "1.14.0"
|
||||
constraintlayout = "2.2.1"
|
||||
|
||||