Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6ec5c7a8e | ||
|
|
2f49559600 | ||
|
|
57803988d4 | ||
|
|
bcde51ad56 | ||
|
|
cc522bb0b7 | ||
|
|
e4c2a72fad | ||
|
|
0627a32df7 | ||
|
|
5df2dace77 | ||
|
|
cb30a3de69 | ||
|
|
4aac815b2e | ||
|
|
d915b0ce45 | ||
|
|
746b0b7325 | ||
|
|
de370751a0 | ||
|
|
e1948a823f | ||
|
|
738b99ff9e | ||
|
|
7959b27d38 | ||
|
|
83c33eeea3 | ||
|
|
b168eb7c04 | ||
|
|
f4b8391fa3 | ||
|
|
d1f67bb64d | ||
|
|
7d02ac8b75 | ||
|
|
6ff59be6ab | ||
|
|
dbeaa9cf5e | ||
|
|
7bfb8c8669 | ||
|
|
3fbabfa0ed | ||
|
|
0fd7568536 | ||
|
|
02618dbcb9 | ||
|
|
dc9514dcfa | ||
|
|
74f557524f | ||
|
|
324112ca8b | ||
|
|
15d6721705 | ||
|
|
1f9ef552c1 | ||
|
|
608d72b856 | ||
|
|
08eef32e59 | ||
|
|
894712c38d | ||
|
|
ef1b2e1be7 | ||
|
|
93a1ff7485 | ||
|
|
8a2dae4173 | ||
|
|
a1a0546b74 | ||
|
|
c616f0203a | ||
|
|
08ac6fe117 | ||
|
|
46c2e92684 | ||
|
|
b2a95adb87 | ||
|
|
9d629c392b | ||
|
|
0eea09542f | ||
|
|
5c72a18f06 | ||
|
|
5cc1f518c7 | ||
|
|
3f393fd56f | ||
|
|
703d8d69e8 | ||
|
|
89e5bbd2bb | ||
|
|
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,24 @@
|
||||
Gearslip 0.3.02 connects wirelessly by itself, and phone calls work much better.
|
||||
Gearslip 0.3.31 brings saved places, a new music player and sixteen languages.
|
||||
|
||||
## 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.
|
||||
- Save Home, Work and three places of your own under Saved places in the Car card on the phone's home screen. Say "navigate home" or "take me to work", and your map app gets the address.
|
||||
- Calls show on the car's own screens, and the car's phone button answers them.
|
||||
- A new music player: Now playing with a big cover, Browse with covers, and Up next. Beside the map, the player floats as a card; drag its edge to resize it.
|
||||
- Lyrics come from BetterLyrics, LRCLIB or KuGou, whichever has the song.
|
||||
- A Dark mode setting: black, black and colour, deep colour or grey. Black is the default.
|
||||
- Gearslip speaks Arabic, Chinese, Czech, Dutch, French, German, Italian, Japanese, Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish and Ukrainian. Most started as machine translations, so fix your language on [Weblate](https://hosted.weblate.org/engage/gearslip/).
|
||||
- With experimental features on, car apps get the car's speed, fuel, range and compass, and cars with an instrument cluster screen get a turn card there.
|
||||
- A Manage storage screen in App info clears lyrics, voice models and old logs.
|
||||
|
||||
## 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.
|
||||
- The nav bar floats on every screen.
|
||||
- The car's settings all sit under the Car card on the phone's home screen.
|
||||
- The Light app theme makes the phone app light too.
|
||||
- With the player beside the map, the map app's own buttons fold behind one button, so everything fits.
|
||||
- The USB audio choice is now called Through Gearslip.
|
||||
- Setup and Settings explain that some newer cars reject the built-in certificate and need your own.
|
||||
|
||||
## 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.
|
||||
- Older head units that take only AAC audio no longer drop the connection when music starts.
|
||||
- Long screens such as Settings, Messages and Vehicle data scroll all the way down.
|
||||
- Settings opens faster.
|
||||
@@ -88,6 +88,9 @@ We are not required to use or include any Contribution.
|
||||
Most contributors sign on GitHub: the CLA Assistant bot comments on your first pull request,
|
||||
and you sign there with one click. Your GitHub account then serves as your signature.
|
||||
|
||||
Translators sign on Weblate: it shows this agreement before your first translation, and
|
||||
accepting it there serves as your signature.
|
||||
|
||||
To sign by hand instead, fill in the following and send it to us on Matrix. The address is
|
||||
on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
|
||||
|
||||
|
||||
@@ -38,6 +38,19 @@ branch on `experimental`, and open your pull request against it.
|
||||
- Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android
|
||||
permissions and on-device consent dialogs.
|
||||
|
||||
## Translating
|
||||
|
||||
Translations happen on [Weblate](https://hosted.weblate.org/engage/gearslip/), not in pull
|
||||
requests. Pick your language, or start a new one, and translate in the browser. Weblate
|
||||
sends the finished work to this repository as a pull request.
|
||||
|
||||
Keep the tone of the English: short, plain, and friendly. Leave placeholders such as `%1$s`
|
||||
and `%1$d` in place; Gearslip fills them with names and numbers. Untranslated strings show
|
||||
in English, so a partial translation is still useful.
|
||||
|
||||
Before your first translation, Weblate asks you to accept the
|
||||
[Contributor License Agreement](CLA.md). That counts as signing it.
|
||||
|
||||
## Writing
|
||||
|
||||
Text that users read, such as screens, release notes, and docs, follows Strunk's
|
||||
|
||||
@@ -9,13 +9,20 @@ An Android Auto alternative built for everyone.
|
||||
|
||||
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/twinksoftware/gearslip)
|
||||
|
||||
[](https://hosted.weblate.org/engage/gearslip/)
|
||||
|
||||
Gearslip speaks English, Arabic, Chinese, Czech, Dutch, French, German, Italian, Japanese,
|
||||
Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish, and Ukrainian. Most of these
|
||||
started as machine translations, so some read awkwardly.
|
||||
[Fix or add your language on Weblate](https://hosted.weblate.org/engage/gearslip/).
|
||||
|
||||
Questions, ideas?
|
||||
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
||||
|
||||
## Which cars work
|
||||
|
||||
Most cars work. Starting around 2020, some cars added newer security standards that reject
|
||||
Gearslip's certificate, and Gearslip can't run in those cars. [CARS.md](CARS.md) lists the
|
||||
Most cars work. Starting around 2020, some cars added newer security checks that reject
|
||||
the aasdk certificate, and in those cars Gearslip needs a certificate of your own. [CARS.md](CARS.md) lists the
|
||||
cars drivers have confirmed.
|
||||
|
||||
## What Gearslip is
|
||||
@@ -31,25 +38,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,33 +72,40 @@ 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
|
||||
the only way an ordinary app can pass another app's audio along.
|
||||
- **Music reaches the car over Bluetooth or through Gearslip.** Setup asks which.
|
||||
Bluetooth is the default: Android never asks for permission, and your map app's voice
|
||||
reaches the car too. Through Gearslip, the sound travels over the car connection, cable
|
||||
or wireless. It 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
|
||||
Android permission or an on-device consent dialog. For audio through Gearslip, setup offers two
|
||||
optional adb commands: one removes the audio dialog for good, and the other keeps
|
||||
notifications visible while audio plays.
|
||||
- **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest
|
||||
@@ -119,7 +135,7 @@ turn them on, in setup or in Settings.
|
||||
those boxes cover: the Android version and whether the phone runs GrapheneOS, the phone
|
||||
model, whether a car connected that day, and each car's head unit, model, year, screen
|
||||
size, and connection result, with whether that drive used the cable or wireless and
|
||||
whether music went over USB or Bluetooth. If you tick it, how each drive went: how far
|
||||
whether music went through Gearslip or over Bluetooth. If you tick it, how each drive went: how far
|
||||
the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and
|
||||
the server drops the copy it kept.
|
||||
- **What they leave out.** Your name, accounts, location, contacts, messages, and
|
||||
@@ -146,6 +162,9 @@ Bug reports from real cars help most, and pull requests are welcome.
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) explains what to put in a report, which branch to work
|
||||
on, and the one-time agreement you sign before your first pull request merges.
|
||||
|
||||
To help translate Gearslip into your language, join
|
||||
[Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
|
||||
|
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 = 13
|
||||
// -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.31-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.
|
||||
@@ -74,6 +74,9 @@ android {
|
||||
applicationIdSuffix = ".dev"
|
||||
signingConfigs.findByName("release")?.let { signingConfig = it }
|
||||
manifestPlaceholders["appLabel"] = "Gearslip Debug"
|
||||
// English (XA) and Arabic (XB) under the phone's languages: stretched, accented and
|
||||
// right-to-left text, which shows what's still hardcoded and what doesn't fit.
|
||||
isPseudoLocalesEnabled = true
|
||||
// Shrunk with R8 like a release, so a debug install behaves like what ships (a keep
|
||||
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
|
||||
if (!project.hasProperty("noR8")) {
|
||||
@@ -88,6 +91,18 @@ android {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Lists each language that has a translation in the app's locale config, so Android 13+
|
||||
// offers it under the system's per-app languages. English is the unqualified default
|
||||
// (res/resources.properties).
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
lint {
|
||||
// Weblate fills languages in a string at a time; the rest fall back to English.
|
||||
disable += "MissingTranslation"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
@@ -106,9 +121,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 +134,6 @@ tasks.register("renameApk") {
|
||||
.forEach { apk ->
|
||||
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
|
||||
apk.copyTo(renamed, overwrite = true)
|
||||
apk.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="${appLabel}"
|
||||
android:supportsRtl="true"
|
||||
android:manageSpaceActivity="app.seb3thehacker.gearslip.ui.ManageSpaceActivity"
|
||||
android:theme="@style/Theme.Gearslip">
|
||||
|
||||
<provider
|
||||
@@ -162,6 +163,18 @@
|
||||
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.
|
||||
-->
|
||||
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
|
||||
<activity
|
||||
android:name=".ui.ManageSpaceActivity"
|
||||
android:exported="true"
|
||||
android:label="Gearslip storage" />
|
||||
|
||||
<activity
|
||||
android:name=".PermissionActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@style/Theme.Gearslip.Invisible" />
|
||||
|
||||
<activity-alias
|
||||
android:name=".GearslipUsbAccessory"
|
||||
android:targetActivity=".GearslipActivity"
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.seb3thehacker.gearslip.car.ClusterScreen
|
||||
|
||||
/**
|
||||
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
|
||||
* offers one, showing the turn card ([ClusterScreen]). The same exchange as the main screen's,
|
||||
* on its own channel, with its own encoder and its own private display:
|
||||
*
|
||||
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
||||
* -> Setup { H.264 } <- Config { status, max_unacked, configuration_indices }
|
||||
* -> VideoFocusRequest <- VideoFocusNotification { projected }
|
||||
* -> Start, CodecConfig, Data... <- Ack
|
||||
*
|
||||
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled], and
|
||||
* untested in a car: none in the notes has a cluster sink.
|
||||
*/
|
||||
internal class ClusterVideo(
|
||||
private val context: Context,
|
||||
private val service: ServiceDiscovery.VideoService,
|
||||
private val send: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
private val log = GearslipLog.tagged("CLUSTER")
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
private var configIndex = 0
|
||||
@Volatile private var started = false
|
||||
@Volatile private var running = true
|
||||
@Volatile private var source: VideoSource? = null
|
||||
private var projector: ScreenProjector? = null
|
||||
|
||||
private val ackLock = Object()
|
||||
@Volatile private var inFlight = 0
|
||||
@Volatile private var maxUnacked = 1
|
||||
@Volatile private var lastAckAt = 0L
|
||||
private var frames = 0
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
GearslipRunner.MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
log.i("<- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) return
|
||||
send(GearslipRunner.MSG_MEDIA_SETUP, Protobuf.varintField(1, GearslipRunner.CODEC_H264_BP.toLong()))
|
||||
log.i("-> Setup(codec=VIDEO_H264_BP)")
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_MEDIA_CONFIG -> {
|
||||
val fields = Wire.fields(body)
|
||||
Wire.varint(fields, 2)?.toInt()?.let { maxUnacked = maxOf(1, it) }
|
||||
val offered = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
|
||||
// The cluster gets the smallest picture it offers: a turn card needs no more.
|
||||
configIndex = offered.minByOrNull { service.configs.getOrNull(it)?.pixelCount() ?: Int.MAX_VALUE } ?: 0
|
||||
log.i("<- Config status=${Wire.varint(fields, 1)} max_unacked=$maxUnacked offered=$offered - using $configIndex")
|
||||
val focus = Protobuf.varintField(2, GearslipRunner.VIDEO_FOCUS_PROJECTED.toLong()) + Protobuf.varintField(3, 1L)
|
||||
send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, focus)
|
||||
log.i("-> VideoFocusRequest(mode=PROJECTED)")
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
|
||||
log.i("<- VideoFocusNotification: mode=$mode")
|
||||
val projected = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||
if (projected && !started) startStream()
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_MEDIA_ACK -> {
|
||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||
synchronized(ackLock) {
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startStream() {
|
||||
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
|
||||
val (width, height) = config.pixelSize() ?: run {
|
||||
log.w("unsupported cluster resolution ${config.resolutionName}")
|
||||
return
|
||||
}
|
||||
started = true
|
||||
send(
|
||||
GearslipRunner.MSG_MEDIA_START,
|
||||
Protobuf.varintField(1, GearslipRunner.SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||
)
|
||||
log.i("-> Start(config=$configIndex -> $config)")
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
val video = VideoSource(
|
||||
width = width,
|
||||
height = height,
|
||||
frameRate = FRAME_RATE,
|
||||
mode = VideoSource.Mode.SURFACE,
|
||||
wireless = true, // a small share of the link: the main screen comes first
|
||||
onCodecConfig = { csd ->
|
||||
send(GearslipRunner.MSG_MEDIA_CODEC_CONFIG, csd)
|
||||
log.i("-> CodecConfig (${csd.size} bytes)")
|
||||
},
|
||||
onFrame = { data, pts, _ -> sendFrame(data, pts) },
|
||||
)
|
||||
source = video
|
||||
runCatching { video.start() }.onFailure {
|
||||
log.e("cluster encoder failed to start", it)
|
||||
return
|
||||
}
|
||||
val surface = video.inputSurface ?: return
|
||||
val density = config.density.takeIf { it > 0 } ?: 160
|
||||
main.post {
|
||||
if (!running) return@post
|
||||
projector = ScreenProjector(context).also {
|
||||
it.start(surface, width, height, density, name = "gearslip-cluster") {
|
||||
// The margins the car crops off are kept clear, as on the main screen.
|
||||
ClusterScreen(config.widthMargin, config.heightMargin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** As on the main screen: wait for the car's acks rather than drop a frame. */
|
||||
private fun sendFrame(data: ByteArray, presentationTimeUs: Long) {
|
||||
synchronized(ackLock) {
|
||||
while (running && inFlight >= maxUnacked) {
|
||||
val since = System.currentTimeMillis() - lastAckAt
|
||||
if (since > GearslipRunner.ACK_STALL_MS) {
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
break
|
||||
}
|
||||
ackLock.wait(GearslipRunner.ACK_STALL_MS - since + 1)
|
||||
}
|
||||
}
|
||||
if (!running) return
|
||||
val timestamp = ByteArray(8)
|
||||
for (i in 0 until 8) timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
runCatching { send(GearslipRunner.MSG_MEDIA_DATA, timestamp + data) }.onFailure { running = false }
|
||||
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
synchronized(ackLock) { ackLock.notifyAll() }
|
||||
main.post {
|
||||
projector?.stop()
|
||||
projector = null
|
||||
}
|
||||
source?.stop()
|
||||
source = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// A turn card changes a few times a minute; the encoder repeats the last frame between.
|
||||
const val FRAME_RATE = 15
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -322,6 +320,8 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
// Debug builds only: opens the audio choice for a look, without turning down a real prompt.
|
||||
if (BuildConfig.DEBUG && intent?.getBooleanExtra("show_audio_choice", false) == true) offerBluetooth.value = true
|
||||
if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
|
||||
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
|
||||
return
|
||||
|
||||
@@ -96,6 +96,8 @@ class GearslipRunner(
|
||||
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
||||
@Volatile private var sensorChannelId: Int = -1
|
||||
@Volatile private var mediaStatus: CarMediaStatus? = null
|
||||
@Volatile private var phoneStatus: app.seb3thehacker.gearslip.call.CarPhoneStatus? = null
|
||||
@Volatile private var clusterVideo: ClusterVideo? = null
|
||||
@Volatile private var clusterNav: ClusterNav? = null
|
||||
private var sensorTypes: List<Int> = emptyList()
|
||||
@Volatile private var audioLink: AudioLink? = null
|
||||
@@ -184,12 +186,17 @@ class GearslipRunner(
|
||||
guidanceLink?.close()
|
||||
guidanceLink = null
|
||||
CarSensors.clear()
|
||||
app.seb3thehacker.gearslip.car.CarData.clear()
|
||||
CarMic.current = null
|
||||
CarBluetooth.onCarDisconnected()
|
||||
BluetoothAudioCheck.onCarDisconnected()
|
||||
LocationCheck.onCarDisconnected()
|
||||
mediaStatus?.stop()
|
||||
mediaStatus = null
|
||||
phoneStatus?.stop()
|
||||
phoneStatus = null
|
||||
clusterVideo?.stop()
|
||||
clusterVideo = null
|
||||
clusterNav?.stop()
|
||||
clusterNav = null
|
||||
lastSensorNight = null
|
||||
@@ -237,6 +244,10 @@ class GearslipRunner(
|
||||
clusterNav?.onMessage(messageId, body)
|
||||
} else if (frame.channel == mediaStatus?.channelId) {
|
||||
mediaStatus?.onMessage(messageId, body)
|
||||
} else if (frame.channel == phoneStatus?.channelId) {
|
||||
phoneStatus?.onMessage(messageId, body)
|
||||
} else if (frame.channel == clusterVideo?.channelId) {
|
||||
clusterVideo?.onMessage(messageId, body)
|
||||
} else if (frame.channel == guidanceLink?.channelId) {
|
||||
guidanceLink?.onMessage(messageId, body)
|
||||
} else if (frame.channel == CarBluetooth.channelId) {
|
||||
@@ -462,6 +473,7 @@ class GearslipRunner(
|
||||
log.i("head unit: $info")
|
||||
SessionReport.headUnit(info.toString())
|
||||
SessionReport.headUnitInfo = info
|
||||
app.seb3thehacker.gearslip.car.CarData.onModel(info.carModel, info.carYear)
|
||||
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
||||
val profile = vehicleProfile
|
||||
SessionReport.profile(profile?.name)
|
||||
@@ -512,6 +524,8 @@ class GearslipRunner(
|
||||
startSensorChannel(serviceDiscoveryResponse)
|
||||
startMicChannel(serviceDiscoveryResponse)
|
||||
startMediaStatusChannel(serviceDiscoveryResponse)
|
||||
startPhoneStatusChannel(serviceDiscoveryResponse)
|
||||
startClusterVideo(serviceDiscoveryResponse)
|
||||
startClusterNavChannel(serviceDiscoveryResponse)
|
||||
startBluetooth(serviceDiscoveryResponse)
|
||||
startVendorChannel(serviceDiscoveryResponse)
|
||||
@@ -628,6 +642,32 @@ class GearslipRunner(
|
||||
openChannel(channel)
|
||||
}
|
||||
|
||||
/** The turn card on the instrument cluster, for a car with a cluster screen: experimental, off by default. */
|
||||
private fun startClusterVideo(serviceDiscoveryResponse: ByteArray) {
|
||||
val sink = ServiceDiscovery.findClusterVideoService(serviceDiscoveryResponse) ?: return
|
||||
log.i("cluster video sink: channel=${sink.serviceId} display=${sink.displayId} type=${sink.displayType}")
|
||||
if (!CarSettings.experimentalFeaturesEnabled.value || !CarSettings.clusterVideoEnabled.value) {
|
||||
log.i("cluster screen is off in the car's Settings (experimental) - leaving that sink alone")
|
||||
return
|
||||
}
|
||||
val context = app.seb3thehacker.gearslip.car.CarEnvironment.appContext ?: return
|
||||
clusterVideo = ClusterVideo(context, sink) { id, body -> send(id, body, encrypted = true, channel = sink.serviceId) }
|
||||
openChannel(sink.serviceId)
|
||||
}
|
||||
|
||||
/** Calls, for the car's own screens: the cluster, the steering wheel display. */
|
||||
private fun startPhoneStatusChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val channel = ServiceDiscovery.findPhoneStatusChannel(serviceDiscoveryResponse) ?: run {
|
||||
log.i("no phone status channel - the car's own screens won't show calls")
|
||||
return
|
||||
}
|
||||
log.i("phone status channel: $channel")
|
||||
phoneStatus = app.seb3thehacker.gearslip.call.CarPhoneStatus(channel) { id, body ->
|
||||
send(id, body, encrypted = true, channel = channel)
|
||||
}
|
||||
openChannel(channel)
|
||||
}
|
||||
|
||||
/** Bluetooth pairing, for calls: the channel only opens if the driver asks to pair. */
|
||||
/**
|
||||
* GM's own channel ("com.gm.vip"), opened in GM cars only. Issue #18: a 2021 Bolt ends every
|
||||
@@ -766,6 +806,7 @@ class GearslipRunner(
|
||||
|
||||
MSG_SENSOR_EVENT_INDICATION -> {
|
||||
CarSensors.onEvent(body)
|
||||
app.seb3thehacker.gearslip.car.CarData.onEvent(body)
|
||||
logSensorEvent(body)
|
||||
onNightSensor(body)
|
||||
}
|
||||
@@ -1089,11 +1130,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 +1425,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 +1443,66 @@ class GearslipRunner(
|
||||
}.apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
internal 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}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,17 @@ object ServiceDiscovery {
|
||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
||||
}
|
||||
|
||||
/** [displayId] is MediaSinkService field 6: 0, the default, is the main screen. */
|
||||
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>, val displayId: Int = 0) {
|
||||
/**
|
||||
* [displayId] is MediaSinkService field 6: 0, the default, is the main screen. [displayType]
|
||||
* is field 7 (0 main, 1 instrument cluster, 2 auxiliary), or null when the car leaves it out.
|
||||
*/
|
||||
class VideoService(
|
||||
val serviceId: Int,
|
||||
val codecType: Int,
|
||||
val configs: List<VideoConfig>,
|
||||
val displayId: Int = 0,
|
||||
val displayType: Int? = null,
|
||||
) {
|
||||
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
||||
}
|
||||
|
||||
@@ -116,6 +125,12 @@ object ServiceDiscovery {
|
||||
*/
|
||||
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
|
||||
|
||||
/**
|
||||
* The channel the car takes call status on (PhoneStatusService, Service field 10), for its
|
||||
* cluster and phone screens. It carries no settings. Null when the car has none.
|
||||
*/
|
||||
fun findPhoneStatusChannel(response: ByteArray): Int? = findChannelWith(response, 10)
|
||||
|
||||
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
|
||||
|
||||
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
|
||||
@@ -277,6 +292,19 @@ object ServiceDiscovery {
|
||||
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The instrument cluster's video sink: one marked as a cluster, or, from a car that marks
|
||||
* none, a second sink beside the main screen's. Null when the car has no cluster screen.
|
||||
*/
|
||||
fun findClusterVideoService(response: ByteArray): VideoService? {
|
||||
val sinks = videoServices(response)
|
||||
val main = findVideoService(response) ?: return null
|
||||
return sinks.firstOrNull { it.displayType == DISPLAY_TYPE_CLUSTER && it.serviceId != main.serviceId }
|
||||
?: sinks.firstOrNull { it.displayType == null && it.serviceId != main.serviceId && it.displayId != main.displayId }
|
||||
}
|
||||
|
||||
private const val DISPLAY_TYPE_CLUSTER = 1
|
||||
|
||||
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
@@ -298,7 +326,7 @@ object ServiceDiscovery {
|
||||
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0))
|
||||
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0, Wire.varint(sinkFields, 7)?.toInt()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -87,8 +87,17 @@ internal class AacEncoder(private val sampleRate: Int, private val channels: Int
|
||||
runCatching { codec.release() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INPUT_WAIT_US = 10_000L
|
||||
val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
|
||||
companion object {
|
||||
private const val INPUT_WAIT_US = 10_000L
|
||||
private val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
|
||||
|
||||
/** The two-byte AudioSpecificConfig for AAC-LC: object type 2, rate index, channel count. */
|
||||
fun audioSpecificConfig(sampleRate: Int, channels: Int): ByteArray {
|
||||
val freq = FREQUENCIES.indexOf(sampleRate).let { if (it < 0) 3 else it }
|
||||
return byteArrayOf(
|
||||
((2 shl 3) or (freq shr 1)).toByte(),
|
||||
(((freq and 1) shl 7) or (channels shl 3)).toByte(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,10 @@ class AudioLink(
|
||||
private var bytes = 0L
|
||||
private var dropped = 0
|
||||
@Volatile private var configTimer: Thread? = null
|
||||
/** Underflows the car reported this drive, and the silence still to slip in ahead of the next chunk. */
|
||||
private var underflows = 0
|
||||
@Volatile private var padMs = 0
|
||||
@Volatile private var pendingPadMs = 0
|
||||
private val ackLock = Object()
|
||||
/** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */
|
||||
@Volatile private var aac: AacEncoder? = null
|
||||
@@ -180,6 +184,8 @@ class AudioLink(
|
||||
}
|
||||
}
|
||||
|
||||
MSG_UNDERFLOW -> onUnderflow()
|
||||
|
||||
MSG_ACK -> {
|
||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||
synchronized(ackLock) {
|
||||
@@ -221,6 +227,26 @@ class AudioLink(
|
||||
if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack()
|
||||
}
|
||||
|
||||
/**
|
||||
* The car ran out of sound to play (AudioUnderflowNotification, no body): its buffer emptied
|
||||
* before the next chunk arrived, which the driver hears as a gap. Capture runs in real time, so
|
||||
* the car's buffer only ever holds what the window lets through. A little silence slipped in
|
||||
* ahead of the next chunk puts it that far ahead, so the next slow patch is soaked up rather
|
||||
* than heard. Capped, as each step adds a little delay to the music.
|
||||
*/
|
||||
private fun onUnderflow() {
|
||||
underflows++
|
||||
val step = if (started && padMs < MAX_PAD_MS) PAD_STEP_MS else 0
|
||||
padMs += step
|
||||
pendingPadMs += step
|
||||
if (underflows <= 3 || underflows % 20 == 0) {
|
||||
GearslipLog.w("$tag: <- AudioUnderflow ($underflows this drive) - running ${padMs}ms ahead now")
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence for [ms], whole frames of this sink's format. */
|
||||
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
|
||||
|
||||
override fun start() {
|
||||
if (!ready || started) return
|
||||
if (asksFocus && !focusRequested) {
|
||||
@@ -251,6 +277,12 @@ class AudioLink(
|
||||
MSG_START,
|
||||
Protobuf.varintField(1, sessionId.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||
)
|
||||
// Raw AAC-LC has no header of its own: the car needs the codec config before the first frame.
|
||||
if (service.codecType == ServiceDiscovery.CODEC_AAC_LC) {
|
||||
val asc = AacEncoder.audioSpecificConfig(sampleRate, channels)
|
||||
sendOnChannel(MSG_CODEC_CONFIG, asc)
|
||||
GearslipLog.hex("$tag: -> CodecConfig (AudioSpecificConfig)", asc)
|
||||
}
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
if (aacSink && aac == null) {
|
||||
@@ -282,10 +314,23 @@ class AudioLink(
|
||||
GearslipLog.i("$tag: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
|
||||
chunks = 0
|
||||
bytes = 0
|
||||
// A new stream starts with the car's buffer empty again: earn the lead back if it's needed.
|
||||
padMs = 0
|
||||
pendingPadMs = 0
|
||||
}
|
||||
|
||||
override fun write(pcm: ByteArray, size: Int) {
|
||||
if (!started) return
|
||||
val pad = pendingPadMs
|
||||
if (pad > 0) {
|
||||
pendingPadMs = 0
|
||||
val quiet = silence(pad)
|
||||
writeNow(quiet, quiet.size)
|
||||
}
|
||||
writeNow(pcm, size)
|
||||
}
|
||||
|
||||
private fun writeNow(pcm: ByteArray, size: Int) {
|
||||
val encoder = aac
|
||||
if (encoder == null) {
|
||||
send(pcm.copyOf(size))
|
||||
@@ -364,12 +409,16 @@ class AudioLink(
|
||||
const val MSG_FOCUS_RESPONSE = 19
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_DATA = 0
|
||||
private const val MSG_CODEC_CONFIG = 1
|
||||
private const val MSG_SETUP = 32768
|
||||
private const val MSG_START = 32769
|
||||
private const val MSG_STOP = 32770
|
||||
private const val MSG_CONFIG = 32771
|
||||
private const val MSG_ACK = 32772
|
||||
private const val MSG_SETUP_ANSWER = 255
|
||||
private const val MSG_UNDERFLOW = 32779 // 0x800B, car to phone, no body
|
||||
private const val PAD_STEP_MS = 60
|
||||
private const val MAX_PAD_MS = 240
|
||||
private const val STATUS_READY = 2
|
||||
private const val FOCUS_GAIN = 1
|
||||
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.KeyEvent
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import app.seb3thehacker.gearslip.car.CarKeys
|
||||
|
||||
/**
|
||||
* Tells the car about calls, so its own screens show them: the instrument cluster, the steering
|
||||
* wheel display, the car's phone page. Android Auto does the same over the car's
|
||||
* PhoneStatusService (Service field 10):
|
||||
*
|
||||
* -> PhoneStatusUpdate { repeated PhoneCall calls = 1 }
|
||||
* PhoneCall { state = 1 (1 in call, 4 incoming, 6 muted); duration_seconds = 2;
|
||||
* number = 3; name = 4 }
|
||||
* <- PhoneStatusInput { input = 1 { action = 1 (7 call) }; caller = 2; name = 3 }
|
||||
*
|
||||
* Read once a second from [CarCalls]. An update goes when the call changes, and every second
|
||||
* while one is going, so the car's timer counts along; no calls goes once, as an empty list.
|
||||
* The car's own call button answers through [CarKeys], like the steering wheel's.
|
||||
*/
|
||||
class CarPhoneStatus(
|
||||
val channelId: Int,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
@Volatile private var running = false
|
||||
private var sent: String? = null
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("phone status: <- ChannelOpenResponse: status=$status")
|
||||
if (status == 0) start()
|
||||
}
|
||||
MSG_INPUT -> onInput(body)
|
||||
else -> GearslipLog.i("phone status: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
Thread({
|
||||
while (running) {
|
||||
runCatching { tick() }.onFailure { GearslipLog.w("phone status: ${it.message}") }
|
||||
Thread.sleep(TICK_MS)
|
||||
}
|
||||
}, "gearslip-phone-status").start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
}
|
||||
|
||||
private fun tick() {
|
||||
val call = when (val state = CarCalls.state.value) {
|
||||
CallUiState.None -> null
|
||||
is CallUiState.Ringing -> Call(STATE_INCOMING, 0, state.caller)
|
||||
is CallUiState.Active -> Call(
|
||||
if (CarCalls.muted.value) STATE_MUTED else STATE_IN_CALL,
|
||||
((SystemClock.uptimeMillis() - state.since) / 1000).coerceAtLeast(0),
|
||||
state.caller,
|
||||
)
|
||||
}
|
||||
// Only a call that's going changes from one second to the next.
|
||||
val key = call?.let { "${it.state}|${it.caller?.number}|${it.caller?.name}|${if (it.state == STATE_INCOMING) 0 else it.seconds}" } ?: ""
|
||||
if (key == sent) return
|
||||
val changed = call?.state != lastState
|
||||
sent = key
|
||||
lastState = call?.state
|
||||
val body = call?.let { Protobuf.bytesField(1, it.encode()) } ?: ByteArray(0)
|
||||
sendOnChannel(MSG_STATUS, body)
|
||||
if (changed) GearslipLog.i("phone status: -> ${call?.let { STATE_NAMES[it.state] } ?: "no calls"}")
|
||||
}
|
||||
|
||||
private var lastState: Int? = null
|
||||
|
||||
private class Call(val state: Int, val seconds: Long, val caller: Caller?) {
|
||||
fun encode(): ByteArray {
|
||||
var body = Protobuf.varintField(1, state.toLong()) + Protobuf.varintField(2, seconds)
|
||||
caller?.number?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(3, it) }
|
||||
caller?.name?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(4, it) }
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
/** The car's own phone buttons. Only "call" does anything: it answers, as the wheel's does. */
|
||||
private fun onInput(body: ByteArray) {
|
||||
val action = Wire.bytes(Wire.fields(body), 1)?.let { Wire.varint(Wire.fields(it), 1)?.toInt() }
|
||||
GearslipLog.i("phone status: <- input action=${action ?: "?"}")
|
||||
if (action == ACTION_CALL) CarKeys.onKey(KeyEvent.KEYCODE_CALL, down = true, longPress = false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TICK_MS = 1_000L
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_STATUS = 32769 // 0x8001
|
||||
private const val MSG_INPUT = 32770 // 0x8002
|
||||
private const val STATE_IN_CALL = 1
|
||||
private const val STATE_INCOMING = 4
|
||||
private const val STATE_MUTED = 6
|
||||
private const val ACTION_CALL = 7
|
||||
private val STATE_NAMES = mapOf(STATE_IN_CALL to "in a call", STATE_INCOMING to "ringing", STATE_MUTED to "in a call, muted")
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,12 @@ object AssistantCommands {
|
||||
heard: String,
|
||||
): String? {
|
||||
// "use Organic Maps to get to X", "open OsmAnd and navigate to X": the app comes first.
|
||||
var text = heard
|
||||
// "take me home", "go home": no "to", so no trigger would catch them.
|
||||
var text = if (Regex("^(?:(?:take|bring|get|drive) me|go|drive|head|get|directions|go back|head back) home$").matches(heard)) {
|
||||
"navigate to home"
|
||||
} else {
|
||||
heard
|
||||
}
|
||||
var leadingApp: String? = null
|
||||
Regex("^(?:use|using|with|open|in|on) (.+?) (?:to |and )(.+)$").find(text)?.let { m ->
|
||||
if (after(m.groupValues[2], NAVIGATE_TRIGGERS) != null) {
|
||||
@@ -135,6 +140,20 @@ object AssistantCommands {
|
||||
val (place, nearby) = plainPlace(destination)
|
||||
destination = place
|
||||
if (destination.isBlank()) return "Where to?"
|
||||
// "home", "work", or a name from Saved places: its address is the search.
|
||||
var spoken: String? = null
|
||||
SavedPlaces.match(context, destination)?.let { (slot, saved) ->
|
||||
val name = when (slot) {
|
||||
SavedPlaces.HOME -> "home"
|
||||
SavedPlaces.WORK -> "work"
|
||||
else -> saved.label
|
||||
}
|
||||
if (saved.address.isBlank()) {
|
||||
return "You haven't saved a $name address yet. Add it under Saved places in Gearslip's settings on your phone."
|
||||
}
|
||||
destination = saved.address
|
||||
spoken = if (slot == SavedPlaces.HOME) "home" else "to $name"
|
||||
}
|
||||
|
||||
val chosen = chosenApp ?: when {
|
||||
navApps.size == 1 -> navApps.first()
|
||||
@@ -144,6 +163,7 @@ object AssistantCommands {
|
||||
|
||||
CarServices.connectNav(template, frame, destination)
|
||||
navigator.home()
|
||||
spoken?.let { return "Navigating $it with ${chosen.label}." }
|
||||
return if (nearby) "Finding the nearest $destination with ${chosen.label}." else "Navigating to $destination with ${chosen.label}."
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ object BluetoothAudioCheck {
|
||||
if (!armed) return
|
||||
val context = appContext ?: return
|
||||
if (CarSettings.pipeAudio.value) {
|
||||
set(false, "USB audio is on")
|
||||
set(false, "audio goes through Gearslip")
|
||||
return
|
||||
}
|
||||
if (!CarSettings.bluetoothWarning.value) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -45,32 +47,28 @@ internal fun BluetoothPairOverlay() {
|
||||
val locationOff by LocationCheck.off.collectAsState()
|
||||
val locationDismissed by LocationCheck.carDismissed.collectAsState()
|
||||
if (missing && bluetoothAudio && warn && !dismissed) PairCard(
|
||||
title = "Bluetooth isn't connected",
|
||||
body = "Music plays over Bluetooth, but your phone isn't connected to the car. " +
|
||||
"Connect it from the car's Bluetooth menu or your phone's.",
|
||||
secondary = "Don't show again" to BluetoothAudioCheck::neverAgain,
|
||||
primary = "OK" to BluetoothAudioCheck::dismissOnCar,
|
||||
title = stringResource(R.string.home_bt_title),
|
||||
body = stringResource(R.string.overlay_bt_body),
|
||||
secondary = stringResource(R.string.overlay_dont_show) to BluetoothAudioCheck::neverAgain,
|
||||
primary = stringResource(R.string.overlay_ok) to BluetoothAudioCheck::dismissOnCar,
|
||||
) else if (locationOff && !locationDismissed) PairCard(
|
||||
title = "Location is off",
|
||||
body = "Map apps can't show where you are or give directions. Turn on location " +
|
||||
"in your phone's quick settings when you're parked.",
|
||||
secondary = "Don't show again" to LocationCheck::neverAgain,
|
||||
primary = "OK" to LocationCheck::dismissOnCar,
|
||||
title = stringResource(R.string.overlay_location_off),
|
||||
body = stringResource(R.string.overlay_location_body),
|
||||
secondary = stringResource(R.string.overlay_dont_show) to LocationCheck::neverAgain,
|
||||
primary = stringResource(R.string.overlay_ok) to LocationCheck::dismissOnCar,
|
||||
)
|
||||
}
|
||||
CarBluetooth.Prompt.ASK -> PairCard(
|
||||
title = "Pair with this car?",
|
||||
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") +
|
||||
" through the car's speakers once your phone and the car are paired. Your phone " +
|
||||
"then asks you to confirm a code.",
|
||||
secondary = "Not now" to CarBluetooth::notNow,
|
||||
primary = "Pair" to CarBluetooth::pair,
|
||||
title = stringResource(R.string.overlay_pair_title),
|
||||
body = stringResource(if (bluetoothAudio) R.string.overlay_pair_body_music else R.string.overlay_pair_body),
|
||||
secondary = stringResource(R.string.worked_not_now) to CarBluetooth::notNow,
|
||||
primary = stringResource(R.string.overlay_pair) to CarBluetooth::pair,
|
||||
)
|
||||
CarBluetooth.Prompt.PAIRING -> PairCard(
|
||||
title = "Confirm on your phone",
|
||||
body = "Check the code on your phone and tap Pair. The car may ask too.",
|
||||
title = stringResource(R.string.overlay_confirm_title),
|
||||
body = stringResource(R.string.overlay_confirm_body),
|
||||
secondary = null,
|
||||
primary = "Hide" to CarBluetooth::hide,
|
||||
primary = stringResource(R.string.carset_hide) to CarBluetooth::hide,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -63,7 +65,7 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Incoming call",
|
||||
stringResource(R.string.call_incoming),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -76,13 +78,13 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
|
||||
)
|
||||
Spacer(Modifier.height(56.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
||||
CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
||||
CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
||||
CallButton(Icons.Filled.Close, stringResource(R.string.call_decline), MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
||||
CallButton(Icons.Filled.Call, stringResource(R.string.call_answer), MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
||||
}
|
||||
if (!s.canDecline) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Already ringing out loud - answer here, or let it ring.",
|
||||
stringResource(R.string.call_ringing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
@@ -11,11 +13,14 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import kotlin.math.hypot
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -38,6 +43,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,14 +73,32 @@ 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())
|
||||
}
|
||||
// The docked player floats over one side of the map: the chrome keeps clear of it,
|
||||
// and the app hears that side is covered, so its map centres in the rest.
|
||||
val panel = LocalSidePanelInset.current
|
||||
val panelAtStart = LocalCardsAtEnd.current
|
||||
TemplateChrome(
|
||||
template,
|
||||
Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR, bottom = navBar),
|
||||
Modifier.fillMaxSize().padding(
|
||||
top = if (embedded) 0.dp else CHROME_BAR,
|
||||
bottom = navBar,
|
||||
start = if (panelAtStart) panel else 0.dp,
|
||||
end = if (panelAtStart) 0.dp else panel,
|
||||
),
|
||||
)
|
||||
if (panel > 0.dp) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(if (panelAtStart) Alignment.CenterStart else Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(panel)
|
||||
.covers("side-panel", if (panelAtStart) CoverEdge.START else CoverEdge.END),
|
||||
)
|
||||
}
|
||||
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
||||
@@ -102,10 +126,22 @@ 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 cardsAtEnd = LocalCardsAtEnd.current
|
||||
val cardEdge = cardEdge
|
||||
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
|
||||
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd) {
|
||||
if (width > 0 && height > 0) {
|
||||
val visible = visibleAreaFor(template, width, height, measured)
|
||||
connection.reportAreas(visible = visible, stable = visible)
|
||||
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd)
|
||||
// Waze seems to centre its car in the stable area rather than the visible one, so it
|
||||
// gets the cards taken out of that too. Test: see whether its car moves out from under them.
|
||||
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
|
||||
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd)
|
||||
} else {
|
||||
visible
|
||||
}
|
||||
connection.reportAreas(visible = visible, stable = stable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,28 +241,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 ?: stringResource(R.string.stage_car_app),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsIconButton(
|
||||
androidx.compose.material.icons.Icons.Filled.Close,
|
||||
status.app?.let { stringResource(R.string.stage_close, it) } ?: stringResource(R.string.stage_close_app),
|
||||
onDisconnect,
|
||||
size = 36.dp,
|
||||
iconSize = 20.dp,
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,3 +275,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 }
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
@@ -13,7 +15,9 @@ import app.seb3thehacker.gearslip.ProjectedContent
|
||||
/** One tile in the launcher and the screen it opens. */
|
||||
class CarApp(
|
||||
val id: String,
|
||||
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
|
||||
val label: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val icon: ImageVector,
|
||||
val content: @Composable () -> Unit,
|
||||
)
|
||||
@@ -22,16 +26,16 @@ class CarApp(
|
||||
object CarApps {
|
||||
|
||||
val all: List<CarApp> = listOf(
|
||||
CarApp("web", "Browser", Icons.Filled.Search) {
|
||||
CarApp("web", "Browser", R.string.app_browser, Icons.Filled.Search) {
|
||||
WebApp(content = ProjectedContent.resolve(LocalContext.current))
|
||||
},
|
||||
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
|
||||
CarApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share) {
|
||||
PhoneAppsScreen()
|
||||
},
|
||||
CarApp("dialer", "Phone", Icons.Filled.Call) {
|
||||
CarApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call) {
|
||||
PhoneDialerScreen()
|
||||
},
|
||||
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
||||
CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) {
|
||||
CarSettingsScreen()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
@@ -111,7 +113,7 @@ object CarBluetooth {
|
||||
// If the car never answers on its channel, pair from the phone anyway.
|
||||
main.postDelayed({ startBond("the car didn't answer the pairing request") }, CAR_REPLY_WAIT_MS)
|
||||
main.postDelayed({
|
||||
if (pairing) finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
|
||||
if (pairing) finish(R.string.bt_pair_unfinished)
|
||||
}, GIVE_UP_MS)
|
||||
}
|
||||
|
||||
@@ -168,7 +170,7 @@ object CarBluetooth {
|
||||
bondStarted = true
|
||||
val started = runCatching { device.createBond() }.getOrDefault(false)
|
||||
log.i("pairing from the phone ($why): ${if (started) "started" else "refused"}")
|
||||
if (!started) finish("Pairing didn't start. Pair from the car's Bluetooth menu instead.")
|
||||
if (!started) finish(R.string.bt_pair_not_started)
|
||||
}
|
||||
|
||||
private fun watchBonding(device: BluetoothDevice) {
|
||||
@@ -184,8 +186,8 @@ object CarBluetooth {
|
||||
log.i("bond state $previous -> $state")
|
||||
when {
|
||||
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
|
||||
state == BluetoothDevice.BOND_BONDED -> finish("Paired. Calls now play through the car.")
|
||||
previous == BluetoothDevice.BOND_BONDING -> finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
|
||||
state == BluetoothDevice.BOND_BONDED -> finish(R.string.bt_paired)
|
||||
previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,12 +195,12 @@ object CarBluetooth {
|
||||
receiver = r
|
||||
}
|
||||
|
||||
private fun finish(message: String) {
|
||||
private fun finish(@StringRes message: Int) {
|
||||
pairing = false
|
||||
stopWatching()
|
||||
main.removeCallbacksAndMessages(null)
|
||||
_prompt.value = Prompt.NONE
|
||||
CarToasts.show(this, message, CarToast.LENGTH_LONG)
|
||||
CarToasts.show(this, appContext?.getString(message) ?: return, CarToast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
private fun stopWatching() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
@@ -60,7 +62,7 @@ fun CarDashboardScreen() {
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
ScreenHeader("Today")
|
||||
ScreenHeader(stringResource(R.string.dash_today))
|
||||
WeatherCard(context)
|
||||
AgendaCard(context)
|
||||
NotificationsSection()
|
||||
@@ -84,9 +86,9 @@ private fun WeatherCard(context: Context) {
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
|
||||
WeatherState.Loading -> Text(stringResource(R.string.dash_getting_weather), style = MaterialTheme.typography.bodyLarge)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
s.reason, style = MaterialTheme.typography.bodyLarge,
|
||||
stringResource(s.reason), style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is WeatherState.Ready -> {
|
||||
@@ -94,7 +96,7 @@ private fun WeatherCard(context: Context) {
|
||||
WeatherIcon(w.code, w.isDay, size = 44.dp)
|
||||
Column {
|
||||
Text(
|
||||
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
|
||||
"${w.temp}${w.tempUnit} · ${Weather.condition(w.code)}",
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -112,12 +114,12 @@ private fun AgendaCard(context: Context) {
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Coming up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.dash_coming_up), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
val list = events
|
||||
when {
|
||||
list == null -> Text("Loading calendar…", style = MaterialTheme.typography.bodyMedium)
|
||||
list == null -> Text(stringResource(R.string.dash_loading_calendar), style = MaterialTheme.typography.bodyMedium)
|
||||
list.isEmpty() -> Text(
|
||||
"Nothing in the next week.",
|
||||
stringResource(R.string.dash_nothing_week),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -135,7 +137,7 @@ private fun EventRow(event: AgendaEvent) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
eventWhen(event),
|
||||
eventWhen(event, stringResource(R.string.dash_all_day)),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
@@ -149,14 +151,14 @@ private fun EventRow(event: AgendaEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */
|
||||
private fun eventWhen(event: AgendaEvent): String {
|
||||
/** stringResource(R.string.dash_today) events show just a time; later days get a weekday so a week's list stays readable. */
|
||||
private fun eventWhen(event: AgendaEvent, allDay: String): String {
|
||||
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
|
||||
val today = Calendar.getInstance()
|
||||
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
|
||||
start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR)
|
||||
val time = if (event.allDay) "All day" else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
|
||||
val time = if (event.allDay) allDay else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
|
||||
if (sameDay || (event.allDay && start.before(today))) return time
|
||||
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The car's sensors, read into real values for the car apps that ask (a map's speed, an EV app's
|
||||
* range). [CarSensors] keeps the raw readings for the Vehicle data screen; this keeps the few with
|
||||
* a known meaning, decoded the way Android Auto's SensorBatch scales them:
|
||||
*
|
||||
* gps_location = 1 { latitude_e7 = 2; longitude_e7 = 3; accuracy_e3 = 4; altitude_e2 = 5;
|
||||
* speed_e3 = 6; bearing_e6 = 7 }
|
||||
* compass = 2 { bearing_e6 = 1 } speed = 3 { speed_e3 = 1 } (metres per second)
|
||||
* odometer = 5 { kms_e1 = 1 } fuel = 6 { level = 1 (percent); range = 2 (km); low = 3 }
|
||||
*
|
||||
* Each value is null until the car first sends it, and the whole lot clears when it disconnects.
|
||||
*/
|
||||
object CarData {
|
||||
|
||||
class Location(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyMeters: Float?,
|
||||
val altitudeMeters: Double?,
|
||||
val speedMps: Float?,
|
||||
val bearingDegrees: Float?,
|
||||
)
|
||||
|
||||
data class Snapshot(
|
||||
val speedMps: Float? = null,
|
||||
val odometerKm: Float? = null,
|
||||
val fuelPercent: Int? = null,
|
||||
val rangeKm: Int? = null,
|
||||
val fuelLow: Boolean? = null,
|
||||
val compassDegrees: Float? = null,
|
||||
val location: Location? = null,
|
||||
/** When each kind last changed, by its SensorBatch field number. */
|
||||
val updatedAt: Map<Int, Long> = emptyMap(),
|
||||
)
|
||||
|
||||
/** What the car says it is, from its service discovery: model and year. It names no maker. */
|
||||
class Model(val name: String, val year: Int?)
|
||||
|
||||
private val flow = MutableStateFlow(Snapshot())
|
||||
val state: StateFlow<Snapshot> = flow
|
||||
|
||||
@Volatile var model: Model? = null
|
||||
private set
|
||||
|
||||
fun onModel(name: String, year: String) {
|
||||
model = name.takeIf { it.isNotBlank() }?.let { Model(it, year.toIntOrNull()?.takeIf { y -> y > 0 }) }
|
||||
}
|
||||
|
||||
fun onEvent(body: ByteArray) {
|
||||
val now = System.currentTimeMillis()
|
||||
val batch = Wire.fields(body)
|
||||
var s = flow.value
|
||||
val touched = HashSet<Int>()
|
||||
// Each kind may come several times in one batch: the last is the newest.
|
||||
Wire.allBytes(batch, LOCATION).lastOrNull()?.let { bytes ->
|
||||
val f = Wire.fields(bytes)
|
||||
val lat = Wire.varint(f, 2)?.toInt()
|
||||
val lon = Wire.varint(f, 3)?.toInt()
|
||||
if (lat != null && lon != null) {
|
||||
s = s.copy(
|
||||
location = Location(
|
||||
lat / 1e7, lon / 1e7,
|
||||
Wire.varint(f, 4)?.let { it / 1e3f },
|
||||
Wire.varint(f, 5)?.let { it.toInt() / 1e2 },
|
||||
Wire.varint(f, 6)?.let { it.toInt() / 1e3f },
|
||||
Wire.varint(f, 7)?.let { it.toInt() / 1e6f },
|
||||
),
|
||||
)
|
||||
touched += LOCATION
|
||||
}
|
||||
}
|
||||
Wire.allBytes(batch, COMPASS).lastOrNull()?.let { bytes ->
|
||||
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(compassDegrees = it.toInt() / 1e6f); touched += COMPASS }
|
||||
}
|
||||
Wire.allBytes(batch, SPEED).lastOrNull()?.let { bytes ->
|
||||
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(speedMps = it.toInt() / 1e3f); touched += SPEED }
|
||||
}
|
||||
Wire.allBytes(batch, ODOMETER).lastOrNull()?.let { bytes ->
|
||||
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(odometerKm = it.toInt() / 10f); touched += ODOMETER }
|
||||
}
|
||||
Wire.allBytes(batch, FUEL).lastOrNull()?.let { bytes ->
|
||||
val f = Wire.fields(bytes)
|
||||
s = s.copy(
|
||||
fuelPercent = Wire.varint(f, 1)?.toInt() ?: s.fuelPercent,
|
||||
rangeKm = Wire.varint(f, 2)?.toInt() ?: s.rangeKm,
|
||||
fuelLow = Wire.varint(f, 3)?.let { it != 0L } ?: s.fuelLow,
|
||||
)
|
||||
touched += FUEL
|
||||
}
|
||||
if (touched.isEmpty()) return
|
||||
flow.value = s.copy(updatedAt = s.updatedAt + touched.associateWith { now })
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
flow.value = Snapshot()
|
||||
model = null
|
||||
}
|
||||
|
||||
const val LOCATION = 1
|
||||
const val COMPASS = 2
|
||||
const val SPEED = 3
|
||||
const val ODOMETER = 5
|
||||
const val FUEL = 6
|
||||
}
|
||||
@@ -64,7 +64,9 @@ object CarEnvironment {
|
||||
}
|
||||
|
||||
@Volatile private var sensorNight: Boolean? = null
|
||||
private var appContext: Context? = null
|
||||
/** Set once the car UI starts; the cluster screen draws with it too. */
|
||||
var appContext: Context? = null
|
||||
private set
|
||||
private var running = false
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -12,6 +13,8 @@ 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 app.seb3thehacker.gearslip.car.theme.gsPanelShadow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
@@ -64,7 +67,9 @@ import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The home screen: the map on the left, the docked player on the right when asked for.
|
||||
* The home screen: the map, with the docked player floating over one side of it when asked for.
|
||||
* With the player on the left, the map app's cards move to the map's far edge, so the player, the
|
||||
* map and the cards each keep their own space.
|
||||
*
|
||||
* The map is whatever navigation app was used last, brought back on its own. The player sits in
|
||||
* the nav bar's pill until its dock button moves it here; the column closes itself back to a
|
||||
@@ -93,23 +98,57 @@ fun CarHome() {
|
||||
if (onHome) seenHome[0] = true
|
||||
}
|
||||
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
||||
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle()
|
||||
// What the card showed last, so it still has something to show while it slides away.
|
||||
val shown = remember { androidx.compose.runtime.mutableStateOf(SidePanel.CONTROLS) }
|
||||
if (panel != SidePanel.NONE) shown.value = panel
|
||||
|
||||
// No padding anywhere, split screen or not: the player sits flush against the map.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(if (splitScreen) 2.1f else 1f)
|
||||
.fillMaxHeight(),
|
||||
// The map runs edge to edge underneath; the player floats over one side of it as a card, with
|
||||
// a gap all round. The map's surface keeps its size when the card comes and goes, so the app
|
||||
// only re-centres its map instead of drawing onto a new surface.
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val column = (maxWidth / PLAYER_SHARE).coerceIn(PLAYER_MIN_WIDTH, PLAYER_MAX_WIDTH)
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
LocalCardsAtEnd provides (splitScreen && playerLeft),
|
||||
LocalSidePanelInset provides if (splitScreen) column else 0.dp,
|
||||
) {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
if (splitScreen) {
|
||||
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
|
||||
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
splitScreen,
|
||||
Modifier.align(if (playerLeft) Alignment.CenterStart else Alignment.CenterEnd),
|
||||
enter = androidx.compose.animation.slideInHorizontally(tween(220)) { if (playerLeft) -it / 3 else it / 3 } +
|
||||
androidx.compose.animation.fadeIn(tween(180)),
|
||||
exit = androidx.compose.animation.slideOutHorizontally(tween(180)) { if (playerLeft) -it / 3 else it / 3 } +
|
||||
androidx.compose.animation.fadeOut(tween(140)),
|
||||
) {
|
||||
val card = Modifier
|
||||
.width(column)
|
||||
.fillMaxHeight()
|
||||
.padding(
|
||||
start = if (playerLeft) CARD_GAP else 0.dp,
|
||||
end = if (playerLeft) 0.dp else CARD_GAP,
|
||||
top = CARD_GAP,
|
||||
bottom = LocalNavBarInset.current + CARD_GAP,
|
||||
)
|
||||
if (shown.value == SidePanel.LYRICS) SideLyrics(now, card) else DockedPlayer(now, card)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The player takes one part in this many of the screen's width. */
|
||||
private const val PLAYER_SHARE = 3.6f
|
||||
|
||||
/** Narrow enough to leave the map most of a small screen; on a wide one the map gets the extra room. */
|
||||
private val PLAYER_MIN_WIDTH = 240.dp
|
||||
private val PLAYER_MAX_WIDTH = 340.dp
|
||||
|
||||
/** Between the player card and the screen's edges, and the map's own cards beside it. */
|
||||
private val CARD_GAP = 12.dp
|
||||
|
||||
/** The card's corners: rounder than a button's, as the map app's own cards are. */
|
||||
private val CARD_SHAPE = RoundedCornerShape(24.dp)
|
||||
|
||||
@Composable
|
||||
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
@@ -119,8 +158,11 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
Modifier.fillMaxSize().padding(
|
||||
start = if (LocalCardsAtEnd.current) LocalSidePanelInset.current else 0.dp,
|
||||
end = if (LocalCardsAtEnd.current) 0.dp else LocalSidePanelInset.current,
|
||||
).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val hasLast = CarSettings.lastNav.value != null
|
||||
@@ -128,18 +170,19 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
when (status.phase) {
|
||||
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
|
||||
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
|
||||
Text(status.app?.let { stringResource(R.string.home_starting, it) } ?: stringResource(R.string.home_starting_map), style = MaterialTheme.typography.titleMedium)
|
||||
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
|
||||
Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium)
|
||||
Text(status.app?.let { stringResource(R.string.home_failed, it) } ?: stringResource(R.string.home_failed_map), style = MaterialTheme.typography.titleMedium)
|
||||
status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
GsButton(onClick = navigator::apps, tone = GsTone.Tonal) { Text("Choose another app") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StatusButton(stringResource(R.string.home_choose_app), GsTone.Primary, navigator::apps)
|
||||
}
|
||||
else -> if (!hasNavApps) {
|
||||
// Nothing to auto-select and nothing to pick from Car apps either - this is the
|
||||
// only case worth a distinct screen, since every other IDLE case resolves itself.
|
||||
Text("No mapping apps found.", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.home_no_maps), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Install a navigation app that supports Android Auto and it will start here on its own.",
|
||||
stringResource(R.string.home_no_maps_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -147,9 +190,18 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
// IDLE with a last app on record means the driver stopped it themselves - it will
|
||||
// not come back on its own, so the screen must offer a way to bring it back rather
|
||||
// than sit on a "starting" message that never resolves.
|
||||
Text("Map stopped.", style = MaterialTheme.typography.titleMedium)
|
||||
GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") }
|
||||
GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") }
|
||||
Text(stringResource(R.string.home_map_stopped), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
stringResource(R.string.home_map_stopped_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Side by side, the same size, the likely one lit: one clear choice, not a stack.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
StatusButton(stringResource(R.string.home_reopen_map), GsTone.Primary) { scope.launch { CarServices.reconnectNav(frame) } }
|
||||
StatusButton(stringResource(R.string.home_car_apps), GsTone.Tonal) { navigator.apps() }
|
||||
}
|
||||
} else {
|
||||
// Autostart is still picking one - a navigation app is installed, so this resolves
|
||||
// to a "Starting..." state itself, in the next frame or two.
|
||||
@@ -158,9 +210,17 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
}
|
||||
}
|
||||
|
||||
/** A big, even button for the map pane's few choices. */
|
||||
@Composable
|
||||
private fun StatusButton(label: String, tone: GsTone, onClick: () -> Unit) {
|
||||
GsButton(onClick = onClick, tone = tone, modifier = Modifier.width(220.dp).height(64.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat
|
||||
* and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
||||
* The player, docked beside the map: the art as large as it fits, then title, progress, transport,
|
||||
* and like, shuffle, repeat and lyrics along the bottom. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
||||
* twice. The X sends it back to the nav bar.
|
||||
*/
|
||||
@Composable
|
||||
@@ -171,28 +231,31 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
|
||||
// Whatever height the controls below leave over, so the art grows on a taller
|
||||
// screen and shrinks rather than pushing the buttons off a short one.
|
||||
val artSize = minOf(maxWidth - 44.dp, maxHeight - 250.dp).coerceIn(56.dp, 200.dp)
|
||||
// The theme's surface, with a faint wash of the cover's colours behind the top of it. A solid
|
||||
// slab of album colour beside the map fought it for attention; this only hints at the song.
|
||||
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
||||
AlbumGlow(art, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().padding(12.dp)) {
|
||||
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
// The cover gets everything the controls leave, as big a square as fits, sitting
|
||||
// right on top of the title; the controls stay pinned to the bottom.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
|
||||
val artSize = minOf(maxWidth, maxHeight).coerceAtLeast(0.dp)
|
||||
// Art opens the full media screen, same as on the pill.
|
||||
val coverShape = RoundedCornerShape(16.dp)
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.size(artSize)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.gsPanelShadow(coverShape)
|
||||
.clip(coverShape)
|
||||
.background(scheme.surface)
|
||||
.clickable { navigator.media() },
|
||||
) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
// A new song's cover fades in over the old one instead of cutting to it.
|
||||
androidx.compose.animation.Crossfade(art, animationSpec = tween(350), label = "cover") { shown ->
|
||||
shown?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
}
|
||||
GsIconButton(
|
||||
Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel,
|
||||
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
@@ -208,46 +271,53 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
||||
} else {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
TransportRow(now, media, skip = 48.dp, modifier = Modifier.fillMaxWidth())
|
||||
TransportRow(now, media, skip = 56.dp, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PlayModeRow(now, media, size = 42.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
PlayModeRow(now, media, size = 50.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
GsIconButton(
|
||||
ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics,
|
||||
size = 42.dp, iconSize = 22.dp,
|
||||
ImageVector.vectorResource(R.drawable.lyrics_24), stringResource(R.string.storage_lyrics), navigator::showSideLyrics,
|
||||
size = 50.dp, iconSize = 26.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
GsIconButton(
|
||||
Icons.Filled.Close, stringResource(R.string.home_back_nav), navigator::closeSidePanel,
|
||||
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lyrics alone beside the map, big enough to read at a glance. No art: that space goes to a
|
||||
* larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock
|
||||
* button swaps these back out for the controls.
|
||||
* Lyrics alone beside the map, big enough to read at a glance. The header is slim, since the
|
||||
* player goes back to the nav bar meanwhile and names the song there. Its note button swaps the
|
||||
* lyrics back for the player; the X closes the panel.
|
||||
*/
|
||||
@Composable
|
||||
private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val context = LocalContext.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
now.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist, style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
listOf(now.title, now.artist).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = scheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// The way back to the player, as the player's own Lyrics button is the way here.
|
||||
GsIconButton(MediaIcons.MusicNote, stringResource(R.string.home_show_player), navigator::showSidePlayer, size = 36.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.size(6.dp))
|
||||
GsIconButton(Icons.Filled.Close, stringResource(R.string.home_hide_lyrics), navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -153,7 +155,7 @@ fun CarKeyboard(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
Key(stringResource(R.string.keyboard_space), Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
if (onDismiss != null) {
|
||||
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||
}
|
||||
@@ -200,7 +202,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Hide keyboard")
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.keyboard_hide))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +222,7 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
|
||||
) {
|
||||
Icon(
|
||||
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
|
||||
contentDescription = "Submit",
|
||||
contentDescription = stringResource(R.string.keyboard_submit),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -119,35 +121,62 @@ 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], context.getString(R.string.media_browse)).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?,
|
||||
browse: String,
|
||||
): 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +283,7 @@ fun CarLauncher() {
|
||||
val pkg = entry.packageName?.takeIf { entry.messaging == null }
|
||||
val id = entry.componentId
|
||||
return Tile(
|
||||
entry.label, entry.icon, entry.builtIn?.glyph,
|
||||
entry.builtIn?.let { context.getString(it.labelRes) } ?: entry.label, entry.icon, entry.builtIn?.glyph,
|
||||
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
||||
@@ -281,7 +310,7 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
|
||||
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Doesn't work with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.launcher_broken), tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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, stringResource(R.string.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.shown(),
|
||||
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()
|
||||
|
||||
// A full-width strip along the bottom in the screens' own background, so it reads as part of
|
||||
// the screen above rather than a panel on it. Over the player it's see-through, so the
|
||||
// cover's glow carries on behind it.
|
||||
val overPlayer = navigator.current == CarScreen.Media
|
||||
Surface(color = if (overPlayer) androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.background) {
|
||||
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 = stringResource(R.string.crumb_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.shownLabel(), 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 ?: stringResource(R.string.navbar_on_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,
|
||||
stringResource(if (muted) R.string.navbar_unmute else R.string.navbar_mute),
|
||||
CarCalls::toggleMute,
|
||||
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
GsIconButton(
|
||||
MediaIcons.CallEnd, stringResource(R.string.navbar_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. */
|
||||
@Composable
|
||||
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||
.ifEmpty { stringResource(R.string.media_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 closes them.
|
||||
*/
|
||||
@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, stringResource(R.string.player_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,
|
||||
stringResource(if (now.playing) R.string.player_pause else R.string.media_play),
|
||||
media::togglePlay,
|
||||
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Next, stringResource(R.string.player_next), media::next,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
// Drawn on the side the panel opens on, and filled in while it's open.
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val docked = navigator.current == CarScreen.Home && navigator.sidePanel != SidePanel.NONE
|
||||
GsIconButton(
|
||||
when {
|
||||
playerLeft && docked -> MediaIcons.DockLeftOpen
|
||||
playerLeft -> MediaIcons.DockLeft
|
||||
docked -> MediaIcons.DockRightOpen
|
||||
else -> MediaIcons.DockRight
|
||||
},
|
||||
stringResource(if (docked) R.string.navbar_close_panel else R.string.navbar_dock_player),
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -41,7 +45,11 @@ sealed interface CarScreen {
|
||||
enum class SidePanel { NONE, CONTROLS, LYRICS }
|
||||
|
||||
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
|
||||
data class Crumb(val screen: CarScreen, val label: String)
|
||||
data class Crumb(val screen: CarScreen, val label: String, @param:StringRes val labelRes: Int = 0) {
|
||||
/** [labelRes] when the step is one of Gearslip's own, translated; [label] for an app's own name. */
|
||||
@Composable
|
||||
fun shown(): String = if (labelRes != 0) stringResource(labelRes) else label
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
|
||||
@@ -57,6 +65,7 @@ class CarNavigator(startId: String) {
|
||||
Crumb(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
"Home",
|
||||
R.string.crumb_home,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -69,15 +78,15 @@ class CarNavigator(startId: String) {
|
||||
|
||||
/** Pushes a new step, unless it's the same screen already on top - tapping the button for
|
||||
* where you already are shouldn't grow the trail. */
|
||||
private fun push(screen: CarScreen, label: String) {
|
||||
private fun push(screen: CarScreen, label: String, @StringRes labelRes: Int = 0) {
|
||||
if (stack.last().screen == screen) return
|
||||
stack.add(Crumb(screen, label))
|
||||
stack.add(Crumb(screen, label, labelRes))
|
||||
}
|
||||
|
||||
/** The one way back to a clean slate: resets the whole trail to just Home. */
|
||||
fun home() {
|
||||
stack.clear()
|
||||
stack.add(Crumb(CarScreen.Home, "Home"))
|
||||
stack.add(Crumb(CarScreen.Home, "Home", R.string.crumb_home))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,8 +99,9 @@ class CarNavigator(startId: String) {
|
||||
private set
|
||||
|
||||
/**
|
||||
* The pill's dock button: moves the player beside the map, going Home first if need be. With
|
||||
* lyrics already there it swaps them for the controls; with the controls there it closes.
|
||||
* The pill's dock button, an open/close switch for the panel: moves the player beside the map,
|
||||
* going Home first if need be, or closes the panel when something is already there. Switching
|
||||
* between the player and lyrics is done inside the panel itself.
|
||||
*/
|
||||
fun togglePlayerDock() {
|
||||
if (current != CarScreen.Home) {
|
||||
@@ -99,9 +109,11 @@ class CarNavigator(startId: String) {
|
||||
home()
|
||||
return
|
||||
}
|
||||
sidePanel = if (sidePanel == SidePanel.CONTROLS) SidePanel.NONE else SidePanel.CONTROLS
|
||||
sidePanel = if (sidePanel == SidePanel.NONE) SidePanel.CONTROLS else SidePanel.NONE
|
||||
}
|
||||
|
||||
fun showSidePlayer() { sidePanel = SidePanel.CONTROLS }
|
||||
|
||||
fun showSideLyrics() { sidePanel = SidePanel.LYRICS }
|
||||
|
||||
fun closeSidePanel() { sidePanel = SidePanel.NONE }
|
||||
@@ -114,17 +126,17 @@ class CarNavigator(startId: String) {
|
||||
|
||||
fun dismissAppMenu() { appMenu = null }
|
||||
|
||||
fun media() { push(CarScreen.Media, "Media") }
|
||||
fun media() { push(CarScreen.Media, "Media", R.string.crumb_media) }
|
||||
|
||||
fun browse(label: String) { push(CarScreen.Browse, label) }
|
||||
|
||||
fun apps() { push(CarScreen.Apps, "Apps") }
|
||||
fun apps() { push(CarScreen.Apps, "Apps", R.string.crumb_apps) }
|
||||
|
||||
fun settings() { push(CarScreen.Settings, "Settings") }
|
||||
fun settings() { push(CarScreen.Settings, "Settings", R.string.settings_title) }
|
||||
|
||||
fun dashboard() { push(CarScreen.Dashboard, "Dashboard") }
|
||||
fun dashboard() { push(CarScreen.Dashboard, "Dashboard", R.string.crumb_dashboard) }
|
||||
|
||||
fun weather() { push(CarScreen.Weather, "Weather") }
|
||||
fun weather() { push(CarScreen.Weather, "Weather", R.string.crumb_weather) }
|
||||
|
||||
fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") }
|
||||
|
||||
@@ -132,10 +144,10 @@ class CarNavigator(startId: String) {
|
||||
|
||||
/** The list lives on the dashboard; only a reply gets a screen of its own. */
|
||||
fun notifications(replyTo: String? = null) {
|
||||
if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply")
|
||||
if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply", R.string.crumb_reply)
|
||||
}
|
||||
|
||||
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
|
||||
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id, CarApps.find(id)?.labelRes ?: 0) }
|
||||
|
||||
/** Jumps straight to a step already on the trail, dropping everything after it - what
|
||||
* tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
@@ -63,13 +64,19 @@ 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_MORE_LYRICS = "car_more_lyrics"
|
||||
private const val KEY_THEME = "car_app_theme"
|
||||
private const val KEY_UI_THEME = "car_ui_theme"
|
||||
private const val KEY_DARK_STYLE = "car_dark_style"
|
||||
private const val KEY_PINNED = "car_pinned_apps"
|
||||
private const val KEY_APP_ORDER = "car_app_order"
|
||||
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||
private const val KEY_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_CLUSTER_VIDEO = "cluster_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,8 +107,14 @@ 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 moreLyricsFlow = MutableStateFlow(true)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||
private val darkStyleFlow = MutableStateFlow(DarkStyle.BLACK)
|
||||
|
||||
/** How dark the car's dark mode goes; black until the driver picks another. */
|
||||
val darkStyle: StateFlow<DarkStyle> = darkStyleFlow
|
||||
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
||||
|
||||
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
||||
@@ -111,6 +124,9 @@ 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 clusterVideoFlow = MutableStateFlow(false)
|
||||
private val debugBadgeFlow = MutableStateFlow(true)
|
||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||
|
||||
@@ -215,6 +231,12 @@ 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
|
||||
|
||||
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
|
||||
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
|
||||
|
||||
val scale: StateFlow<Float> = scaleFlow
|
||||
val openOnConnect: StateFlow<String> = openFlow
|
||||
val nightMode: StateFlow<NightMode> = nightFlow
|
||||
@@ -267,6 +289,27 @@ 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
|
||||
|
||||
/**
|
||||
* Gearslip's turn card on a car's instrument-cluster screen, for a car that offers one as a
|
||||
* second video sink. Applies only while [experimentalFeaturesEnabled] is on too, and from the
|
||||
* next connection; untested in a car so far.
|
||||
*/
|
||||
val clusterVideoEnabled: StateFlow<Boolean> = clusterVideoFlow
|
||||
|
||||
/** 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 -
|
||||
@@ -289,9 +332,14 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
themeFlow.value = runCatching {
|
||||
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
|
||||
}.getOrDefault(AppTheme.PHONE)
|
||||
darkStyleFlow.value = runCatching {
|
||||
DarkStyle.valueOf(AppSettings.getString(app, KEY_DARK_STYLE, DarkStyle.BLACK.name))
|
||||
}.getOrDefault(DarkStyle.BLACK)
|
||||
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"
|
||||
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||
// own voice already reaches the car.
|
||||
@@ -308,6 +356,9 @@ 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"
|
||||
clusterVideoFlow.value = AppSettings.getString(app, KEY_CLUSTER_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))
|
||||
@@ -352,6 +403,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_THEME, theme.name)
|
||||
}
|
||||
|
||||
fun setDarkStyle(style: DarkStyle) {
|
||||
darkStyleFlow.value = style
|
||||
AppSettings.putString(app, KEY_DARK_STYLE, style.name)
|
||||
}
|
||||
|
||||
fun setUiTheme(id: String) {
|
||||
uiThemeFlow.value = id
|
||||
AppSettings.putString(app, KEY_UI_THEME, id)
|
||||
@@ -362,6 +418,16 @@ 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 setMoreLyrics(on: Boolean) {
|
||||
moreLyricsFlow.value = on
|
||||
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
|
||||
}
|
||||
|
||||
fun setAutoplay(on: Boolean) {
|
||||
autoplayFlow.value = on
|
||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||
@@ -405,6 +471,16 @@ 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 setClusterVideoEnabled(on: Boolean) {
|
||||
clusterVideoFlow.value = on
|
||||
AppSettings.putString(app, KEY_CLUSTER_VIDEO, on.toString())
|
||||
}
|
||||
|
||||
fun setCarSensorsEnabled(on: Boolean) {
|
||||
carSensorsFlow.value = on
|
||||
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -8,11 +11,15 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.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 +70,15 @@ 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 moreLyrics by CarSettings.moreLyrics.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
|
||||
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
|
||||
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||
val display by CarEnvironment.display.collectAsState()
|
||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||
@@ -75,214 +87,259 @@ fun CarSettingsScreen() {
|
||||
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
// Built as it scrolls into view rather than all at once: every row here is a set of
|
||||
// buttons, and building the whole screen made opening Settings visibly slow.
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
item {
|
||||
ScreenHeader("Settings")
|
||||
}
|
||||
// A rail of the groups down the left, and the chosen group's settings beside it: five short
|
||||
// lists instead of one long scroll.
|
||||
var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) }
|
||||
Row(Modifier.fillMaxSize().padding(start = 16.dp, top = 12.dp)) {
|
||||
SettingsRail(listOf(stringResource(R.string.car_group_screen), stringResource(R.string.car_group_nav_bar), stringResource(R.string.car_group_sound), stringResource(R.string.car_group_warnings), stringResource(R.string.car_group_more)), chosen) { chosen = it }
|
||||
LazyColumn(
|
||||
Modifier.weight(1f).fillMaxHeight(),
|
||||
state = listState,
|
||||
// Built as it scrolls into view rather than all at once: every row here is a set of
|
||||
// buttons, and building the whole screen made opening Settings visibly slow.
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
var section: Int
|
||||
section = 0
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_screen)) }
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Open on connect",
|
||||
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Size",
|
||||
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"App theme",
|
||||
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Theme",
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Map day/night",
|
||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Units",
|
||||
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
}
|
||||
|
||||
// For a head unit that reports the wrong size and stretches the picture (issue #12).
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Screen size (next connection)",
|
||||
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||
.map { it to (it?.toString() ?: "From the car") },
|
||||
screenSize,
|
||||
) { CarSettings.setScreenSize(it) }
|
||||
}
|
||||
|
||||
if (screenSize != null) item {
|
||||
ChoiceRow(
|
||||
"Fit to the screen",
|
||||
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
|
||||
screenFit,
|
||||
) { CarSettings.setScreenFit(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Nav bar",
|
||||
listOf(
|
||||
NavBarMode.SHOW to "Always show",
|
||||
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
|
||||
NavBarMode.HIDE to "Hide until tapped",
|
||||
),
|
||||
navBar,
|
||||
) { CarSettings.setNavBarMode(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Player in the nav bar",
|
||||
listOf(false to "Skip buttons", true to "Song title"),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Autoplay on connect",
|
||||
listOf(false to "Off", true to "On"),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Car audio",
|
||||
listOf(false to "Bluetooth", true to "USB"),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
|
||||
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
|
||||
"Android never asks.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (!pipeAudio) item {
|
||||
ChoiceRow(
|
||||
"Warn when Bluetooth isn't connected",
|
||||
listOf(false to "Off", true to "On"),
|
||||
bluetoothWarning,
|
||||
) { CarSettings.setBluetoothWarning(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Warn when location is off",
|
||||
listOf(false to "Off", true to "On"),
|
||||
locationWarning,
|
||||
) { CarSettings.setLocationWarning(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Spoken directions",
|
||||
listOf(false to "Off", true to "On"),
|
||||
speakDirections,
|
||||
) { CarSettings.setSpeakDirections(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
|
||||
"speaks. Turn it off if your map app's own voice already plays in the car.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ExperimentalFeatureRow(
|
||||
title = "Use the phone's speech recognition",
|
||||
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
|
||||
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
|
||||
"none, so Gearslip keeps using its own.",
|
||||
checked = systemSpeech,
|
||||
) { CarSettings.setSystemSpeech(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
WebPageSetting()
|
||||
}
|
||||
|
||||
item {
|
||||
ScreenFit(vehicle)
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Experimental features",
|
||||
listOf(false to "Hide", true to "Show"),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
}
|
||||
|
||||
if (experimentalFeatures) item {
|
||||
ExperimentalFeaturesList(carSensors)
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info("Vehicle", vehicle.ifEmpty { "-" })
|
||||
Info("Video", display.ifEmpty { "-" })
|
||||
Info("Certificate", cert?.headline ?: "Checking…")
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_open_on_connect),
|
||||
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_size),
|
||||
listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_app_theme),
|
||||
listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && theme != AppTheme.LIGHT) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_dark_mode),
|
||||
listOf(
|
||||
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
|
||||
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
|
||||
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
|
||||
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
|
||||
),
|
||||
darkStyle,
|
||||
) { CarSettings.setDarkStyle(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_theme),
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_map_day_night),
|
||||
listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_units),
|
||||
listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
}
|
||||
|
||||
// For a head unit that reports the wrong size and stretches the picture (issue #12).
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_screen_size),
|
||||
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
|
||||
screenSize,
|
||||
) { CarSettings.setScreenSize(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && screenSize != null) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_fit),
|
||||
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
|
||||
screenFit,
|
||||
) { CarSettings.setScreenFit(it) }
|
||||
}
|
||||
|
||||
section = 1
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_nav_bar)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_nav_bar),
|
||||
listOf(
|
||||
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
|
||||
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
|
||||
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
|
||||
),
|
||||
navBar,
|
||||
) { CarSettings.setNavBarMode(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_nav_bar),
|
||||
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_beside_map),
|
||||
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
|
||||
playerLeft,
|
||||
) { CarSettings.setPlayerLeft(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_autoplay),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_lyrics_from),
|
||||
listOf(false to "LRCLIB", true to stringResource(R.string.carset_more_sources)),
|
||||
moreLyrics,
|
||||
) { CarSettings.setMoreLyrics(it) }
|
||||
}
|
||||
|
||||
section = 2
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_sound)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_audio),
|
||||
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Text(
|
||||
stringResource(R.string.carset_audio_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
section = 3
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_warnings)) }
|
||||
|
||||
if (section == chosen && !pipeAudio) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_warn_bluetooth),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
bluetoothWarning,
|
||||
) { CarSettings.setBluetoothWarning(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_warn_location),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
locationWarning,
|
||||
) { CarSettings.setLocationWarning(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_spoken_directions),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
speakDirections,
|
||||
) { CarSettings.setSpeakDirections(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Text(
|
||||
stringResource(R.string.carset_directions_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ExperimentalFeatureRow(
|
||||
"What's new after updates",
|
||||
"Show the list of changes once, the first time the car connects after an update.",
|
||||
whatsNew,
|
||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||
Text("What's new", style = MaterialTheme.typography.titleMedium)
|
||||
title = stringResource(R.string.car_phone_speech),
|
||||
description = stringResource(R.string.carset_phone_speech_note),
|
||||
checked = systemSpeech,
|
||||
) { CarSettings.setSystemSpeech(it) }
|
||||
}
|
||||
|
||||
section = 4
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_more)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
WebPageSetting()
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ScreenFit(vehicle)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_experimental),
|
||||
listOf(false to stringResource(R.string.carset_hide), true to stringResource(R.string.carset_show)),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && experimentalFeatures) item {
|
||||
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { "-" })
|
||||
Info(stringResource(R.string.carset_video), display.ifEmpty { "-" })
|
||||
Info(stringResource(R.string.settings_certificate), cert?.headline ?: stringResource(R.string.cert_checking))
|
||||
}
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||
ExperimentalFeatureRow(
|
||||
stringResource(R.string.carset_whats_new_after),
|
||||
stringResource(R.string.carset_whats_new_after_sub),
|
||||
whatsNew,
|
||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,8 +348,78 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The groups, with the chosen one lit: a card down the left of Settings. */
|
||||
@Composable
|
||||
private fun SettingsRail(groups: List<String>, chosen: Int, onChoose: (Int) -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Surface(
|
||||
Modifier.width(250.dp).padding(bottom = 12.dp).fillMaxHeight().gsPanelShadow(shape),
|
||||
shape = shape,
|
||||
color = scheme.surfaceContainer,
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(10.dp).verticalScroll(androidx.compose.foundation.rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 14.dp, top = 6.dp, bottom = 10.dp),
|
||||
)
|
||||
groups.forEachIndexed { i, name ->
|
||||
val on = i == chosen
|
||||
GsIconBox(
|
||||
onClick = { onChoose(i) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
|
||||
else GsColors(Color.Transparent, scheme.onSurface),
|
||||
latched = on,
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,19 +446,26 @@ 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, clusterVideo: Boolean) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ExperimentalFeatureRow(
|
||||
title = "Vehicle data",
|
||||
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
|
||||
"channel. Field labels (speed, RPM, fuel...) are carried over from other " +
|
||||
"reverse-engineering projects and unverified against this car - treat them as a " +
|
||||
"guess, not a fact, until you've watched one change for yourself.",
|
||||
title = stringResource(R.string.car_vehicle_data),
|
||||
description = stringResource(R.string.carset_vehicle_data_note),
|
||||
checked = carSensors,
|
||||
) { CarSettings.setCarSensorsEnabled(it) }
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_adaptive_video),
|
||||
description = stringResource(R.string.carset_adaptive_note),
|
||||
checked = adaptiveVideo,
|
||||
) { CarSettings.setAdaptiveVideoEnabled(it) }
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_cluster_video),
|
||||
description = stringResource(R.string.car_cluster_video_note),
|
||||
checked = clusterVideo,
|
||||
) { CarSettings.setClusterVideoEnabled(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +511,7 @@ private fun WebPageSetting() {
|
||||
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Web page", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.web_page), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Surface(
|
||||
onClick = { editing = !editing },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
@@ -385,7 +519,7 @@ private fun WebPageSetting() {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" },
|
||||
url.ifEmpty { stringResource(R.string.carset_web_default) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -394,7 +528,7 @@ private fun WebPageSetting() {
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Shown by the Browser app on the launcher. Applies on the next connection.",
|
||||
stringResource(R.string.carset_web_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -437,22 +571,22 @@ private fun ScreenFit(vehicle: String) {
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Stepper("Top", insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
|
||||
Stepper("Bottom", insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
|
||||
Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Stepper(stringResource(R.string.carset_top), insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
|
||||
Stepper(stringResource(R.string.carset_bottom), insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
|
||||
Text(
|
||||
"Usable height ${480 - insets.top - insets.bottom}px of 480",
|
||||
stringResource(R.string.carset_usable_height, 480 - insets.top - insets.bottom),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(
|
||||
onClick = {
|
||||
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
|
||||
saved = if (ok) "Saved to $vehicle" else "Could not save"
|
||||
saved = if (ok) context.getString(R.string.carset_saved_to, vehicle) else context.getString(R.string.carset_save_failed)
|
||||
},
|
||||
enabled = known,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") }
|
||||
) { Text(if (known) stringResource(R.string.carset_save_vehicle) else stringResource(R.string.carset_no_profile)) }
|
||||
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
||||
}
|
||||
}
|
||||
@@ -464,7 +598,7 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
|
||||
StepButton("−5", Modifier.weight(1f)) { onStep(-5) }
|
||||
StepButton("−1", Modifier.weight(1f)) { onStep(-1) }
|
||||
Text(
|
||||
"${value}px",
|
||||
stringResource(R.string.carset_px, value),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1.3f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
@@ -62,6 +63,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import android.media.session.PlaybackState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -75,6 +78,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
|
||||
@@ -109,6 +113,7 @@ fun CarUi() {
|
||||
val phoneDark by CarEnvironment.phoneDark.collectAsState()
|
||||
val appTheme by CarSettings.appTheme.collectAsState()
|
||||
val uiThemeId by CarSettings.uiTheme.collectAsState()
|
||||
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||
val mediaApp by CarServices.mediaApp.collectAsState()
|
||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
@@ -117,7 +122,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, darkStyle)
|
||||
val base = LocalDensity.current
|
||||
val insets by CarEnvironment.insets.collectAsState()
|
||||
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
|
||||
@@ -221,8 +226,10 @@ fun CarUi() {
|
||||
},
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
// Insets are in video-frame pixels, so convert with the un-scaled density.
|
||||
val pad = with(base) {
|
||||
// Insets are video-frame pixels. Resolve them with the live (size-scaled) density,
|
||||
// which padding() also uses, so the safe-area margin stays one fixed physical size
|
||||
// at every size setting instead of growing with it and eating the screen's sides.
|
||||
val pad = with(LocalDensity.current) {
|
||||
PaddingValues(
|
||||
start = insets.left.toDp(), top = insets.top.toDp(),
|
||||
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
|
||||
@@ -231,12 +238,11 @@ fun CarUi() {
|
||||
val screen = navigator.current
|
||||
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
|
||||
val keyboardVisible by CarKeyboardVisibility.state
|
||||
// Off "Always show", the bar floats over the map rather than taking a row of its
|
||||
// own: hiding it then leaves the map's surface the size it was. Resizing it would
|
||||
// hand the map app a new surface each time, and some go black on that.
|
||||
// The bar always lies over the map and every other screen rather than taking a row
|
||||
// of its own: hiding it then leaves the map's surface the size it was.
|
||||
// Resizing it would hand the map app a new surface each time, and some go black on that.
|
||||
val navBarMode by CarSettings.navBarMode.collectAsState()
|
||||
val appTemplate by CarServices.nav.template.collectAsState()
|
||||
val floatingBar = navBarMode != NavBarMode.SHOW
|
||||
val barMayHide = screen == CarScreen.Home && when (navBarMode) {
|
||||
NavBarMode.SHOW -> false
|
||||
NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate
|
||||
@@ -247,7 +253,8 @@ fun CarUi() {
|
||||
// A call keeps the bar up whatever the setting: End and Mute live on it.
|
||||
val onCall = CarCalls.state.collectAsState().value is CallUiState.Active
|
||||
val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall)
|
||||
val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp
|
||||
// Up to the bar's top edge, so the map's cards and the player end above it.
|
||||
val homeInset = if (barShown) NAV_BAR_HEIGHT else 0.dp
|
||||
// Only the screen that asked: going Home or anywhere else ends it.
|
||||
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
|
||||
Box(Modifier.fillMaxSize().padding(pad)) {
|
||||
@@ -267,39 +274,53 @@ fun CarUi() {
|
||||
// The keyboard takes the nav bar's row instead of squeezing in above it -
|
||||
// nothing on the nav bar is useful mid-type, and the screen is too small to
|
||||
// show both.
|
||||
if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
|
||||
}
|
||||
// Every other screen, and everything that pops up, in a layer over Home rather
|
||||
// than inside its area: full screen grows this layer over the nav bar's row while
|
||||
// Home, and the map's surface in it, keeps its size.
|
||||
// Each new screen fades in while rising a little, once, over a fifth of a second.
|
||||
// Coming from Home the backdrop fades in with it, over the map; between two other
|
||||
// screens it stays put, so the map never flashes through.
|
||||
val lastScreen = remember { arrayOf<CarScreen>(CarScreen.Home) }
|
||||
val fromHome = remember(screen) { lastScreen[0] == CarScreen.Home }
|
||||
SideEffect { lastScreen[0] = screen }
|
||||
val enter = remember(screen) { androidx.compose.animation.core.Animatable(0f) }
|
||||
LaunchedEffect(screen) { enter.animateTo(1f, androidx.compose.animation.core.tween(180)) }
|
||||
if (safetyWarningSeen) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = if (!floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (screen != CarScreen.Home) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { if (fromHome) alpha = enter.value }
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
// The floating bar never hides here, so keep the screen clear of it.
|
||||
.padding(bottom = if (floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp)
|
||||
// The bar never hides here, so keep the screen clear of it. The
|
||||
// player runs on under the see-through bar and keeps clear itself.
|
||||
.padding(bottom = if (!keyboardVisible && !fullscreen && screen != CarScreen.Media) NAV_BAR_HEIGHT else 0.dp)
|
||||
// Keeps touches that miss this screen's controls off the map.
|
||||
.pointerInput(Unit) { detectTapGestures { } },
|
||||
) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> Unit
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
|
||||
CarScreen.Browse -> BrowseAppScreen(navigator)
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
CarScreen.VehicleData -> VehicleDataScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.Messages -> MessagesScreen(screen.packageName)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
Box(
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
if (!fromHome) alpha = enter.value
|
||||
translationY = (1f - enter.value) * 16.dp.toPx()
|
||||
},
|
||||
) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> Unit
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> CompositionLocalProvider(
|
||||
LocalNavBarInset provides if (!keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp,
|
||||
) { mediaApp?.let { MediaScreen(it) { navigator.back() } } }
|
||||
CarScreen.Browse -> BrowseAppScreen(navigator)
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
CarScreen.VehicleData -> VehicleDataScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.Messages -> MessagesScreen(screen.packageName)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,14 +337,15 @@ 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
|
||||
// whatever the screen drew there.
|
||||
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
|
||||
}
|
||||
if (floatingBar || fullscreen) {
|
||||
run {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
if (fullscreen) CarFullscreen.revealed || onCall else barShown,
|
||||
Modifier.align(Alignment.BottomCenter),
|
||||
@@ -341,7 +363,7 @@ fun CarUi() {
|
||||
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
|
||||
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Exit full screen", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.carui_exit_fullscreen), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,7 +379,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,
|
||||
@@ -386,22 +408,20 @@ private fun CarSafetyWizard(onDone: () -> Unit) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
||||
Text(stringResource(R.string.carui_drive_safely), style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Gearslip is unofficial software: it is not made or reviewed by any " +
|
||||
"carmaker, and it can fail without warning.",
|
||||
stringResource(R.string.carui_unofficial),
|
||||
style = ChromeType.body,
|
||||
)
|
||||
Text(
|
||||
"Never interact with this screen while driving. Set the route or the " +
|
||||
"song before you go, then keep your attention on the road.",
|
||||
stringResource(R.string.carui_never_interact),
|
||||
style = ChromeType.body,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("I understand", style = ChromeType.body)
|
||||
Text(stringResource(R.string.carui_understand), style = ChromeType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,7 +452,7 @@ private fun BrowseAppScreen(navigator: CarNavigator) {
|
||||
}
|
||||
val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED
|
||||
if (failed || timedOut) {
|
||||
AppWontStart(status.app ?: "This app", leave)
|
||||
AppWontStart(status.app ?: stringResource(R.string.carui_this_app), leave)
|
||||
return
|
||||
}
|
||||
CarAppStage(CarServices.browse, status, frame, onDisconnect = leave)
|
||||
@@ -448,13 +468,13 @@ private fun AppWontStart(app: String, onBack: () -> Unit) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("$app doesn't work with Gearslip yet.", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.carui_app_unsupported, app), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"It didn't start. Some apps only run on Google's Android Auto.",
|
||||
stringResource(R.string.carui_app_unsupported_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(onClick = onBack, tone = GsTone.Tonal) { Text("Back") }
|
||||
GsButton(onClick = onBack, tone = GsTone.Tonal) { Text(stringResource(R.string.back)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,568 +499,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) {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -76,7 +78,7 @@ fun CarWhatsNew() {
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text(
|
||||
"What's new in ${release.versionName}",
|
||||
stringResource(R.string.whatsnew_title, release.versionName),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
@@ -99,12 +101,12 @@ fun CarWhatsNew() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates)
|
||||
Text(
|
||||
"Show after updates",
|
||||
stringResource(R.string.whatsnew_show_after),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsButton(onClick = close, modifier = Modifier.height(56.dp)) {
|
||||
Text("Got it", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.whatsnew_got_it), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.host.clockTime
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.remainingTime
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* The instrument-cluster screen: the next turn, big enough to read in a glance past the wheel.
|
||||
* The arrow and the distance to it, what to do there, and the time, distance and arrival left
|
||||
* along the bottom. With no route it shows what's playing, quietly, or nothing.
|
||||
*
|
||||
* Always dark, like the dials around it. [marginX] and [marginY] are the pixels the car crops off
|
||||
* the frame, kept clear.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ClusterScreen(marginX: Int, marginY: Int) {
|
||||
// The map app's turn changes a few times a minute: read it once a second.
|
||||
val turn by produceState<ClusterTurn?>(null) {
|
||||
while (true) {
|
||||
value = ClusterTurn.now()
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
CompositionLocalProvider(LocalContentColor provides Color.White) {
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxSize().background(Color.Black).padding(
|
||||
horizontal = with(density) { (marginX / 2).toDp() } + 24.dp,
|
||||
vertical = with(density) { (marginY / 2).toDp() } + 16.dp,
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
// Sized off the height: clusters run from short strips to near squares.
|
||||
val unit = maxHeight / 12
|
||||
val t = turn
|
||||
if (t == null) {
|
||||
NowPlayingLine(Modifier.align(Alignment.Center))
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
|
||||
Spacer(Modifier.width(unit))
|
||||
Text(
|
||||
t.distance,
|
||||
fontSize = with(density) { (unit * 2.2f).toSp() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (t.what.isNotEmpty()) {
|
||||
Text(
|
||||
t.what,
|
||||
fontSize = with(density) { (unit * 1.1f).toSp() },
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (t.trip.isNotEmpty()) {
|
||||
Text(
|
||||
t.trip,
|
||||
fontSize = with(density) { (unit * 0.85f).toSp() },
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NowPlayingLine(modifier: Modifier) {
|
||||
val now = CarServices.mediaOrNull()?.now?.value
|
||||
if (now == null || now.title.isEmpty()) return
|
||||
Text(
|
||||
listOf(now.title, now.artist).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
modifier,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/** What the cluster shows of a route, read from [NavTurn] and the trip's estimate to the destination. */
|
||||
private class ClusterTurn(
|
||||
val icon: androidx.car.app.model.CarIcon?,
|
||||
val distance: String,
|
||||
val what: String,
|
||||
val trip: String,
|
||||
) {
|
||||
companion object {
|
||||
fun now(): ClusterTurn? {
|
||||
val turn = NavTurn.current() ?: return null
|
||||
if (turn.rerouting) return null
|
||||
val step = turn.step
|
||||
val cue = step.cue?.toString().orEmpty()
|
||||
val what = cue.ifEmpty { step.road?.toString().orEmpty() }
|
||||
val estimate = CarServices.navOrNull()?.trip?.value?.destinationTravelEstimates?.firstOrNull()
|
||||
val trip = estimate?.let { e ->
|
||||
listOf(
|
||||
e.remainingTimeSeconds.takeIf { it >= 0 }?.let { remainingTime(it) }.orEmpty(),
|
||||
e.remainingDistance.display(),
|
||||
e.arrivalTimeAtDestination.clockTime(),
|
||||
).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
}.orEmpty()
|
||||
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.speech.tts.TextToSpeech
|
||||
@@ -121,7 +122,7 @@ object DirectionsVoice {
|
||||
say(instruction)
|
||||
stage = Stage.DONE
|
||||
} else {
|
||||
say("In ${spoken(turn.distance)}, $instruction")
|
||||
say(ahead(turn.distance, instruction))
|
||||
stage = if (meters != null && meters > PREPARE_FROM_METERS) Stage.FAR else Stage.PREPARED
|
||||
}
|
||||
return
|
||||
@@ -132,7 +133,7 @@ object DirectionsVoice {
|
||||
stage = Stage.DONE
|
||||
}
|
||||
stage == Stage.FAR && meters != null && meters <= PREPARE_AT_METERS -> {
|
||||
say("In ${spoken(turn.distance)}, $instruction")
|
||||
say(ahead(turn.distance, instruction))
|
||||
stage = Stage.PREPARED
|
||||
}
|
||||
}
|
||||
@@ -140,67 +141,79 @@ object DirectionsVoice {
|
||||
|
||||
/** What to do, in words: built from the manoeuvre when the app names one, else the app's own text. */
|
||||
private fun instruction(step: Step): String? {
|
||||
val res = appContext?.resources ?: return null
|
||||
val road = step.road.text()
|
||||
val onto = if (road.isBlank()) "" else " onto $road"
|
||||
val toward = if (road.isBlank()) "" else " toward $road"
|
||||
// Each manoeuvre is a whole sentence, with and without the road, so a translation can
|
||||
// put the road wherever its language wants it.
|
||||
fun say(plain: Int, withRoad: Int) =
|
||||
if (road.isBlank() || withRoad == 0) res.getString(plain) else res.getString(withRoad, road)
|
||||
val maneuver = step.maneuver
|
||||
val built = when (maneuver?.type) {
|
||||
Maneuver.TYPE_TURN_SLIGHT_LEFT -> "Turn slightly left$onto"
|
||||
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> "Turn slightly right$onto"
|
||||
Maneuver.TYPE_TURN_NORMAL_LEFT -> "Turn left$onto"
|
||||
Maneuver.TYPE_TURN_NORMAL_RIGHT -> "Turn right$onto"
|
||||
Maneuver.TYPE_TURN_SHARP_LEFT -> "Turn sharply left$onto"
|
||||
Maneuver.TYPE_TURN_SHARP_RIGHT -> "Turn sharply right$onto"
|
||||
Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> "Make a U-turn"
|
||||
Maneuver.TYPE_KEEP_LEFT -> "Keep left$toward"
|
||||
Maneuver.TYPE_KEEP_RIGHT -> "Keep right$toward"
|
||||
Maneuver.TYPE_FORK_LEFT -> "Keep left at the fork$toward"
|
||||
Maneuver.TYPE_FORK_RIGHT -> "Keep right at the fork$toward"
|
||||
Maneuver.TYPE_TURN_SLIGHT_LEFT -> say(R.string.dir_slight_left, R.string.dir_slight_left_onto)
|
||||
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> say(R.string.dir_slight_right, R.string.dir_slight_right_onto)
|
||||
Maneuver.TYPE_TURN_NORMAL_LEFT -> say(R.string.dir_left, R.string.dir_left_onto)
|
||||
Maneuver.TYPE_TURN_NORMAL_RIGHT -> say(R.string.dir_right, R.string.dir_right_onto)
|
||||
Maneuver.TYPE_TURN_SHARP_LEFT -> say(R.string.dir_sharp_left, R.string.dir_sharp_left_onto)
|
||||
Maneuver.TYPE_TURN_SHARP_RIGHT -> say(R.string.dir_sharp_right, R.string.dir_sharp_right_onto)
|
||||
Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> say(R.string.dir_u_turn, 0)
|
||||
Maneuver.TYPE_KEEP_LEFT -> say(R.string.dir_keep_left, R.string.dir_keep_left_toward)
|
||||
Maneuver.TYPE_KEEP_RIGHT -> say(R.string.dir_keep_right, R.string.dir_keep_right_toward)
|
||||
Maneuver.TYPE_FORK_LEFT -> say(R.string.dir_fork_left, R.string.dir_fork_left_toward)
|
||||
Maneuver.TYPE_FORK_RIGHT -> say(R.string.dir_fork_right, R.string.dir_fork_right_toward)
|
||||
Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT,
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> "Take the ramp on the left$onto"
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> say(R.string.dir_ramp_left, R.string.dir_ramp_left_onto)
|
||||
Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT,
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> "Take the ramp on the right$onto"
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> "Take the exit on the left$toward"
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> "Take the exit on the right$toward"
|
||||
Maneuver.TYPE_MERGE_LEFT -> "Merge left$onto"
|
||||
Maneuver.TYPE_MERGE_RIGHT -> "Merge right$onto"
|
||||
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> "Merge$onto"
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> say(R.string.dir_ramp_right, R.string.dir_ramp_right_onto)
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> say(R.string.dir_exit_left, R.string.dir_exit_left_toward)
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> say(R.string.dir_exit_right, R.string.dir_exit_right_toward)
|
||||
Maneuver.TYPE_MERGE_LEFT -> say(R.string.dir_merge_left, R.string.dir_merge_left_onto)
|
||||
Maneuver.TYPE_MERGE_RIGHT -> say(R.string.dir_merge_right, R.string.dir_merge_right_onto)
|
||||
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> say(R.string.dir_merge, R.string.dir_merge_onto)
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE,
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE ->
|
||||
maneuver.roundaboutExitNumber.takeIf { it in 1..ORDINALS.size }
|
||||
?.let { "At the roundabout, take the ${ORDINALS[it - 1]} exit$onto" }
|
||||
?: "Go through the roundabout$onto"
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> "Enter the roundabout"
|
||||
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> "Exit the roundabout$onto"
|
||||
Maneuver.TYPE_STRAIGHT -> "Continue straight$onto"
|
||||
Maneuver.TYPE_NAME_CHANGE -> "Continue$onto"
|
||||
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> "Take the ferry"
|
||||
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> "Take the train"
|
||||
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> "Your destination is ahead"
|
||||
Maneuver.TYPE_DESTINATION_LEFT -> "Your destination is on the left"
|
||||
Maneuver.TYPE_DESTINATION_RIGHT -> "Your destination is on the right"
|
||||
?.let { res.getString(ORDINALS[it - 1]) }
|
||||
?.let { exit ->
|
||||
if (road.isBlank()) res.getString(R.string.dir_roundabout_take, exit)
|
||||
else res.getString(R.string.dir_roundabout_take_onto, exit, road)
|
||||
}
|
||||
?: say(R.string.dir_roundabout_through, R.string.dir_roundabout_through_onto)
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> say(R.string.dir_roundabout_enter, 0)
|
||||
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> say(R.string.dir_roundabout_exit, R.string.dir_roundabout_exit_onto)
|
||||
Maneuver.TYPE_STRAIGHT -> say(R.string.dir_straight, R.string.dir_straight_onto)
|
||||
Maneuver.TYPE_NAME_CHANGE -> say(R.string.dir_continue, R.string.dir_continue_onto)
|
||||
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> say(R.string.dir_ferry, 0)
|
||||
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> say(R.string.dir_train, 0)
|
||||
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> say(R.string.dir_destination, 0)
|
||||
Maneuver.TYPE_DESTINATION_LEFT -> say(R.string.dir_destination_left, 0)
|
||||
Maneuver.TYPE_DESTINATION_RIGHT -> say(R.string.dir_destination_right, 0)
|
||||
else -> null
|
||||
}
|
||||
return built ?: step.cue.text().ifBlank { null }
|
||||
}
|
||||
|
||||
/** "In 300 meters, turn left": the distance ahead of a turn that isn't close yet. */
|
||||
private fun ahead(d: Distance, instruction: String): String =
|
||||
appContext?.getString(R.string.dir_in_distance, spoken(d), instruction) ?: instruction
|
||||
|
||||
/** The app's own rounded distance, read the way a person would say it. */
|
||||
private fun spoken(d: Distance): String {
|
||||
val res = appContext?.resources ?: return ""
|
||||
val n = d.displayDistance
|
||||
val number = if (n % 1.0 == 0.0) n.toInt().toString() else "%.1f".format(n)
|
||||
val one = n == 1.0
|
||||
val whole = n % 1.0 == 0.0
|
||||
val number = if (whole) n.toInt().toString() else "%.1f".format(n)
|
||||
return when (d.displayUnit) {
|
||||
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> if (one) "1 kilometer" else "$number kilometers"
|
||||
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 ->
|
||||
if (whole) res.getQuantityString(R.plurals.dir_km, n.toInt(), number) else res.getString(R.string.dir_km_decimal, number)
|
||||
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> when (n) {
|
||||
0.25 -> "a quarter mile"
|
||||
0.5 -> "half a mile"
|
||||
0.75 -> "three quarters of a mile"
|
||||
1.0 -> "1 mile"
|
||||
else -> "$number miles"
|
||||
0.25 -> res.getString(R.string.dir_quarter_mile)
|
||||
0.5 -> res.getString(R.string.dir_half_mile)
|
||||
0.75 -> res.getString(R.string.dir_three_quarter_mile)
|
||||
else -> if (whole) res.getQuantityString(R.plurals.dir_miles, n.toInt(), number) else res.getString(R.string.dir_miles_decimal, number)
|
||||
}
|
||||
Distance.UNIT_FEET -> "$number feet"
|
||||
Distance.UNIT_YARDS -> "$number yards"
|
||||
else -> "${n.roundToInt()} meters"
|
||||
Distance.UNIT_FEET -> res.getQuantityString(R.plurals.dir_feet, n.toInt(), number)
|
||||
Distance.UNIT_YARDS -> res.getQuantityString(R.plurals.dir_yards, n.toInt(), number)
|
||||
else -> res.getQuantityString(R.plurals.dir_meters, n.roundToInt(), n.roundToInt())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,5 +360,8 @@ object DirectionsVoice {
|
||||
private const val LEAD_MS = 100L
|
||||
private const val HOLD_MS = 2_000L
|
||||
private const val SYNTH_TIMEOUT_S = 10L
|
||||
private val ORDINALS = listOf("first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth")
|
||||
private val ORDINALS = listOf(
|
||||
R.string.dir_ordinal_1, R.string.dir_ordinal_2, R.string.dir_ordinal_3, R.string.dir_ordinal_4,
|
||||
R.string.dir_ordinal_5, R.string.dir_ordinal_6, R.string.dir_ordinal_7, R.string.dir_ordinal_8,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
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.aspectRatio
|
||||
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(Modifier.padding(bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (media.canGoUp) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.media_up), media::up, size = 44.dp, shape = CircleShape)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
}
|
||||
browse.trail.lastOrNull()?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
browse.loading -> Notice(stringResource(R.string.media_loading), null)
|
||||
browse.unavailable -> Notice(
|
||||
stringResource(R.string.media_keeps_library, appLabel),
|
||||
stringResource(R.string.media_browse_start),
|
||||
)
|
||||
browse.failed -> Notice(stringResource(R.string.media_load_failed), null)
|
||||
browse.entries.isEmpty() -> Notice(stringResource(R.string.media_nothing_here), null)
|
||||
else -> BrowseList(browse.entries, media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A list laid out the way the app asked: under its section headings, covers where it asked for a
|
||||
* grid and rows everywhere else. A grid section among others is a shelf that scrolls sideways; a
|
||||
* list that is one grid and nothing else wraps into rows of covers instead.
|
||||
*/
|
||||
@Composable
|
||||
private fun BrowseList(entries: List<MediaEntry>, media: CarMedia) {
|
||||
val sections = remember(entries) { browseSections(entries) }
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val onlyGrid = sections.size == 1 && sections[0].grid && sections[0].heading == null
|
||||
val columns = ((maxWidth + SHELF_GAP) / (SHELF_COVER + SHELF_GAP)).toInt().coerceAtLeast(2)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
sections.forEachIndexed { i, section ->
|
||||
val heading = section.heading
|
||||
if (heading != null && sections.getOrNull(i - 1)?.heading != heading) {
|
||||
item(key = "heading $i") {
|
||||
Text(
|
||||
heading,
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = if (i == 0) 0.dp else 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!section.grid -> items(section.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
|
||||
onlyGrid -> items(section.entries.chunked(columns)) { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(SHELF_GAP)) {
|
||||
row.forEach { CoverTile(it, media, Modifier.weight(1f)) }
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
else -> item(key = "shelf $i") {
|
||||
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(SHELF_GAP)) {
|
||||
items(section.entries, key = { it.id }) { CoverTile(it, media, Modifier.width(SHELF_COVER)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SHELF_COVER = 132.dp
|
||||
private val SHELF_GAP = 14.dp
|
||||
|
||||
/** A run of entries under one heading that are all covers or all rows. */
|
||||
private class BrowseSection(val heading: String?, val grid: Boolean, val entries: List<MediaEntry>)
|
||||
|
||||
private fun browseSections(entries: List<MediaEntry>): List<BrowseSection> {
|
||||
val out = mutableListOf<BrowseSection>()
|
||||
var run = mutableListOf<MediaEntry>()
|
||||
for (entry in entries) {
|
||||
val last = run.lastOrNull()
|
||||
if (last != null && (last.group != entry.group || last.grid != entry.grid)) {
|
||||
out += BrowseSection(last.group, last.grid, run)
|
||||
run = mutableListOf()
|
||||
}
|
||||
run += entry
|
||||
}
|
||||
run.lastOrNull()?.let { out += BrowseSection(it.group, it.grid, run) }
|
||||
return out
|
||||
}
|
||||
|
||||
/** One entry as a cover with its name under it: what the app's grids are made of. */
|
||||
@Composable
|
||||
private fun CoverTile(entry: MediaEntry, media: CarMedia, modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||
}
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Column(modifier.clip(shape).clickable { media.select(entry) }) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.gsPanelShadow(shape)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
?: Icon(
|
||||
if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote, null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxSize(0.35f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
entry.title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 8.dp, start = 2.dp),
|
||||
)
|
||||
if (entry.subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 2.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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(
|
||||
stringResource(R.string.media_nothing_queued),
|
||||
stringResource(if (now.title.isEmpty()) R.string.media_queue_empty_idle else R.string.media_queue_empty_playing),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
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.surfaceContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RowArt(icon, MediaIcons.MusicNote)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||
Text(track.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (track.subtitle.isNotEmpty()) {
|
||||
Text(track.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.PlayArrow, stringResource(R.string.media_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.surfaceContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (entry.subtitle.isNotEmpty()) {
|
||||
Text(entry.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (entry.browsable && entry.playable) {
|
||||
GsIconButton(Icons.Filled.PlayArrow, stringResource(R.string.media_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(56.dp).clip(RoundedCornerShape(12.dp)).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 split into the map and a side panel: outlined while the panel is closed ("put the
|
||||
* player beside the map"), filled in while it's open ("close it"), on the side it opens on.
|
||||
*/
|
||||
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4zM16,6h4v12h-4z", PathFillType.EvenOdd)
|
||||
val DockRightOpen = icon("DockRightOpen", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
||||
val DockLeft = icon("DockLeft", "M2,4h20v16H2zM4,6h4v12H4zM10,6h10v12H10z", PathFillType.EvenOdd)
|
||||
val DockLeftOpen = icon("DockLeftOpen", "M2,4h20v16H2zM10,6h10v12H10z", 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()
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
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.ui.unit.sp
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -19,6 +24,7 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -49,6 +55,9 @@ 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.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -93,21 +102,22 @@ 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)
|
||||
|
||||
// The art's colour behind everything, header included; the buttons keep their own.
|
||||
Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) {
|
||||
// The theme's background, with the cover's colours glowing faintly behind the cover on the left.
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
if (phase == CarMedia.Phase.READY && !searching) AlbumGlow(art, Modifier.fillMaxSize(), wide = true)
|
||||
if (phase != CarMedia.Phase.READY) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().padding(bottom = LocalNavBarInset.current)) {
|
||||
AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
|
||||
when (phase) {
|
||||
CarMedia.Phase.REJECTED -> when (rejection) {
|
||||
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
|
||||
"${app.label} keeps its library to itself.",
|
||||
"Gearslip can still control it once it has Notification access on the phone. " +
|
||||
"Allow it under Settings > Notifications > Notification access.",
|
||||
action = "Open on phone" to {
|
||||
stringResource(R.string.media_keeps_library, app.label),
|
||||
stringResource(R.string.media_needs_access),
|
||||
action = stringResource(R.string.media_open_on_phone) to {
|
||||
val app = context.applicationContext
|
||||
runCatching {
|
||||
app.startActivity(
|
||||
@@ -117,24 +127,23 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
},
|
||||
)
|
||||
CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice(
|
||||
"${app.label} isn't running.",
|
||||
"${app.label} plays through Gearslip's own player, but only while it's " +
|
||||
"open on the phone. Open it once and it keeps playing here in the car.",
|
||||
action = "Open ${app.label}" to { media.launchAndConnect(app) },
|
||||
stringResource(R.string.media_not_running, app.label),
|
||||
stringResource(R.string.media_not_running_body, app.label),
|
||||
action = stringResource(R.string.media_open_app, app.label) to { media.launchAndConnect(app) },
|
||||
)
|
||||
CarMedia.Rejection.REFUSED -> Notice(
|
||||
"${app.label} would not let Gearslip browse it.",
|
||||
"Some media apps only accept Google's own host.",
|
||||
stringResource(R.string.media_refused, app.label),
|
||||
stringResource(R.string.media_refused_body),
|
||||
)
|
||||
}
|
||||
else -> Notice("Connecting to ${app.label}…", null)
|
||||
else -> Notice(stringResource(R.string.media_connecting, app.label), null)
|
||||
}
|
||||
}
|
||||
return@Surface
|
||||
}
|
||||
|
||||
if (searching) {
|
||||
SearchPanel(
|
||||
Box(Modifier.padding(bottom = LocalNavBarInset.current)) { SearchPanel(
|
||||
appLabel = app.label,
|
||||
media = media,
|
||||
canPickForYou = now.canSearch,
|
||||
@@ -152,52 +161,91 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
tab = Tab.NOW_PLAYING
|
||||
},
|
||||
onClose = { searching = false },
|
||||
)
|
||||
) }
|
||||
return@Surface
|
||||
}
|
||||
|
||||
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
||||
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
||||
// top of the right column, over whatever the tab shows.
|
||||
Row(
|
||||
Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Column(Modifier.weight(0.8f).fillMaxHeight()) {
|
||||
AppIdentity(app, onExit)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (tab == Tab.NOW_PLAYING) {
|
||||
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
|
||||
} else {
|
||||
CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth())
|
||||
// The page runs down under the see-through nav bar, so the cover's glow carries on behind
|
||||
// it; the content stops at the bar's top edge. Lists run right to it; the rest keeps a gap.
|
||||
val barTop = LocalNavBarInset.current
|
||||
val barInset = barTop + 8.dp
|
||||
val tabs: @Composable (Modifier) -> Unit = { tabModifier ->
|
||||
MediaTab(stringResource(R.string.media_now_playing), tab == Tab.NOW_PLAYING, tabModifier) { tab = Tab.NOW_PLAYING }
|
||||
MediaTab(stringResource(R.string.media_up_next), tab == Tab.QUEUE, tabModifier) { tab = Tab.QUEUE }
|
||||
MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, tabModifier) { tab = Tab.BROWSE }
|
||||
MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, tabModifier) { tab = Tab.LYRICS }
|
||||
}
|
||||
val searchButton: @Composable () -> Unit = {
|
||||
if (now.canSearch || media.canSearchLibrary) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Search,
|
||||
stringResource(R.string.media_search, app.label),
|
||||
{ searching = true },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
size = 44.dp,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
)
|
||||
}
|
||||
}
|
||||
val tabContent: @Composable (Modifier) -> Unit = { contentModifier ->
|
||||
Box(contentModifier) {
|
||||
when (tab) {
|
||||
Tab.NOW_PLAYING -> StageControls(now, media, Modifier.fillMaxSize().padding(bottom = barInset))
|
||||
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize().padding(bottom = barTop))
|
||||
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize().padding(bottom = barTop))
|
||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize().padding(bottom = barInset))
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1.2f).fillMaxHeight()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
|
||||
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
||||
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
||||
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
||||
if (now.canSearch || media.canSearchLibrary) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Search,
|
||||
"Search ${app.label}",
|
||||
{ searching = true },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
size = 44.dp,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
)
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp)) {
|
||||
// A small screen, or a big one at Extra large size, has no room for two columns: the
|
||||
// app and the tabs share one bar, and a list takes the whole width. The nav bar keeps
|
||||
// the controls, so nothing is lost.
|
||||
if (maxWidth < NARROW_WIDTH || maxHeight - barInset < NARROW_HEIGHT) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AppIdentity(app, onExit, Modifier.widthIn(max = 180.dp))
|
||||
Row(
|
||||
Modifier.weight(1f).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) { tabs(Modifier) }
|
||||
searchButton()
|
||||
}
|
||||
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||
if (tab == Tab.NOW_PLAYING) NowPlayingPage(now, media, Modifier.weight(1f).fillMaxWidth().padding(bottom = barInset), padded = false)
|
||||
else tabContent(Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
||||
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
||||
// top of the right column, over whatever the tab shows.
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Column(Modifier.weight(0.8f).fillMaxHeight().padding(bottom = barInset)) {
|
||||
AppIdentity(app, onExit)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (tab == Tab.NOW_PLAYING) {
|
||||
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
|
||||
} else {
|
||||
CompactPlayer(now, media, art, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (tab) {
|
||||
Tab.NOW_PLAYING -> NowPlayingControls(now, media, Modifier.fillMaxSize())
|
||||
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
|
||||
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
|
||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
|
||||
Column(Modifier.weight(1.2f).fillMaxHeight()) {
|
||||
// The tabs share the row evenly; a long word (a translation, a big text size)
|
||||
// shrinks to fit its tab instead.
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
tabs(Modifier.weight(1f))
|
||||
searchButton()
|
||||
}
|
||||
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
tabContent(Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +260,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp)
|
||||
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit, size = 44.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
icon?.let {
|
||||
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
|
||||
@@ -272,10 +320,10 @@ private fun SearchPanel(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE)
|
||||
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onClose, size = ICON_BUTTON_SIZE)
|
||||
SearchField(
|
||||
query,
|
||||
"Search $appLabel",
|
||||
stringResource(R.string.media_search, appLabel),
|
||||
Modifier.weight(1f).clickable { typing = true },
|
||||
onClear = {
|
||||
query = ""
|
||||
@@ -286,12 +334,12 @@ private fun SearchPanel(
|
||||
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
val entries = current?.entries
|
||||
when {
|
||||
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.")
|
||||
trimmed.isEmpty() -> SearchNote(stringResource(R.string.media_search_hint))
|
||||
unsupported && canPickForYou ->
|
||||
SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.")
|
||||
unsupported -> SearchNote("$appLabel can't search from the car.")
|
||||
entries == null -> SearchNote("Searching $appLabel…")
|
||||
entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".")
|
||||
SearchNote(stringResource(R.string.media_search_no_list, appLabel))
|
||||
unsupported -> SearchNote(stringResource(R.string.media_search_unsupported, appLabel))
|
||||
entries == null -> SearchNote(stringResource(R.string.media_searching, appLabel))
|
||||
entries.isEmpty() -> SearchNote(stringResource(R.string.media_search_nothing, appLabel, trimmed))
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(entries) { entry ->
|
||||
EntryRow(entry, media) {
|
||||
@@ -349,8 +397,8 @@ private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: (
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"."
|
||||
else "Searching $appLabel for \"${search.query}\"…",
|
||||
if (search.ignored) stringResource(R.string.media_search_ignored, appLabel, search.query)
|
||||
else stringResource(R.string.media_searching_for, appLabel, search.query),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -375,14 +423,17 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
autoSize = TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = MaterialTheme.typography.titleSmall.fontSize),
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with
|
||||
* the current line in the accent colour; unsynced ones are plain scrolling text.
|
||||
* The song's lyrics, fetched when this tab opens. Synced lyrics follow playback: the line being
|
||||
* sung sits a little above the middle in bold, lines already sung dim, and the list fades out at
|
||||
* both edges instead of cutting a line in half. Word-timed lyrics also light up each word as it's
|
||||
* sung. Unsynced ones are plain scrolling text.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LyricsPanel(
|
||||
@@ -393,43 +444,89 @@ internal fun LyricsPanel(
|
||||
val context = LocalContext.current
|
||||
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
||||
value = LyricsState.Loading
|
||||
value = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
|
||||
?.let { LyricsState.Found(it) } ?: LyricsState.Missing
|
||||
}
|
||||
val list = rememberLazyListState()
|
||||
|
||||
// Ticks with playback so the highlighted line moves; only synced lyrics need it.
|
||||
var position by remember { mutableLongStateOf(0L) }
|
||||
LaunchedEffect(now) {
|
||||
while (true) {
|
||||
position = now.currentPosition()
|
||||
kotlinx.coroutines.delay(300)
|
||||
val found = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
|
||||
value = found?.let { LyricsState.Found(it) } ?: LyricsState.Missing
|
||||
// Cached lyrics show at once; if a source now has better ones, they replace them.
|
||||
if (found != null) {
|
||||
Lyrics.upgrade(context, now.title, now.artist, now.album, now.durationMs, found)
|
||||
?.let { value = LyricsState.Found(it) }
|
||||
}
|
||||
}
|
||||
|
||||
when (val state = lyrics) {
|
||||
LyricsState.Loading -> Notice("Looking for lyrics…", null)
|
||||
LyricsState.Loading -> Notice(stringResource(R.string.media_looking_lyrics), null)
|
||||
LyricsState.Missing -> Notice(
|
||||
if (now.title.isEmpty()) "Nothing playing." else "No lyrics found for this track.",
|
||||
stringResource(if (now.title.isEmpty()) R.string.media_nothing_playing_dot else R.string.media_no_lyrics),
|
||||
null,
|
||||
)
|
||||
is LyricsState.Found -> {
|
||||
val lines = state.result.lines
|
||||
val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1
|
||||
LaunchedEffect(current) {
|
||||
if (current >= 0) list.animateScrollToItem((current - 1).coerceAtLeast(0))
|
||||
is LyricsState.Found -> Column(modifier) {
|
||||
if (state.result.synced) SyncedLyrics(now, state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
|
||||
else PlainLyrics(state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
|
||||
// Debug builds name the service the lyrics came from, to see which one answers.
|
||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
|
||||
val from = state.result.source.ifEmpty { "an older cache" }
|
||||
val kind = when {
|
||||
state.result.lines.any { it.words.isNotEmpty() } -> "word-timed"
|
||||
state.result.synced -> "synced"
|
||||
else -> "plain"
|
||||
}
|
||||
Text(
|
||||
"From $from · $kind",
|
||||
Modifier.padding(top = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
LazyColumn(modifier, state = list, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
itemsIndexed(lines) { index, line ->
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncedLyrics(
|
||||
now: NowPlaying,
|
||||
result: LyricsResult,
|
||||
lineStyle: androidx.compose.ui.text.TextStyle,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val lines = result.lines
|
||||
val wordTimed = lines.any { it.words.isNotEmpty() }
|
||||
// Every frame while words are filling in, so a word doesn't light up in steps; otherwise a
|
||||
// few times a second is enough to move the line along.
|
||||
var position by remember { mutableLongStateOf(now.currentPosition()) }
|
||||
LaunchedEffect(now, wordTimed) {
|
||||
while (true) {
|
||||
position = now.currentPosition()
|
||||
if (wordTimed && now.state == PlaybackState.STATE_PLAYING) androidx.compose.runtime.withFrameMillis { }
|
||||
else delay(300)
|
||||
}
|
||||
}
|
||||
// Only the line changing recomposes the list; the moving position is read by the one line.
|
||||
val current by remember(lines) {
|
||||
androidx.compose.runtime.derivedStateOf { lines.indexOfLast { it.timeMs <= position } }
|
||||
}
|
||||
val list = rememberLazyListState()
|
||||
LaunchedEffect(current) {
|
||||
if (current >= 0) list.animateScrollToItem(current)
|
||||
}
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val sung = scheme.onSurfaceVariant.copy(alpha = 0.55f)
|
||||
BoxWithConstraints(modifier.fadingEdges()) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
state = list,
|
||||
// The padding lets the first line start, and the last line end, near the middle.
|
||||
contentPadding = PaddingValues(top = maxHeight * 0.32f, bottom = maxHeight * 0.6f),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
itemsIndexed(lines) { index, line ->
|
||||
if (index == current) {
|
||||
CurrentLine(line, { position }, lineStyle)
|
||||
} else {
|
||||
Text(
|
||||
line.text.ifEmpty { " " },
|
||||
style = lineStyle,
|
||||
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
|
||||
color = when {
|
||||
!state.result.synced -> MaterialTheme.colorScheme.onSurface
|
||||
index == current -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
color = if (index < current) sung else scheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -437,6 +534,72 @@ internal fun LyricsPanel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The line being sung, in bold and a size up. With word timings, words already sung are in the
|
||||
* accent colour, the one being sung fills in letter by letter, and the rest wait dimmed.
|
||||
*/
|
||||
@Composable
|
||||
private fun CurrentLine(line: app.seb3thehacker.gearslip.media.LyricLine, position: () -> Long, lineStyle: androidx.compose.ui.text.TextStyle) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val style = lineStyle.copy(fontWeight = FontWeight.Bold, fontSize = lineStyle.fontSize * 1.15f)
|
||||
if (line.words.isEmpty()) {
|
||||
Text(line.text.ifEmpty { " " }, style = style, color = scheme.primary)
|
||||
return
|
||||
}
|
||||
val lit = scheme.primary
|
||||
val waiting = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
val now = position()
|
||||
val text = androidx.compose.ui.text.buildAnnotatedString {
|
||||
line.words.forEachIndexed { i, word ->
|
||||
// The line's first word has no leading space to trim, the last no trailing one.
|
||||
val shown = if (i == line.words.lastIndex) word.text.trimEnd() else word.text
|
||||
val progress = when {
|
||||
now >= word.endMs -> 1f
|
||||
now <= word.startMs -> 0f
|
||||
else -> (now - word.startMs).toFloat() / (word.endMs - word.startMs).coerceAtLeast(1)
|
||||
}
|
||||
val letters = shown.trimEnd().length
|
||||
val filled = progress * letters
|
||||
shown.forEachIndexed { c, ch ->
|
||||
val amount = (filled - c).coerceIn(0f, 1f)
|
||||
withStyle(androidx.compose.ui.text.SpanStyle(color = androidx.compose.ui.graphics.lerp(waiting, lit, amount))) {
|
||||
append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(text, style = style)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlainLyrics(result: LyricsResult, lineStyle: androidx.compose.ui.text.TextStyle, modifier: Modifier) {
|
||||
LazyColumn(
|
||||
modifier.fadingEdges(),
|
||||
contentPadding = PaddingValues(vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(result.lines) { line ->
|
||||
Text(line.text.ifEmpty { " " }, style = lineStyle, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fades the top and bottom edges out, so a line scrolling past doesn't end in a hard cut. */
|
||||
private fun Modifier.fadingEdges(): Modifier = this
|
||||
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
0f to androidx.compose.ui.graphics.Color.Transparent,
|
||||
0.14f to androidx.compose.ui.graphics.Color.Black,
|
||||
0.86f to androidx.compose.ui.graphics.Color.Black,
|
||||
1f to androidx.compose.ui.graphics.Color.Transparent,
|
||||
),
|
||||
blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
|
||||
private sealed interface LyricsState {
|
||||
data object Loading : LyricsState
|
||||
data object Missing : LyricsState
|
||||
@@ -444,7 +607,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,
|
||||
@@ -467,31 +630,103 @@ private fun Notice(title: String, detail: String?, action: Pair<String, () -> Un
|
||||
// --- now playing -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The now-playing view on its own, for places with no tabs around it (an app's own
|
||||
* now-playing template): the cover on the left, [NowPlayingControls] on the right.
|
||||
* The now-playing view on its own: an app's own now-playing template, or the Now playing tab on a
|
||||
* small screen. The cover on the left and [NowPlayingControls] on the right where there's room;
|
||||
* narrower, the cover sits above the controls, and only when they still fit under it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Row(
|
||||
modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight())
|
||||
NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight())
|
||||
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier, padded: Boolean = true) {
|
||||
BoxWithConstraints(if (padded) modifier.padding(horizontal = 24.dp, vertical = 8.dp) else modifier) {
|
||||
val art = rememberArt(now)
|
||||
if (maxWidth >= 520.dp && maxHeight >= 260.dp) {
|
||||
val artWidth = minOf(maxHeight, (maxWidth - 24.dp) * 0.4f)
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
CoverArt(art, Modifier.width(artWidth).fillMaxHeight())
|
||||
NowPlayingControls(now, media, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
} else {
|
||||
val artHeight = if (maxHeight >= 400.dp) minOf(maxHeight * 0.25f, 160.dp) else 0.dp
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (artHeight > 0.dp) CoverArt(art, Modifier.fillMaxWidth().height(artHeight))
|
||||
NowPlayingControls(now, media, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The cover, as big a square as its space allows, centred in it. */
|
||||
@Composable
|
||||
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
|
||||
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier, alignment: Alignment = Alignment.Center) {
|
||||
BoxWithConstraints(modifier, contentAlignment = alignment) {
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
|
||||
shape = shape,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.size(minOf(maxWidth, maxHeight)).gsPanelShadow(shape),
|
||||
) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Now playing beside the big cover: the title, artist and seek bar along the top, and the
|
||||
* controls in two rows under them, as big as the height left over allows - the transport, then
|
||||
* like, shuffle, repeat and the app's own buttons.
|
||||
*/
|
||||
@Composable
|
||||
private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
BoxWithConstraints(modifier) {
|
||||
// Short of height (a small screen, or Extra large size), the title steps down a size.
|
||||
val compact = maxHeight < 360.dp
|
||||
val showTimes = maxHeight >= 260.dp
|
||||
val titleRoom = maxHeight * 0.4f
|
||||
Column(Modifier.fillMaxSize().padding(start = 4.dp, end = 4.dp, top = if (compact) 6.dp else 16.dp, bottom = 4.dp)) {
|
||||
// The title gives up its height first and scrolls, so the controls keep theirs.
|
||||
Column(Modifier.heightIn(max = titleRoom).verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||
style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
minFontSize = if (compact) 20.sp else 22.sp,
|
||||
maxFontSize = MaterialTheme.typography.displaySmall.fontSize,
|
||||
stepSize = 2.sp,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||
if (now.title.isEmpty() && now.error == null) {
|
||||
Text(
|
||||
stringResource(R.string.media_press_play),
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(top = if (compact) 4.dp else 12.dp), showTimes = showTimes)
|
||||
// What's left goes to the buttons: play takes about half of it, the row below a third,
|
||||
// and neither grows past what a row of them fits across.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
val gap = (maxHeight * 0.08f).coerceIn(6.dp, 20.dp)
|
||||
val play = minOf(maxHeight * 0.5f, maxWidth / 3.2f, 128.dp).coerceAtLeast(56.dp)
|
||||
val mode = minOf(maxHeight * 0.32f, maxWidth / 6.5f, 80.dp).coerceAtLeast(44.dp)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(gap), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
TransportRow(now, media, skip = play / 1.5f, playScale = 1.5f, modifier = Modifier.fillMaxWidth())
|
||||
PlayModeRow(now, media, size = mode, modifier = Modifier.fillMaxWidth(), maxOther = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,200 +737,80 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||
*/
|
||||
@Composable
|
||||
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Column(
|
||||
modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||
BoxWithConstraints(modifier) {
|
||||
val compact = maxHeight < 360.dp || maxWidth < 420.dp
|
||||
val showTimes = maxHeight >= 260.dp
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 14.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
// The title gives up its height first and scrolls, so the controls never leave the screen.
|
||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
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(
|
||||
stringResource(R.string.media_press_play),
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth(), showTimes = showTimes)
|
||||
TransportRow(now, media, skip = if (compact) 48.dp else 60.dp, modifier = Modifier.fillMaxWidth())
|
||||
PlayModeRow(now, media, size = if (compact) 46.dp else 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
|
||||
}
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
||||
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
|
||||
PlayModeRow(now, media, size = 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
|
||||
}
|
||||
}
|
||||
|
||||
/** Below these, the media app drops to one column: see [MediaScreen]. */
|
||||
private val NARROW_WIDTH = 600.dp
|
||||
private val NARROW_HEIGHT = 300.dp
|
||||
|
||||
/**
|
||||
* The player beside the browse list, the queue and the lyrics: no art, which the list needs the
|
||||
* room for, just what's playing, the seek bar and the controls.
|
||||
* The player beside the browse list, the queue and the lyrics: the cover as big as the controls
|
||||
* leave room for, with the title and artist beside it rather than under it, so the cover gets the
|
||||
* height; then the seek bar and the controls along the bottom.
|
||||
*/
|
||||
@Composable
|
||||
private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Column(
|
||||
modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist,
|
||||
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val cover = minOf(maxHeight, maxWidth * 0.62f).coerceAtLeast(0.dp)
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.Bottom) {
|
||||
CoverArt(art, Modifier.size(cover))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f).padding(bottom = 4.dp)) {
|
||||
Text(
|
||||
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 3, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist,
|
||||
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
||||
TransportRow(now, media, skip = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(6.dp))
|
||||
TransportRow(now, media, skip = 54.dp, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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()
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
@@ -83,9 +85,10 @@ internal fun ReplyGlyph(modifier: Modifier = Modifier) =
|
||||
Icon(ImageVector.vectorResource(R.drawable.voice_selection_24), null, modifier)
|
||||
|
||||
/** "Alex", or "Alex · 3 new" when a burst of messages from one chat has been merged. */
|
||||
@Composable
|
||||
private fun CarNotification.heading(): String {
|
||||
val who = title.ifBlank { appLabel }
|
||||
return if (burst > 1) "$who · $burst new" else who
|
||||
return if (burst > 1) pluralStringResource(R.plurals.msg_burst, burst, who, burst) else who
|
||||
}
|
||||
|
||||
/** The card. It shows the voice reply too, wherever that was started from. */
|
||||
@@ -106,17 +109,17 @@ fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) {
|
||||
Text(n.heading(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(n.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
GsIconButton(Icons.Filled.Close, "Dismiss", { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp)
|
||||
GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_dismiss), { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) {
|
||||
ReadGlyph(Modifier.size(20.dp))
|
||||
Text("Read", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,36 +194,36 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
|
||||
when (s.phase) {
|
||||
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> {
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) {
|
||||
Text(if (s.phase == VoiceReply.Phase.READING) "Stop" else "Close")
|
||||
Text(if (s.phase == VoiceReply.Phase.READING) stringResource(R.string.msg_stop) else stringResource(R.string.close))
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceReply.Phase.OFFERING -> {
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("No") }
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.msg_no)) }
|
||||
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING ->
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) }
|
||||
VoiceReply.Phase.CONFIRMING -> {
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
|
||||
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text("Send now") }
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) }
|
||||
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text(stringResource(R.string.msg_send_now)) }
|
||||
}
|
||||
VoiceReply.Phase.ERROR -> {
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Close") }
|
||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.close)) }
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text("Try again") }
|
||||
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text(stringResource(R.string.update_try_again)) }
|
||||
GsButton(
|
||||
onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) },
|
||||
tone = GsTone.Neutral, modifier = wide,
|
||||
) { Text("Type") }
|
||||
) { Text(stringResource(R.string.msg_type)) }
|
||||
}
|
||||
}
|
||||
VoiceReply.Phase.SENT, VoiceReply.Phase.IDLE -> Unit
|
||||
@@ -228,13 +231,14 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun voiceHeading(s: VoiceReply.State, who: String): String = when (s.phase) {
|
||||
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who
|
||||
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> "Reply to $who"
|
||||
VoiceReply.Phase.LISTENING -> "Listening"
|
||||
VoiceReply.Phase.CONFIRMING -> "Sending to $who"
|
||||
VoiceReply.Phase.SENT -> "Sent to $who"
|
||||
VoiceReply.Phase.ERROR -> "Couldn't reply"
|
||||
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> stringResource(R.string.msg_reply_to, who)
|
||||
VoiceReply.Phase.LISTENING -> stringResource(R.string.msg_listening)
|
||||
VoiceReply.Phase.CONFIRMING -> stringResource(R.string.msg_sending_to, who)
|
||||
VoiceReply.Phase.SENT -> stringResource(R.string.msg_sent_to, who)
|
||||
VoiceReply.Phase.ERROR -> stringResource(R.string.msg_reply_failed)
|
||||
VoiceReply.Phase.IDLE -> ""
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
@@ -8,6 +11,8 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -60,14 +65,14 @@ fun MessagesScreen(packageName: String) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val context = LocalContext.current.applicationContext
|
||||
val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) }
|
||||
val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: "Messages"
|
||||
val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: stringResource(R.string.msg_messages)
|
||||
val threads = history.filter { it.packageName == packageName }
|
||||
|
||||
LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
|
||||
entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
@@ -82,7 +87,7 @@ fun MessagesScreen(packageName: String) {
|
||||
|
||||
if (threads.isEmpty()) {
|
||||
Text(
|
||||
"No recent messages. New $label messages appear here as they arrive on the phone.",
|
||||
stringResource(R.string.msg_none_recent, label),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -104,6 +109,7 @@ fun MessagesScreen(packageName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () -> Unit, onMarkRead: () -> Unit) {
|
||||
var expanded by rememberSaveable(n.key) { mutableStateOf(false) }
|
||||
@@ -143,40 +149,48 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: ()
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
if (n.replied) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.notif_replied),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 8.dp).size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!expanded && lines.size > COLLAPSED_LINES) {
|
||||
Text(
|
||||
"${lines.size - COLLAPSED_LINES} earlier",
|
||||
pluralStringResource(R.plurals.msg_earlier, lines.size - COLLAPSED_LINES, lines.size - COLLAPSED_LINES),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
shown.forEach { MessageLine(it, showSender = n.isGroup) }
|
||||
// Below the messages and side by side, so the text keeps the card's whole width.
|
||||
FlowRow(
|
||||
Modifier.padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
GsButton(onClick = onRead, tone = GsTone.Neutral) {
|
||||
ReadGlyph(Modifier.size(20.dp))
|
||||
Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = onReply, tone = GsTone.Tonal) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
|
||||
Text(stringResource(if (quick) R.string.msg_hide_quick else R.string.msg_quick))
|
||||
}
|
||||
}
|
||||
if (n.markRead != null) {
|
||||
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text(stringResource(R.string.msg_mark_read)) }
|
||||
}
|
||||
}
|
||||
if (quick) QuickReplies(n, Modifier.padding(top = 6.dp))
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (n.replied) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
GsButton(onClick = onRead, tone = GsTone.Neutral) {
|
||||
ReadGlyph(Modifier.size(20.dp))
|
||||
Text("Read", Modifier.padding(start = 8.dp))
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = onReply, tone = GsTone.Tonal) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
if (n.reply != null) {
|
||||
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
|
||||
Text(if (quick) "Hide quick replies" else "Quick reply")
|
||||
}
|
||||
}
|
||||
if (n.markRead != null) {
|
||||
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text("Mark read") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,8 +198,8 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: ()
|
||||
@Composable
|
||||
private fun MessageLine(line: ChatLine, showSender: Boolean) {
|
||||
val who = when {
|
||||
line.sender == null -> "You: "
|
||||
showSender -> "${line.sender}: "
|
||||
line.sender == null -> stringResource(R.string.msg_you)
|
||||
showSender -> stringResource(R.string.msg_sender, line.sender)
|
||||
else -> ""
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -65,26 +68,32 @@ private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f
|
||||
*/
|
||||
internal class BuiltInApp(
|
||||
val id: String,
|
||||
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
|
||||
val label: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val glyph: ImageVector,
|
||||
val screen: CarScreen?,
|
||||
val open: (CarNavigator) -> Unit,
|
||||
)
|
||||
|
||||
internal object BuiltInApps {
|
||||
val Web = BuiltInApp("web", "Browser", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
|
||||
val ScreenSharing = BuiltInApp("phone", "Screen sharing", Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
|
||||
val Phone = BuiltInApp("dialer", "Phone", Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
|
||||
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
||||
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
||||
val Web = BuiltInApp("web", "Browser", R.string.app_browser, Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
|
||||
val ScreenSharing = BuiltInApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
|
||||
val Phone = BuiltInApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
|
||||
val VehicleData = BuiltInApp("vehicle", "Vehicle data", R.string.car_vehicle_data, Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
||||
val Settings = BuiltInApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
||||
|
||||
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
|
||||
val Exit = BuiltInApp("exit", "Exit", CarGlyph, screen = null) { CarFocus.exitToCar() }
|
||||
val Exit = BuiltInApp("exit", "Exit", R.string.app_exit, CarGlyph, screen = null) { CarFocus.exitToCar() }
|
||||
|
||||
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
|
||||
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
||||
}
|
||||
|
||||
/** The name a driver sees: translated for Gearslip's own apps, the app's own name otherwise. */
|
||||
@Composable
|
||||
internal fun Entry.shownLabel(): String = builtIn?.let { stringResource(it.labelRes) } ?: label
|
||||
|
||||
/** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */
|
||||
@Composable
|
||||
internal fun rememberVehicleDataShown(): Boolean {
|
||||
@@ -215,7 +224,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
||||
entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) }
|
||||
entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) }
|
||||
Text(
|
||||
entry.label,
|
||||
entry.shownLabel(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 14.dp),
|
||||
@@ -232,9 +241,9 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
isPinned -> "Unpin from nav bar"
|
||||
barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)"
|
||||
else -> "Pin to nav bar"
|
||||
isPinned -> stringResource(R.string.navapps_unpin)
|
||||
barFull -> stringResource(R.string.navapps_full, CarSettings.MAX_PINNED)
|
||||
else -> stringResource(R.string.navapps_pin)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
@@ -248,13 +257,13 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
||||
},
|
||||
tone = GsTone.Danger,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text("Close app", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.navapps_close), style = MaterialTheme.typography.titleMedium) }
|
||||
}
|
||||
GsButton(
|
||||
onClick = navigator::dismissAppMenu,
|
||||
tone = GsTone.Neutral,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text("Cancel", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,19 +290,19 @@ private fun MoveButtons(id: String) {
|
||||
tone = GsTone.Tonal,
|
||||
enabled = index > 0,
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Move left", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.navapps_move_left), style = MaterialTheme.typography.titleMedium) }
|
||||
GsButton(
|
||||
onClick = { move(1) },
|
||||
tone = GsTone.Tonal,
|
||||
enabled = index < order.lastIndex,
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Move right", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.navapps_move_right), style = MaterialTheme.typography.titleMedium) }
|
||||
}
|
||||
if (custom.isNotEmpty()) {
|
||||
GsButton(
|
||||
onClick = { CarSettings.setAppOrder(emptyList()) },
|
||||
tone = GsTone.Neutral,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text("Reset app order", style = MaterialTheme.typography.titleMedium) }
|
||||
) { Text(stringResource(R.string.navapps_reset), style = MaterialTheme.typography.titleMedium) }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
@@ -79,12 +81,12 @@ fun NotificationsSection() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Notifications",
|
||||
stringResource(R.string.notif_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") }
|
||||
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text(stringResource(R.string.notif_clear_all)) }
|
||||
}
|
||||
|
||||
if (!listening) {
|
||||
@@ -97,7 +99,7 @@ fun NotificationsSection() {
|
||||
|
||||
if (history.isEmpty()) {
|
||||
Text(
|
||||
"Nothing yet. New notifications from the phone will show up here.",
|
||||
stringResource(R.string.notif_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -124,11 +126,11 @@ internal fun AccessBanner(onOpen: () -> Unit) {
|
||||
) {
|
||||
Icon(Icons.Filled.Warning, contentDescription = null)
|
||||
Text(
|
||||
"Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone.",
|
||||
stringResource(R.string.notif_off),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsButton(onClick = onOpen) { Text("Open") }
|
||||
GsButton(onClick = onOpen) { Text(stringResource(R.string.guide_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,10 +162,10 @@ private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Un
|
||||
}
|
||||
}
|
||||
when {
|
||||
n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
|
||||
n.replied -> Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.notif_replied), tint = MaterialTheme.colorScheme.primary)
|
||||
n.reply != null -> GsButton(onClick = { VoiceReply.reply(n) }, tone = GsTone.Tonal) {
|
||||
ReplyGlyph(Modifier.size(20.dp))
|
||||
Text("Reply", Modifier.padding(start = 8.dp))
|
||||
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,14 +200,14 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone)
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), onDone)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
if (text.isEmpty()) "Reply to ${n.title.ifBlank { n.appLabel }}" else "$text|",
|
||||
if (text.isEmpty()) stringResource(R.string.msg_reply_to, n.title.ifBlank { n.appLabel }) else "$text|",
|
||||
color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -52,8 +54,8 @@ fun PhoneAppsScreen() {
|
||||
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
ScreenHeader("Screen sharing")
|
||||
Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
|
||||
ScreenHeader(stringResource(R.string.app_screen_sharing))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (!TouchRelayService.enabled) {
|
||||
TouchRelayWarning()
|
||||
@@ -63,7 +65,7 @@ fun PhoneAppsScreen() {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -94,11 +96,11 @@ private fun TouchRelayWarning() {
|
||||
) {
|
||||
Icon(Icons.Filled.Warning, contentDescription = null)
|
||||
Text(
|
||||
"Touch is off. Enable Gearslip under Settings > Accessibility on the phone.",
|
||||
stringResource(R.string.phoneapps_touch_off),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
|
||||
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text(stringResource(R.string.guide_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
@@ -12,6 +14,7 @@ import app.seb3thehacker.gearslip.GearslipLog
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -133,45 +136,48 @@ 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)) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
// The tabs share the title's row, so the list and keypad get the height back.
|
||||
ScreenHeader("Phone") {
|
||||
ScreenHeader(stringResource(R.string.carset_phone)) {
|
||||
if (hasPermissions) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
|
||||
DialerTab(stringResource(R.string.dialer_contacts), tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||
DialerTab(stringResource(R.string.dialer_keypad), tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPermissions) {
|
||||
MissingPermission(context)
|
||||
MissingPermission(context) { hasPermissions = true }
|
||||
return@Column
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
|
||||
if (tab == TAB_CONTACTS) ContactsList(context) else Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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.",
|
||||
stringResource(R.string.dialer_needs_permission),
|
||||
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(stringResource(R.string.dialer_ask)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +213,7 @@ private fun Keypad(context: Context) {
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
number.ifEmpty { "Enter a number" },
|
||||
number.ifEmpty { stringResource(R.string.dialer_enter_number) },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -236,7 +242,7 @@ private fun Keypad(context: Context) {
|
||||
colors = gsColors(GsTone.Primary),
|
||||
enabled = number.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) { Icon(Icons.Filled.Call, "Call") }
|
||||
) { Icon(Icons.Filled.Call, stringResource(R.string.dialer_call)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,16 +284,16 @@ private fun ContactsList(context: Context) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when {
|
||||
contacts.isEmpty() -> Text(
|
||||
"No contacts with a phone number found.",
|
||||
stringResource(R.string.dialer_no_contacts),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
filtered.isEmpty() -> Text(
|
||||
"No contacts match \"$query\".",
|
||||
stringResource(R.string.dialer_no_match, query),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(filtered) { contact ->
|
||||
ContactRow(contact) { pendingCall = contact }
|
||||
}
|
||||
@@ -340,14 +346,14 @@ private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismis
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(stringResource(R.string.dialer_call_who, contact.name), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Cancel", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Call", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.dialer_call), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,7 +370,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
query.ifEmpty { "Search contacts" },
|
||||
query.ifEmpty { stringResource(R.string.dialer_search) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -402,7 +408,7 @@ private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
||||
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary)
|
||||
Icon(Icons.Filled.Call, contentDescription = stringResource(R.string.dialer_call), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.media.session.PlaybackState
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -94,23 +96,23 @@ internal fun formatDuration(ms: Long): String {
|
||||
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
|
||||
}
|
||||
|
||||
/** Previous, play/pause, next. [skip] sizes the outer two; play is a step bigger. */
|
||||
/** Previous, play/pause, next. [skip] sizes the outer two; play is [playScale] times bigger. */
|
||||
@Composable
|
||||
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier) {
|
||||
val play = skip * 1.25f
|
||||
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier, playScale: Float = 1.25f) {
|
||||
val play = skip * playScale
|
||||
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
|
||||
GsIconButton(
|
||||
MediaIcons.Previous, "Previous", media::previous,
|
||||
MediaIcons.Previous, stringResource(R.string.player_previous), media::previous,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
|
||||
)
|
||||
GsIconButton(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
stringResource(if (now.playing) R.string.player_pause else R.string.media_play),
|
||||
media::togglePlay,
|
||||
tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape,
|
||||
)
|
||||
GsIconButton(
|
||||
MediaIcons.Next, "Next", media::next,
|
||||
MediaIcons.Next, stringResource(R.string.player_next), media::next,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
|
||||
)
|
||||
}
|
||||
@@ -147,7 +149,7 @@ internal fun PlayModeRow(
|
||||
} else {
|
||||
GsIconButton(
|
||||
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
if (liked == true) "Unlike" else "Like",
|
||||
stringResource(if (liked == true) R.string.player_unlike else R.string.player_like),
|
||||
media::toggleLike,
|
||||
enabled = liked != null,
|
||||
tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
|
||||
@@ -159,7 +161,7 @@ internal fun PlayModeRow(
|
||||
CustomActionButton(shuffleAction, media, size, toggle = true)
|
||||
} else {
|
||||
GsIconButton(
|
||||
MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on",
|
||||
MediaIcons.Shuffle, stringResource(if (shuffleOn) R.string.player_shuffle_off else R.string.player_shuffle_on),
|
||||
media::toggleShuffle,
|
||||
enabled = shuffleKnown,
|
||||
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
|
||||
@@ -172,7 +174,7 @@ internal fun PlayModeRow(
|
||||
} else {
|
||||
GsIconButton(
|
||||
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
|
||||
"Repeat",
|
||||
stringResource(R.string.player_repeat),
|
||||
media::cycleRepeat,
|
||||
enabled = repeatKnown,
|
||||
tone = if (repeatOn) GsTone.Primary else GsTone.Neutral,
|
||||
@@ -229,26 +231,48 @@ internal fun rememberArt(now: NowPlaying): android.graphics.Bitmap? {
|
||||
}
|
||||
|
||||
/**
|
||||
* A background taken from the album art's main colour, in place of [fallback]. Only the hue and a
|
||||
* capped amount of its saturation come from the art; the lightness stays close to the theme's
|
||||
* own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same
|
||||
* on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than
|
||||
* jumping.
|
||||
* Three soft pools of the cover's colours: top left, top right and the middle, read off a 3x3
|
||||
* shrink of it. Still, and redrawn only while a new song's colours fade in, so it costs the video
|
||||
* stream nothing between songs. Fainter on a light card, where colour shows more. [wide] is for
|
||||
* a whole screen: the pools gather on its left, behind the cover, and reach less far across it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberArtColor(
|
||||
art: android.graphics.Bitmap?,
|
||||
fallback: Color,
|
||||
darkLightness: Float = 0.2f,
|
||||
lightLightness: Float = 0.86f,
|
||||
): Color {
|
||||
val dark = fallback.luminance() < 0.5f
|
||||
val tint by produceState<ArtColor.Tint?>(null, art) {
|
||||
value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } }
|
||||
internal fun AlbumGlow(art: android.graphics.Bitmap?, modifier: Modifier, wide: Boolean = false) {
|
||||
val spots by produceState<List<Color>>(emptyList(), art) {
|
||||
value = art?.let { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { glowColours(it) } }.orEmpty()
|
||||
}
|
||||
val strength = if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) 0.30f else 0.18f
|
||||
val fade = tween<Color>(600)
|
||||
val first by androidx.compose.animation.animateColorAsState(spots.getOrNull(0)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
|
||||
val second by androidx.compose.animation.animateColorAsState(spots.getOrNull(1)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
|
||||
val third by androidx.compose.animation.animateColorAsState(spots.getOrNull(2)?.copy(alpha = strength * 0.8f) ?: Color.Transparent, fade, label = "glow")
|
||||
androidx.compose.foundation.Canvas(modifier) {
|
||||
fun pool(colour: Color, x: Float, y: Float, reach: Float) {
|
||||
if (colour.alpha <= 0f) return
|
||||
drawRect(
|
||||
androidx.compose.ui.graphics.Brush.radialGradient(
|
||||
listOf(colour, Color.Transparent),
|
||||
center = androidx.compose.ui.geometry.Offset(size.width * x, size.height * y),
|
||||
radius = size.width * reach,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (wide) {
|
||||
pool(third, 0.22f, 0.8f, 0.35f)
|
||||
pool(first, 0.1f, 0.25f, 0.42f)
|
||||
pool(second, 0.3f, 0.45f, 0.38f)
|
||||
} else {
|
||||
pool(third, 0.5f, 0.5f, 0.85f)
|
||||
pool(first, 0.2f, 0.1f, 0.9f)
|
||||
pool(second, 0.85f, 0.22f, 0.8f)
|
||||
}
|
||||
}
|
||||
val target = tint?.let {
|
||||
if (dark) Color.hsl(it.hue, it.saturation.coerceIn(0.3f, 0.5f), darkLightness)
|
||||
else Color.hsl(it.hue, it.saturation.coerceIn(0.35f, 0.55f), lightLightness)
|
||||
} ?: fallback
|
||||
return animateColorAsState(target, tween(600), label = "art colour").value
|
||||
}
|
||||
|
||||
/** The cover's colours at its top left, top right and middle. */
|
||||
private fun glowColours(art: android.graphics.Bitmap): List<Color> {
|
||||
// A hardware bitmap can't be scaled or read on the CPU, so copy it out first.
|
||||
val readable = if (art.config == android.graphics.Bitmap.Config.HARDWARE) art.copy(android.graphics.Bitmap.Config.ARGB_8888, false) else art
|
||||
val small = android.graphics.Bitmap.createScaledBitmap(readable ?: return emptyList(), 3, 3, true)
|
||||
return listOf(small.getPixel(0, 0), small.getPixel(2, 0), small.getPixel(1, 1)).map { Color(it).copy(alpha = 1f) }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/** One saved place. Home and Work keep a blank [label]; their names come from strings.xml. */
|
||||
data class SavedPlace(val label: String = "", val address: String = "")
|
||||
|
||||
/**
|
||||
* Up to [COUNT] places a driver can name by voice in any map app: slot [HOME], slot [WORK],
|
||||
* then three they name themselves. "Navigate home" sends the saved address as the search.
|
||||
*/
|
||||
object SavedPlaces {
|
||||
|
||||
const val COUNT = 5
|
||||
const val HOME = 0
|
||||
const val WORK = 1
|
||||
|
||||
private const val KEY = "saved_places"
|
||||
|
||||
private val HOME_WORDS = setOf("home", "house", "place", "home address", "my home", "my house", "my place")
|
||||
private val WORK_WORDS = setOf("work", "office", "job", "work address", "my work", "my office", "my job", "the office")
|
||||
|
||||
fun all(context: Context): List<SavedPlace> {
|
||||
val saved = runCatching {
|
||||
val array = JSONArray(AppSettings.getString(context, KEY, "[]"))
|
||||
List(array.length()) { i ->
|
||||
array.getJSONObject(i).let { SavedPlace(it.optString("label"), it.optString("address")) }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
return List(COUNT) { saved.getOrNull(it) ?: SavedPlace() }
|
||||
}
|
||||
|
||||
fun set(context: Context, index: Int, place: SavedPlace) {
|
||||
val next = all(context).toMutableList().also { it[index] = place }
|
||||
val array = JSONArray()
|
||||
next.forEach { array.put(JSONObject().put("label", it.label).put("address", it.address)) }
|
||||
AppSettings.putString(context, KEY, array.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot [spoken] names, if any: "home", "my house", "work", or a custom label. Returns the
|
||||
* slot even when its address is blank, so the assistant can say it isn't set yet.
|
||||
*/
|
||||
fun match(context: Context, spoken: String): Pair<Int, SavedPlace>? {
|
||||
val said = spoken.lowercase().trim()
|
||||
val places = all(context)
|
||||
if (said in HOME_WORDS) return HOME to places[HOME]
|
||||
if (said in WORK_WORDS) return WORK to places[WORK]
|
||||
val squashed = squash(said.removePrefix("my ").removePrefix("the "))
|
||||
if (squashed.isEmpty()) return null
|
||||
places.forEachIndexed { i, place ->
|
||||
if (i > WORK && place.label.isNotBlank() && squash(place.label) == squashed) return i to place
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
|
||||
|
||||
fun hasLocationPermission(context: Context) =
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Where the phone is now, as a street address, or as coordinates when there's no geocoder. Null if unknown. */
|
||||
suspend fun here(context: Context): String? = withContext(Dispatchers.IO) {
|
||||
val location = locate(context) ?: return@withContext null
|
||||
val address = runCatching {
|
||||
if (!Geocoder.isPresent()) return@runCatching null
|
||||
@Suppress("DEPRECATION")
|
||||
Geocoder(context, Locale.getDefault()).getFromLocation(location.latitude, location.longitude, 1)
|
||||
?.firstOrNull()?.getAddressLine(0)
|
||||
}.getOrNull()
|
||||
address ?: "%.6f,%.6f".format(Locale.US, location.latitude, location.longitude)
|
||||
}
|
||||
|
||||
private suspend fun locate(context: Context): Location? {
|
||||
if (!hasLocationPermission(context)) return null
|
||||
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||
val provider = listOf(LocationManager.FUSED_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||
.firstOrNull { it in manager.allProviders && manager.isProviderEnabled(it) }
|
||||
// A fresh fix: saving the spot you're standing on wants better than an hour-old one.
|
||||
val fresh = provider?.let {
|
||||
withTimeoutOrNull(FIX_TIMEOUT_MS) {
|
||||
suspendCancellableCoroutine { done ->
|
||||
runCatching {
|
||||
manager.getCurrentLocation(it, null, context.mainExecutor) { fix -> done.resume(fix) }
|
||||
}.onFailure { e ->
|
||||
GearslipLog.w("places: fix failed: ${e.message}")
|
||||
done.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fresh ?: manager.allProviders
|
||||
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
|
||||
.maxByOrNull { it.time }
|
||||
}
|
||||
|
||||
private const val FIX_TIMEOUT_MS = 15_000L
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
@@ -29,7 +31,7 @@ fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Compos
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.graphics.Rect
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -99,17 +101,18 @@ fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) {
|
||||
* A template that isn't map-backed covers the surface completely, and reporting an empty
|
||||
* rectangle is how the app is told to stop bothering.
|
||||
*/
|
||||
fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null): Rect = when (template) {
|
||||
fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null, cardsAtEnd: Boolean = false): Rect = when (template) {
|
||||
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 ?: if (cardsAtEnd) Rect(0, 0, (width * (1 - CARD_COLUMN_FRACTION)).toInt(), height)
|
||||
else 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 +132,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)
|
||||
}
|
||||
}
|
||||
@@ -150,6 +155,24 @@ internal class ChromeCover {
|
||||
|
||||
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
|
||||
|
||||
/**
|
||||
* The app's cards go to the far edge of the map and its buttons to the near one, set while the
|
||||
* player is docked on the left: the player, the map and the cards each keep their own space
|
||||
* instead of the cards crowding up against the player.
|
||||
*/
|
||||
internal val LocalCardsAtEnd = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* How much of the map's width the docked player floats over, on the side opposite the cards. The
|
||||
* map itself runs underneath at full size; the app is told this part is covered, and the chrome
|
||||
* keeps clear of it.
|
||||
*/
|
||||
internal val LocalSidePanelInset = compositionLocalOf { 0.dp }
|
||||
|
||||
/** The edge the app's cards cover, and the one its map buttons do. */
|
||||
internal val cardEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.END else CoverEdge.START
|
||||
internal val buttonEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.START else CoverEdge.END
|
||||
|
||||
/**
|
||||
* The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather
|
||||
* than following the slide, so the app is told once instead of every frame.
|
||||
@@ -171,7 +194,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 +203,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 +215,114 @@ 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()
|
||||
val atEnd = LocalCardsAtEnd.current
|
||||
val cardEdge = cardEdge
|
||||
val buttonEdge = buttonEdge
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val column = cardColumnWidth(maxWidth)
|
||||
val cards = @Composable {
|
||||
if (routing != null || estimate != null) {
|
||||
Column(
|
||||
Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val buttons = @Composable {
|
||||
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),
|
||||
mapActionsModifier = Modifier.covers("nav-buttons", buttonEdge),
|
||||
alignment = if (atEnd) Alignment.Start else Alignment.End,
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (atEnd) buttons() else cards()
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (atEnd) cards() else buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(stringResource(R.string.nav_finding_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(stringResource(R.string.nav_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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,7 +376,7 @@ private fun LaneTile(lane: Lane) {
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = if (recommended) "Recommended lane" else "Lane",
|
||||
contentDescription = stringResource(if (recommended) R.string.nav_lane_recommended else R.string.nav_lane),
|
||||
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
|
||||
@@ -345,68 +398,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 { stringResource(R.string.nav_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() },
|
||||
stringResource(R.string.nav_stop),
|
||||
{ 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 +472,20 @@ 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()
|
||||
val atEnd = LocalCardsAtEnd.current
|
||||
val cardEdge = cardEdge
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
if (!pan) {
|
||||
ChromeSurface(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.align(if (atEnd) Alignment.TopEnd else Alignment.TopStart)
|
||||
.covers("map-pane", cardEdge)
|
||||
.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,
|
||||
@@ -448,7 +496,8 @@ private fun MapPaneLayout(
|
||||
RightEdgeControls(
|
||||
if (pan) null else actionStrip,
|
||||
mapActionStrip,
|
||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
Modifier.align(if (atEnd) Alignment.TopStart else Alignment.TopEnd).padding(12.dp),
|
||||
alignment = if (atEnd) Alignment.Start else Alignment.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -528,7 +577,7 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
|
||||
)
|
||||
Box(Modifier.weight(1f, fill = false)) {
|
||||
if (template.isLoading) {
|
||||
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||
Text(stringResource(R.string.nav_finding_routes), Modifier.padding(14.dp), style = ChromeType.title)
|
||||
} else {
|
||||
ItemListColumn(template.itemList)
|
||||
}
|
||||
@@ -552,18 +601,22 @@ private fun ListPane(
|
||||
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
Column {
|
||||
HeaderBar(title, startAction)
|
||||
if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||
if (loading) Text(stringResource(R.string.media_loading), Modifier.padding(14.dp), style = ChromeType.title)
|
||||
else ItemListColumn(itemList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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() =
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -317,7 +319,7 @@ internal fun SearchField(
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Text(
|
||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||
query.ifEmpty { hint.ifEmpty { stringResource(R.string.template_search) } },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -327,7 +329,7 @@ internal fun SearchField(
|
||||
if (onClear != null && query.isNotEmpty()) {
|
||||
GsIconButton(
|
||||
androidx.compose.material.icons.Icons.Filled.Close,
|
||||
"Clear",
|
||||
stringResource(R.string.storage_clear),
|
||||
onClear,
|
||||
colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted),
|
||||
size = 42.dp,
|
||||
@@ -517,7 +519,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
|
||||
@@ -622,7 +625,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
|
||||
} else {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Nothing playing yet.",
|
||||
stringResource(R.string.template_nothing_playing),
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
@@ -646,6 +649,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Loading…", style = ChromeType.title)
|
||||
Text(stringResource(R.string.media_loading), style = ChromeType.title)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -34,6 +37,7 @@ 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.MoreVert
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -106,7 +110,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 +222,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?,
|
||||
@@ -224,23 +232,31 @@ internal fun RightEdgeControls(
|
||||
modifier: Modifier = Modifier,
|
||||
/** For the stack of map buttons alone, without the row of the app's own above it. */
|
||||
mapActionsModifier: Modifier = Modifier,
|
||||
/** End normally; Start when the app's cards have moved to the far edge and these to the near one. */
|
||||
alignment: Alignment.Horizontal = Alignment.End,
|
||||
) {
|
||||
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
|
||||
// With the player beside the map there is no room for the app's row and the map controls
|
||||
// both, so the app's own buttons fold behind one; opening it swaps them in for the map's.
|
||||
val folded = LocalSidePanelInset.current > 0.dp && actions.size > 1
|
||||
var open by remember { mutableStateOf(false) }
|
||||
androidx.compose.runtime.LaunchedEffect(visible, folded) { if (!visible || !folded) open = false }
|
||||
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",
|
||||
)
|
||||
@@ -257,18 +273,45 @@ internal fun RightEdgeControls(
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.End,
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (actions.isNotEmpty()) {
|
||||
if (folded) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
GsIconButton(
|
||||
Icons.Filled.MoreVert,
|
||||
stringResource(R.string.car_group_more),
|
||||
onClick = { open = !open },
|
||||
colors = GsColors(if (open) scheme.primary else scheme.surfaceContainerHighest, if (open) scheme.onPrimary else scheme.onSurface),
|
||||
size = ICON_BUTTON_SIZE,
|
||||
iconSize = ICON_BUTTON_GLYPH,
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
)
|
||||
if (open) {
|
||||
// A tap on any of them closes the stack again; the tap still reaches the button.
|
||||
Column(
|
||||
Modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
if (awaitPointerEvent(PointerEventPass.Initial).changes.any { it.changedToUp() }) open = false
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
actions.forEach { ActionButton(it) }
|
||||
}
|
||||
}
|
||||
} else if (actions.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
actions.forEach { ActionButton(it) }
|
||||
}
|
||||
}
|
||||
if (mapActions.isNotEmpty()) {
|
||||
if (mapActions.isNotEmpty() && !(folded && open)) {
|
||||
Column(
|
||||
mapActionsModifier,
|
||||
horizontalAlignment = Alignment.End,
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
mapActions.forEach { ActionButton(it) }
|
||||
@@ -288,7 +331,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 +341,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 +363,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,
|
||||
@@ -417,7 +461,7 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
||||
Text(
|
||||
action.title.text().ifEmpty { "Start" },
|
||||
action.title.text().ifEmpty { stringResource(R.string.template_start) },
|
||||
style = ChromeType.title,
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -431,8 +475,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 +552,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,393 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
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(stringResource(R.string.dialer_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
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -27,7 +28,7 @@ import androidx.compose.ui.unit.dp
|
||||
fun VehicleDataScreen() {
|
||||
val readings by CarSensors.readings.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||
ScreenHeader("Vehicle data")
|
||||
Text(
|
||||
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
||||
@@ -46,7 +47,7 @@ fun VehicleDataScreen() {
|
||||
return@Column
|
||||
}
|
||||
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(readings, key = { it.typeId }) { reading -> ReadingRow(reading) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.annotation.StringRes
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
@@ -38,7 +42,7 @@ class WeatherData(
|
||||
|
||||
sealed interface WeatherState {
|
||||
data object Loading : WeatherState
|
||||
data class Unavailable(val reason: String) : WeatherState
|
||||
data class Unavailable(@param:StringRes val reason: Int) : WeatherState
|
||||
data class Ready(val data: WeatherData) : WeatherState
|
||||
}
|
||||
|
||||
@@ -78,7 +82,7 @@ object Weather {
|
||||
if (flow.value !is WeatherState.Ready) {
|
||||
val allowed = hasLocationPermission(context)
|
||||
flow.value = WeatherState.Unavailable(
|
||||
if (allowed) "Waiting for a location fix." else "Allow location for Gearslip on the phone to see the weather.",
|
||||
if (allowed) R.string.weather_waiting_fix else R.string.weather_allow_location,
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -92,7 +96,7 @@ object Weather {
|
||||
// No place name: logs get shared in bug reports.
|
||||
GearslipLog.i("weather: loaded")
|
||||
}
|
||||
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
|
||||
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable(R.string.weather_unreachable)
|
||||
}
|
||||
|
||||
// --- location -------------------------------------------------------------------------
|
||||
@@ -197,6 +201,26 @@ object Weather {
|
||||
)
|
||||
}
|
||||
|
||||
/** Open-Meteo's WMO weather codes, in words on the screen. [describe] is the English the assistant says. */
|
||||
@StringRes
|
||||
fun conditionRes(code: Int): Int = when (code) {
|
||||
0 -> R.string.weather_clear
|
||||
1 -> R.string.weather_mostly_clear
|
||||
2 -> R.string.weather_partly_cloudy
|
||||
3 -> R.string.weather_overcast
|
||||
45, 48 -> R.string.weather_fog
|
||||
in 51..57 -> R.string.weather_drizzle
|
||||
in 61..67 -> R.string.weather_rain
|
||||
in 71..77 -> R.string.weather_snow
|
||||
in 80..82 -> R.string.weather_rain_showers
|
||||
in 85..86 -> R.string.weather_snow_showers
|
||||
in 95..99 -> R.string.weather_thunderstorm
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun condition(code: Int): String = conditionRes(code).let { if (it == 0) "-" else stringResource(it) }
|
||||
|
||||
/** Open-Meteo's WMO weather codes, in words. */
|
||||
fun describe(code: Int): String = when (code) {
|
||||
0 -> "Clear"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -41,11 +43,11 @@ fun WeatherScreen() {
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
ScreenHeader("Weather")
|
||||
ScreenHeader(stringResource(R.string.crumb_weather))
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||
WeatherState.Loading -> Text(stringResource(R.string.weather_getting), style = MaterialTheme.typography.titleMedium)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
s.reason, style = MaterialTheme.typography.titleMedium,
|
||||
stringResource(s.reason), style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is WeatherState.Ready -> Forecast(s.data)
|
||||
@@ -66,18 +68,18 @@ private fun Forecast(w: WeatherData) {
|
||||
WeatherIcon(w.code, w.isDay, size = 88.dp)
|
||||
Column {
|
||||
Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold)
|
||||
Text(Weather.describe(w.code), style = MaterialTheme.typography.titleLarge)
|
||||
Text(Weather.condition(w.code), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Feels like ${w.feelsLike}${w.tempUnit}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Humidity ${w.humidity}%", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Wind ${w.wind} ${w.windUnit}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.weather_feels_like, "${w.feelsLike}${w.tempUnit}"), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.weather_humidity, w.humidity), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.weather_wind, "${w.wind} ${w.windUnit}"), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.weather_next_hours), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(w.hours) { h ->
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||
@@ -101,7 +103,7 @@ private fun Forecast(w: WeatherData) {
|
||||
}
|
||||
}
|
||||
|
||||
Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.weather_this_week), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
w.days.forEachIndexed { i, d ->
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||
@@ -111,11 +113,11 @@ private fun Forecast(w: WeatherData) {
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
|
||||
if (i == 0) stringResource(R.string.weather_today) else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
|
||||
Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
WeatherIcon(d.code, true, size = 28.dp)
|
||||
Text(Weather.describe(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
Text(Weather.condition(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary)
|
||||
Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
@@ -123,7 +125,7 @@ private fun Forecast(w: WeatherData) {
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Weather data by Open-Meteo.com (CC BY 4.0)",
|
||||
stringResource(R.string.weather_credit),
|
||||
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
@@ -31,6 +33,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 +62,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,8 +97,19 @@ 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)) {
|
||||
GsIconButton(MediaIcons.Fullscreen, "Full screen", CarFullscreen::enter)
|
||||
ScreenHeader(pageTitle.ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, stringResource(R.string.web_search), {
|
||||
fieldText = null
|
||||
address = ""
|
||||
})
|
||||
if (canGoForward) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.web_forward), { web?.goForward() })
|
||||
}
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), {
|
||||
web?.let { view -> loadContent(view, content) }
|
||||
})
|
||||
GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxSize()) {
|
||||
@@ -99,8 +121,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 +146,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 +170,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 +198,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,84 @@
|
||||
package app.seb3thehacker.gearslip.car.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
|
||||
/**
|
||||
* 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, style: DarkStyle = DarkStyle.GREY): ColorScheme {
|
||||
if (dark && style != DarkStyle.GREY) return withDarkStyle(style)
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How dark the car's dark mode goes. [GREY] is Material You's own dark, with the tint above;
|
||||
* the others are darker, and the two colour ones take their hue from the phone's accent.
|
||||
*/
|
||||
enum class DarkStyle { GREY, BLACK, DEEP_COLOUR, BLACK_COLOUR }
|
||||
|
||||
/**
|
||||
* The surfaces rebuilt at [style]'s brightness, from the background up to the highest container.
|
||||
* The levels are 0-255 greys; each surface keeps Material You's own slight hue at that level.
|
||||
*/
|
||||
private fun ColorScheme.withDarkStyle(style: DarkStyle): ColorScheme {
|
||||
val levels = when (style) {
|
||||
DarkStyle.BLACK -> listOf(0f, 12f, 17f, 25f, 34f)
|
||||
DarkStyle.DEEP_COLOUR -> listOf(11f, 19f, 24f, 32f, 41f)
|
||||
else -> listOf(0f, 17f, 22f, 30f, 39f)
|
||||
}
|
||||
val colour = style != DarkStyle.BLACK
|
||||
// The accent's saturated container colour, not the pastel accent itself: tinting with the
|
||||
// pastel only makes a surface lighter, where this one gives it colour at the same brightness.
|
||||
fun at(own: Color, step: Int): Color {
|
||||
val level = levels[step] / 255f
|
||||
if (level == 0f) return Color.Black
|
||||
val neutral = own.scaledTo(level)
|
||||
return if (colour) {
|
||||
lerp(neutral, primaryContainer.scaledTo(level), (0.6f + 0.08f * step).coerceAtMost(0.95f))
|
||||
} else {
|
||||
primary.copy(alpha = 0.02f + 0.015f * step).compositeOver(neutral)
|
||||
}
|
||||
}
|
||||
return copy(
|
||||
// Grey text that read fine on grey looks dim on black, so it moves a little towards white.
|
||||
onSurfaceVariant = lerp(onSurfaceVariant, onSurface, 0.3f),
|
||||
background = at(background, 0),
|
||||
surface = at(surface, 0),
|
||||
surfaceDim = at(surfaceDim, 0),
|
||||
surfaceContainerLowest = at(surfaceContainerLowest, 0),
|
||||
surfaceContainerLow = at(surfaceContainerLow, 1),
|
||||
surfaceContainer = at(surfaceContainer, 2),
|
||||
surfaceContainerHigh = at(surfaceContainerHigh, 3),
|
||||
surfaceContainerHighest = at(surfaceContainerHighest, 4),
|
||||
surfaceBright = at(surfaceBright, 4),
|
||||
surfaceVariant = at(surfaceVariant, 3),
|
||||
)
|
||||
}
|
||||
|
||||
/** This colour's hue at an average brightness of [level], 0 to 1. */
|
||||
private fun Color.scaledTo(level: Float): Color {
|
||||
val mean = (red + green + blue) / 3f
|
||||
if (mean <= 0f) return Color(level, level, level)
|
||||
val k = level / mean
|
||||
return Color((red * k).coerceAtMost(1f), (green * k).coerceAtMost(1f), (blue * k).coerceAtMost(1f))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
@@ -172,6 +173,46 @@ fun Modifier.gsSurface(
|
||||
* outlines: cheap, works on every API level, and already soft enough that the car's video
|
||||
* compression has nothing crisp to smear.
|
||||
*/
|
||||
/**
|
||||
* The current theme's shadow under a panel floating over the map, such as the docked player.
|
||||
* Draws nothing in a look without one.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.gsPanelShadow(shape: Shape): Modifier {
|
||||
val look = gsLook()
|
||||
if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this
|
||||
// A real blur, as soft as the nav bar's gradient and as strong: stacked outlines showed as
|
||||
// rings around a card this big.
|
||||
return dropShadow(
|
||||
shape,
|
||||
androidx.compose.ui.graphics.shadow.Shadow(
|
||||
radius = look.panelShadow * 3,
|
||||
color = Color.Black.copy(alpha = look.panelShadowAlpha * 0.6f),
|
||||
offset = androidx.compose.ui.unit.DpOffset(0.dp, look.panelShadow / 2),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The same shadow cast upwards from the top edge of a bar along the bottom of the screen. */
|
||||
@Composable
|
||||
fun Modifier.gsBarShadow(): Modifier {
|
||||
val look = gsLook()
|
||||
if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this
|
||||
return drawBehind {
|
||||
val reach = look.panelShadow.toPx() * 1.5f
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to Color.Black.copy(alpha = look.panelShadowAlpha * 0.6f),
|
||||
startY = -reach,
|
||||
endY = 0f,
|
||||
),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(0f, -reach),
|
||||
size = Size(size.width, reach),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) {
|
||||
if (alpha <= 0f) return
|
||||
val blur = elevation * 2f
|
||||
|
||||
@@ -64,6 +64,12 @@ data class ControlLook(
|
||||
* from; a flat look takes the dot.
|
||||
*/
|
||||
val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL,
|
||||
/**
|
||||
* The shadow a panel floating over the map casts onto it (the docked player, the lyrics), and
|
||||
* the nav bar casts up. 0 for none, so a flat look stays flat.
|
||||
*/
|
||||
val panelShadow: Dp = 0.dp,
|
||||
val panelShadowAlpha: Float = 0f,
|
||||
/** Replaces this look when the UI is light, for effects that only work on one background. */
|
||||
val light: ControlLook? = null,
|
||||
)
|
||||
@@ -72,7 +78,11 @@ object GsThemes {
|
||||
val Keycap = GsTheme(
|
||||
id = "keycap",
|
||||
name = "Keycap",
|
||||
control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL),
|
||||
// Short and dark, like the keys' own solid base.
|
||||
control = ControlLook(
|
||||
lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL,
|
||||
panelShadow = 4.dp, panelShadowAlpha = 0.45f,
|
||||
),
|
||||
)
|
||||
|
||||
val Tactile = GsTheme(
|
||||
@@ -89,6 +99,8 @@ object GsThemes {
|
||||
pressedDim = 0.12f,
|
||||
pressedScale = 1f,
|
||||
holdMarker = HoldMarkerStyle.PEEL,
|
||||
panelShadow = 12.dp,
|
||||
panelShadowAlpha = 0.42f,
|
||||
// A wide soft shadow bands once the car's video stream compresses it on a light
|
||||
// background, so light mode gets a tight one and leans on the bevel instead.
|
||||
light = ControlLook(
|
||||
@@ -102,6 +114,8 @@ object GsThemes {
|
||||
pressedDim = 0.12f,
|
||||
pressedScale = 1f,
|
||||
holdMarker = HoldMarkerStyle.PEEL,
|
||||
panelShadow = 4.dp,
|
||||
panelShadowAlpha = 0.22f,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -227,6 +227,7 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
private fun disconnect(keepLocation: Boolean) {
|
||||
main.removeCallbacks(handshakeTimeout)
|
||||
hardware.stop()
|
||||
clearToasts()
|
||||
lent?.let { sendSurfaceDestroyed(it) }
|
||||
lent = null
|
||||
@@ -403,6 +404,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) {
|
||||
@@ -597,6 +600,7 @@ class CarAppConnection(private val context: Context) {
|
||||
fun reportAreas(visible: Rect, stable: Rect) {
|
||||
areas = visible to stable
|
||||
val callback = surfaceCallback ?: return
|
||||
GearslipLog.i("host: areas for $appPackage: visible=${visible.toShortString()} stable=${stable.toShortString()}")
|
||||
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
|
||||
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
|
||||
}
|
||||
@@ -626,6 +630,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 +669,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
|
||||
@@ -680,37 +686,25 @@ class CarAppConnection(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers car-hardware questions with "this car doesn't have that".
|
||||
* Answers car-hardware questions from what the car itself sends: see [CarHardwareFeed].
|
||||
*
|
||||
* A phone knows nothing about a car's sensors, so every answer here is "unsupported" - but
|
||||
* it has to be an actual answer. Returning no binder for this service at all is what made
|
||||
* Organic Maps crash in onAppStart: its CarSensorsManager assumes a host that offers the
|
||||
* service, and the library's own "unsupported" path only runs once a reply arrives.
|
||||
*
|
||||
* The reply's Bundleable is ignored by the library whenever isSupported is false, but the
|
||||
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
|
||||
* Every question has to get an actual answer, even "unsupported". Returning no binder for
|
||||
* this service at all is what made Organic Maps crash in onAppStart: its CarSensorsManager
|
||||
* assumes a host that offers the service, and the library's own "unsupported" path only runs
|
||||
* once a reply arrives. The reply's IBinder must be a real IOnDoneCallback: the app
|
||||
* dispatches the reply through it.
|
||||
*/
|
||||
private val hardware = CarHardwareFeed { name -> noop(name).asBinder() }
|
||||
|
||||
private val hardwareHost = object : ICarHardwareHost.Stub() {
|
||||
override fun getCarHardwareResult(
|
||||
type: Int,
|
||||
params: Bundleable?,
|
||||
result: ICarHardwareResult?,
|
||||
) = unsupported(type, result)
|
||||
override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION
|
||||
override fun getCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
|
||||
hardware.get(type, result)
|
||||
|
||||
override fun subscribeCarHardwareResult(
|
||||
type: Int,
|
||||
params: Bundleable?,
|
||||
result: ICarHardwareResult?,
|
||||
) = unsupported(type, result)
|
||||
override fun subscribeCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
|
||||
hardware.subscribe(type, result)
|
||||
|
||||
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = Unit
|
||||
|
||||
private fun unsupported(type: Int, result: ICarHardwareResult?) {
|
||||
val target = result ?: return
|
||||
runCatching {
|
||||
target.onCarHardwareResult(type, false, null, noop("carHardwareResult").asBinder())
|
||||
}.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
|
||||
}
|
||||
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = hardware.unsubscribe(type)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -721,6 +715,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 +734,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 +772,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 +788,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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package app.seb3thehacker.gearslip.host
|
||||
|
||||
import android.os.IBinder
|
||||
import androidx.car.app.hardware.ICarHardwareResult
|
||||
import androidx.car.app.hardware.common.CarUnit
|
||||
import androidx.car.app.hardware.common.CarValue
|
||||
import androidx.car.app.hardware.info.CarHardwareLocation
|
||||
import androidx.car.app.hardware.info.Compass
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.hardware.info.Mileage
|
||||
import androidx.car.app.hardware.info.Model
|
||||
import androidx.car.app.hardware.info.Speed
|
||||
import androidx.car.app.serialization.Bundleable
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.car.CarData
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* What a car app gets when it asks the car hardware API (CarInfo, CarSensors) about the car:
|
||||
* the model, speed, odometer, fuel and range, compass and the car's own GPS, read from [CarData].
|
||||
* Anything else, or anything the car never sends, is answered "this car doesn't have that".
|
||||
*
|
||||
* A one-off question ([get]) is answered at once. A subscription gets the current value, if
|
||||
* there is one, and then each new one as the car sends it, until the app unsubscribes or the
|
||||
* connection ends ([stop]).
|
||||
*/
|
||||
internal class CarHardwareFeed(private val callback: (name: String) -> IBinder) {
|
||||
|
||||
private val subscribers = ConcurrentHashMap<Int, CopyOnWriteArrayList<ICarHardwareResult>>()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@Volatile private var watching: Job? = null
|
||||
|
||||
fun get(type: Int, result: ICarHardwareResult?) {
|
||||
val target = result ?: return
|
||||
send(target, type, valueOf(type, CarData.state.value) ?: return unsupported(target, type))
|
||||
}
|
||||
|
||||
fun subscribe(type: Int, result: ICarHardwareResult?) {
|
||||
val target = result ?: return
|
||||
if (type !in SENSOR_OF) return unsupported(target, type)
|
||||
subscribers.getOrPut(type) { CopyOnWriteArrayList() }.add(target)
|
||||
GearslipLog.i("host: an app subscribed to car hardware ${NAMES[type] ?: type}")
|
||||
valueOf(type, CarData.state.value)?.let { send(target, type, it) }
|
||||
watch()
|
||||
}
|
||||
|
||||
fun unsubscribe(type: Int) {
|
||||
subscribers.remove(type)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
subscribers.clear()
|
||||
watching?.cancel()
|
||||
watching = null
|
||||
}
|
||||
|
||||
fun close() {
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/** Sends each subscribed kind again when the car's reading of it changes. */
|
||||
private fun watch() {
|
||||
if (watching?.isActive == true) return
|
||||
watching = scope.launch {
|
||||
var seen = CarData.state.value.updatedAt
|
||||
CarData.state.collect { s ->
|
||||
for ((type, targets) in subscribers) {
|
||||
val sensor = SENSOR_OF[type] ?: continue
|
||||
if (s.updatedAt[sensor] == seen[sensor]) continue
|
||||
val value = valueOf(type, s) ?: continue
|
||||
targets.forEach { send(it, type, value) }
|
||||
}
|
||||
seen = s.updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun valueOf(type: Int, s: CarData.Snapshot): Any? {
|
||||
val imperial = CarSettings.imperial()
|
||||
val distanceUnit = known(if (imperial) CarUnit.MILE else CarUnit.KILOMETER)
|
||||
val at = s.updatedAt[SENSOR_OF[type]] ?: System.currentTimeMillis()
|
||||
fun <T : Any> v(value: T) = CarValue(value, at, CarValue.STATUS_SUCCESS)
|
||||
return when (type) {
|
||||
TYPE_MODEL -> CarData.model?.let { m ->
|
||||
Model.Builder().setName(v(m.name)).apply { m.year?.let { setYear(v(it)) } }.build()
|
||||
}
|
||||
TYPE_SPEED -> s.speedMps?.let {
|
||||
Speed.Builder()
|
||||
.setRawSpeedMetersPerSecond(v(it))
|
||||
.setDisplaySpeedMetersPerSecond(v(it))
|
||||
.setSpeedDisplayUnit(known(if (imperial) CarUnit.MILES_PER_HOUR else CarUnit.KILOMETERS_PER_HOUR))
|
||||
.build()
|
||||
}
|
||||
TYPE_MILEAGE -> s.odometerKm?.let {
|
||||
Mileage.Builder().setOdometerMeters(v(it * 1000f)).setDistanceDisplayUnit(distanceUnit).build()
|
||||
}
|
||||
TYPE_ENERGY_LEVEL -> if (s.fuelPercent == null && s.rangeKm == null && s.fuelLow == null) null else {
|
||||
EnergyLevel.Builder().apply {
|
||||
s.fuelPercent?.let { setFuelPercent(v(it.toFloat())) }
|
||||
s.rangeKm?.let { setRangeRemainingMeters(v(it * 1000f)) }
|
||||
s.fuelLow?.let { setEnergyIsLow(v(it)) }
|
||||
setDistanceDisplayUnit(distanceUnit)
|
||||
}.build()
|
||||
}
|
||||
TYPE_COMPASS -> s.compassDegrees?.let { Compass(v(listOf(it))) }
|
||||
TYPE_CAR_LOCATION -> s.location?.let { l ->
|
||||
val location = android.location.Location("car").apply {
|
||||
latitude = l.latitude
|
||||
longitude = l.longitude
|
||||
l.accuracyMeters?.let { accuracy = it }
|
||||
l.altitudeMeters?.let { altitude = it }
|
||||
l.speedMps?.let { speed = it }
|
||||
l.bearingDegrees?.let { bearing = it }
|
||||
time = at
|
||||
}
|
||||
CarHardwareLocation(v(location))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun known(value: Int) = CarValue(value, System.currentTimeMillis(), CarValue.STATUS_SUCCESS)
|
||||
|
||||
private fun send(target: ICarHardwareResult, type: Int, value: Any) {
|
||||
runCatching {
|
||||
target.onCarHardwareResult(type, true, Bundleable.create(value), callback("carHardwareResult"))
|
||||
}.onFailure {
|
||||
// The app went away: stop telling it.
|
||||
subscribers.values.forEach { list -> list.remove(target) }
|
||||
GearslipLog.w("host: could not send car hardware ${NAMES[type] ?: type}: ${it.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unsupported(target: ICarHardwareResult, type: Int) {
|
||||
runCatching { target.onCarHardwareResult(type, false, null, callback("carHardwareResult")) }
|
||||
.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
|
||||
}
|
||||
|
||||
companion object {
|
||||
// ICarHardwareResultTypes in the car library.
|
||||
private const val TYPE_MODEL = 1
|
||||
private const val TYPE_ENERGY_LEVEL = 4
|
||||
private const val TYPE_SPEED = 5
|
||||
private const val TYPE_MILEAGE = 6
|
||||
private const val TYPE_COMPASS = 21
|
||||
private const val TYPE_CAR_LOCATION = 23
|
||||
|
||||
/** Which of the car's sensors each kind comes from, for knowing when it changed. */
|
||||
private val SENSOR_OF = mapOf(
|
||||
TYPE_ENERGY_LEVEL to CarData.FUEL,
|
||||
TYPE_SPEED to CarData.SPEED,
|
||||
TYPE_MILEAGE to CarData.ODOMETER,
|
||||
TYPE_COMPASS to CarData.COMPASS,
|
||||
TYPE_CAR_LOCATION to CarData.LOCATION,
|
||||
)
|
||||
|
||||
private val NAMES = mapOf(
|
||||
TYPE_MODEL to "model", TYPE_ENERGY_LEVEL to "energy level", TYPE_SPEED to "speed",
|
||||
TYPE_MILEAGE to "mileage", TYPE_COMPASS to "compass", TYPE_CAR_LOCATION to "car location",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,6 +37,10 @@ class MediaEntry(
|
||||
val iconBitmap: Bitmap?,
|
||||
val browsable: Boolean,
|
||||
val playable: Boolean,
|
||||
/** Shown as a cover in a grid rather than a row, as the app asked. */
|
||||
val grid: Boolean = false,
|
||||
/** The heading the app files this under, when it splits a list into sections. */
|
||||
val group: String? = null,
|
||||
)
|
||||
|
||||
/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */
|
||||
@@ -271,7 +275,10 @@ class CarMedia(private val context: Context) {
|
||||
GearslipLog.w("media: ${app.label} went away")
|
||||
_phase.value = Phase.REJECTED
|
||||
}
|
||||
}, null)
|
||||
}, Bundle().apply {
|
||||
// Tells the app it may ask for grids and section headings, as it would of Android Auto.
|
||||
putBoolean(STYLE_SUPPORTED, true)
|
||||
})
|
||||
browser = mb
|
||||
GearslipLog.i("media: connecting to ${app.label}'s library")
|
||||
runCatching { mb.connect() }.onFailure {
|
||||
@@ -342,6 +349,8 @@ class CarMedia(private val context: Context) {
|
||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||
attach(MediaController(context, mb.sessionToken))
|
||||
rootId = mb.root
|
||||
childStyles.clear()
|
||||
mb.extras?.let { childStyles[mb.root] = it.getInt(STYLE_BROWSABLE, 0) to it.getInt(STYLE_PLAYABLE, 0) }
|
||||
path.clear()
|
||||
_phase.value = Phase.READY
|
||||
open(mb.root)
|
||||
@@ -427,7 +436,7 @@ class CarMedia(private val context: Context) {
|
||||
if (parentId != subscribed) return
|
||||
_browse.value = BrowseState(
|
||||
trail = path.map { it.second },
|
||||
entries = children.map(::entryOf),
|
||||
entries = children.map { entryOf(it, childStyles[parentId]) },
|
||||
loading = false,
|
||||
// An empty top level is an app keeping its library to itself, not an empty one.
|
||||
unavailable = path.isEmpty() && children.isEmpty(),
|
||||
@@ -441,8 +450,21 @@ class CarMedia(private val context: Context) {
|
||||
})
|
||||
}
|
||||
|
||||
private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry {
|
||||
/**
|
||||
* How a folder asked for its children to be shown, browsable ones and playable ones: by
|
||||
* folder id, read off the folder itself (or the root's extras) before it's opened.
|
||||
*/
|
||||
private val childStyles = HashMap<String, Pair<Int, Int>>()
|
||||
|
||||
private fun entryOf(item: MediaBrowser.MediaItem, parentStyle: Pair<Int, Int>?): MediaEntry {
|
||||
val d = item.description
|
||||
val extras = d.extras
|
||||
if (extras != null && item.isBrowsable) {
|
||||
childStyles[item.mediaId.orEmpty()] = extras.getInt(STYLE_BROWSABLE, 0) to extras.getInt(STYLE_PLAYABLE, 0)
|
||||
}
|
||||
// The item's own hint first, then what its folder asked for its kind of child.
|
||||
val style = extras?.getInt(STYLE_SINGLE_ITEM, 0)?.takeIf { it != 0 }
|
||||
?: (if (item.isBrowsable) parentStyle?.first else parentStyle?.second) ?: 0
|
||||
return MediaEntry(
|
||||
id = item.mediaId.orEmpty(),
|
||||
title = d.title?.toString().orEmpty(),
|
||||
@@ -451,6 +473,8 @@ class CarMedia(private val context: Context) {
|
||||
iconBitmap = d.iconBitmap,
|
||||
browsable = item.isBrowsable,
|
||||
playable = item.isPlayable,
|
||||
grid = style == STYLE_GRID || style == STYLE_CATEGORY_GRID,
|
||||
group = extras?.getString(STYLE_GROUP_TITLE)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -772,3 +796,12 @@ class CarMedia(private val context: Context) {
|
||||
const val SEARCH_NOTICE_MS = 6_000L
|
||||
}
|
||||
}
|
||||
|
||||
/** Android Auto's browse layout hints, which any app built for it already sends. */
|
||||
private const val STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
|
||||
private const val STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
|
||||
private const val STYLE_PLAYABLE = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
|
||||
private const val STYLE_SINGLE_ITEM = "android.media.browse.CONTENT_STYLE_SINGLE_ITEM_HINT"
|
||||
private const val STYLE_GROUP_TITLE = "android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"
|
||||
private const val STYLE_GRID = 2
|
||||
private const val STYLE_CATEGORY_GRID = 4
|
||||
@@ -85,7 +85,9 @@ class CarMediaStatus(
|
||||
}
|
||||
|
||||
private fun sendTrack(now: NowPlaying) {
|
||||
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}"
|
||||
// Whether there's a cover, not which bitmap: some apps (Metrolist) hand over a fresh copy
|
||||
// of the same cover with every update, and resending it each second wastes the link.
|
||||
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art != null}"
|
||||
if (key == sentTrack) return
|
||||
sentTrack = key
|
||||
var body = Protobuf.stringField(1, now.title) +
|
||||
|
||||
@@ -1,28 +1,55 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Xml
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.File
|
||||
import java.io.StringReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
|
||||
class LyricLine(val timeMs: Long, val text: String)
|
||||
|
||||
/** [synced] means every line carries a time, so the current one can be followed along. */
|
||||
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean)
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Looks lyrics up on LRCLIB (lrclib.net), a free community database that needs no key.
|
||||
* One line. [words] is filled only for word-timed lyrics, and joined back together it reads as
|
||||
* [text]; each word carries the space after it, if any, so a word split into syllables stays whole.
|
||||
*/
|
||||
class LyricLine(val timeMs: Long, val text: String, val words: List<LyricWord> = emptyList())
|
||||
|
||||
/** A word, or a syllable of one, sung from [startMs] to [endMs]. */
|
||||
class LyricWord(val startMs: Long, val endMs: Long, val text: String)
|
||||
|
||||
/**
|
||||
* [synced] means every line carries a time, so the current one can be followed along. [source]
|
||||
* names the service that had them, shown on debug builds.
|
||||
*/
|
||||
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean, val source: String = "")
|
||||
|
||||
/**
|
||||
* Looks lyrics up online, in the order Metrolist uses: BetterLyrics, then LRCLIB, then KuGou.
|
||||
* Metrolist's other sources match songs by YouTube video id, which a song from another app
|
||||
* doesn't have, and Paxsenix calls Apple Music with a borrowed token, so they're left out.
|
||||
*
|
||||
* Media apps almost never publish lyrics in their session metadata, so this is the only general
|
||||
* source. It sends the track's title, artist, album and length to lrclib.net, which is why the
|
||||
* lookup happens only when the driver opens the lyrics view and never in the background.
|
||||
* - BetterLyrics (lyrics-api.boidu.dev): word-timed lyrics, but only for songs its server has
|
||||
* already fetched; anything else needs an API key and falls through.
|
||||
* - LRCLIB (lrclib.net): a free community database that needs no key.
|
||||
* - KuGou (kugou.com): a large Chinese music service, strong on Asian music.
|
||||
*
|
||||
* Media apps almost never publish lyrics in their session metadata, so these are the only general
|
||||
* source. Each lookup sends the track's title, artist, album and length to these services, which
|
||||
* is why it happens only when the driver opens the lyrics view, never in the background. With
|
||||
* [CarSettings.moreLyrics] off, only LRCLIB is asked.
|
||||
*
|
||||
* Results are cached twice over: in memory for the life of the process, and on disk under the
|
||||
* cache directory so a song looked up on a past drive doesn't need the network again on this one.
|
||||
@@ -33,33 +60,104 @@ object Lyrics {
|
||||
|
||||
private val cache = HashMap<String, LyricsResult?>()
|
||||
|
||||
/** Longest any one source may take, DNS included: a server that never answers can't hold the rest up. */
|
||||
private const val SOURCE_TIMEOUT_MS = 12_000L
|
||||
/** How long a cached song waits before its sources are asked again for better lyrics. */
|
||||
private const val RECHECK_MS = 24 * 60 * 60 * 1000L
|
||||
private val io = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
/** Word-timed beats line-timed, which beats plain text. */
|
||||
private const val BEST = 3
|
||||
private fun quality(result: LyricsResult?): Int = when {
|
||||
result == null -> 0
|
||||
!result.synced -> 1
|
||||
result.lines.any { it.words.isNotEmpty() } -> BEST
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/** One place to look, with the best [quality] it can give. */
|
||||
private class Source(val name: String, val best: Int, val lookup: () -> LyricsResult?)
|
||||
|
||||
private fun sources(title: String, artist: String, album: String, durationMs: Long): List<Source> {
|
||||
val more = CarSettings.moreLyrics.value
|
||||
val seconds = (durationMs / 1000).toInt()
|
||||
return buildList {
|
||||
if (more) add(Source("BetterLyrics", BEST) { betterLyrics(title, artist, album, seconds) })
|
||||
add(Source("LRCLIB", 2) { exact(title, artist, album, durationMs) ?: search(title, artist) })
|
||||
if (more) add(Source("KuGou", 2) { kuGou(title, artist, album, seconds) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(title: String, artist: String, album: String, durationMs: Long) = "$title|$artist|$album|$durationMs"
|
||||
|
||||
suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
|
||||
if (title.isBlank()) return null
|
||||
val key = "$title|$artist|$album|$durationMs"
|
||||
synchronized(cache) { if (cache.containsKey(key)) return cache[key] }
|
||||
val key = key(title, artist, album, durationMs)
|
||||
// A miss with only LRCLIB isn't a miss once more sources are on, so the mode is part of it.
|
||||
val memoryKey = "${CarSettings.moreLyrics.value}|$key"
|
||||
synchronized(cache) { if (cache.containsKey(memoryKey)) return cache[memoryKey] }
|
||||
|
||||
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
|
||||
if (fromDisk != null) {
|
||||
synchronized(cache) { cache[key] = fromDisk }
|
||||
synchronized(cache) { cache[memoryKey] = fromDisk }
|
||||
return fromDisk
|
||||
}
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
exact(title, artist, album, durationMs) ?: search(title, artist)
|
||||
}.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull()
|
||||
}
|
||||
synchronized(cache) { cache[key] = result }
|
||||
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result) }
|
||||
val result = firstBetter(sources(title, artist, album, durationMs), than = 0)
|
||||
synchronized(cache) { cache[memoryKey] = result }
|
||||
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result, System.currentTimeMillis()) }
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* After [find] answered with [current], looks for better lyrics than it: word-timed where it has
|
||||
* line-timed, timed where it has plain. Only the sources that could beat it are asked, and only
|
||||
* once a day per song, so an old cache entry improves when a source gains the song without every
|
||||
* play sending the song out again. Returns the better lyrics, or null to keep [current].
|
||||
*/
|
||||
suspend fun upgrade(
|
||||
context: Context, title: String, artist: String, album: String, durationMs: Long, current: LyricsResult,
|
||||
): LyricsResult? {
|
||||
val have = quality(current)
|
||||
if (have >= BEST || title.isBlank()) return null
|
||||
val key = key(title, artist, album, durationMs)
|
||||
val checked = withContext(Dispatchers.IO) { checkedAt(context, key) }
|
||||
if (System.currentTimeMillis() - checked < RECHECK_MS) return null
|
||||
|
||||
val candidates = sources(title, artist, album, durationMs).filter { it.best > have && it.name != current.source }
|
||||
val better = firstBetter(candidates, than = have)
|
||||
withContext(Dispatchers.IO) { writeDisk(context, key, better ?: current, System.currentTimeMillis()) }
|
||||
if (better == null) return null
|
||||
GearslipLog.i("lyrics: replaced ${current.source.ifEmpty { "cached" }} lyrics with ${better.source}'s")
|
||||
synchronized(cache) { cache["${CarSettings.moreLyrics.value}|$key"] = better }
|
||||
return better
|
||||
}
|
||||
|
||||
/** Asks [sources] in order and returns the first answer of a higher [quality] than [than]. */
|
||||
private suspend fun firstBetter(sources: List<Source>, than: Int): LyricsResult? {
|
||||
for (source in sources) {
|
||||
val job = io.async {
|
||||
runCatching(source.lookup).onFailure { GearslipLog.w("lyrics: ${source.name} failed: ${it.message}") }.getOrNull()
|
||||
}
|
||||
val result = withTimeoutOrNull(SOURCE_TIMEOUT_MS) { job.await() }
|
||||
?.let { LyricsResult(it.lines, it.synced, source.name) }
|
||||
if (quality(result) > than) {
|
||||
GearslipLog.i("lyrics: found on ${source.name} (${if (result!!.synced) "synced" else "plain"})")
|
||||
return result
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun cacheFile(context: Context, key: String): File {
|
||||
val digest = MessageDigest.getInstance("SHA-1").digest(key.toByteArray())
|
||||
val name = digest.joinToString("") { "%02x".format(it) }
|
||||
return File(context.cacheDir, "lyrics").apply { mkdirs() }.resolve("$name.json")
|
||||
return File(context.cacheDir, LYRICS_DIR).apply { mkdirs() }.resolve("$name.json")
|
||||
}
|
||||
|
||||
/** Where the lyrics cache lives under the app's cache directory, for the storage screen too. */
|
||||
const val LYRICS_DIR = "lyrics"
|
||||
|
||||
private fun readDisk(context: Context, key: String): LyricsResult? = runCatching {
|
||||
val file = cacheFile(context, key)
|
||||
if (!file.exists()) return null
|
||||
@@ -67,23 +165,153 @@ object Lyrics {
|
||||
val lines = json.getJSONArray("lines").let { arr ->
|
||||
(0 until arr.length()).map { i ->
|
||||
val line = arr.getJSONObject(i)
|
||||
LyricLine(line.getLong("t"), line.getString("x"))
|
||||
val words = line.optJSONArray("w")?.let { w ->
|
||||
(0 until w.length()).map { j ->
|
||||
val word = w.getJSONArray(j)
|
||||
LyricWord(word.getLong(0), word.getLong(1), word.getString(2))
|
||||
}
|
||||
}.orEmpty()
|
||||
LyricLine(line.getLong("t"), line.getString("x"), words)
|
||||
}
|
||||
}
|
||||
LyricsResult(lines, json.getBoolean("synced"))
|
||||
val source = json.optString("source")
|
||||
// Saved before word timings were kept: fetch again rather than wait a day for an upgrade
|
||||
// that skips the source it came from.
|
||||
if (source == "BetterLyrics" && lines.none { it.words.isNotEmpty() }) return null
|
||||
LyricsResult(lines, json.getBoolean("synced"), source)
|
||||
}.getOrNull()
|
||||
|
||||
private fun writeDisk(context: Context, key: String, result: LyricsResult) {
|
||||
/** When the song's sources were last asked; 0 for entries from before the date was kept. */
|
||||
private fun checkedAt(context: Context, key: String): Long =
|
||||
runCatching { JSONObject(cacheFile(context, key).readText()).optLong("checked") }.getOrDefault(0L)
|
||||
|
||||
private fun writeDisk(context: Context, key: String, result: LyricsResult, checked: Long) {
|
||||
runCatching {
|
||||
val lines = JSONArray()
|
||||
result.lines.forEach { line ->
|
||||
lines.put(JSONObject().put("t", line.timeMs).put("x", line.text))
|
||||
val entry = JSONObject().put("t", line.timeMs).put("x", line.text)
|
||||
if (line.words.isNotEmpty()) {
|
||||
entry.put("w", JSONArray(line.words.map { JSONArray().put(it.startMs).put(it.endMs).put(it.text) }))
|
||||
}
|
||||
lines.put(entry)
|
||||
}
|
||||
val json = JSONObject().put("synced", result.synced).put("lines", lines)
|
||||
val json = JSONObject().put("synced", result.synced).put("source", result.source)
|
||||
.put("checked", checked).put("lines", lines)
|
||||
cacheFile(context, key).writeText(json.toString())
|
||||
}.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Forgets every cached song, on disk and in memory. */
|
||||
fun clearCache(context: Context) {
|
||||
synchronized(cache) { cache.clear() }
|
||||
File(context.cacheDir, LYRICS_DIR).deleteRecursively()
|
||||
}
|
||||
|
||||
// --- BetterLyrics ---
|
||||
|
||||
private fun betterLyrics(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
|
||||
if (artist.isBlank()) return null
|
||||
val query = buildString {
|
||||
append("s=").append(enc(title)).append("&a=").append(enc(artist))
|
||||
if (seconds > 0) append("&d=").append(seconds)
|
||||
if (album.isNotBlank()) append("&al=").append(enc(album))
|
||||
}
|
||||
val body = get("https://lyrics-api.boidu.dev/getLyrics?$query") ?: return null
|
||||
val ttml = JSONObject(body).optString("ttml").takeIf { it.isNotBlank() && it != "null" } ?: return null
|
||||
return parseTtml(ttml)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple-style TTML: one `<p begin=…>` per line, its words in `<span begin=… end=…>`s with
|
||||
* spaces between; a word sung in syllables is several spans with no space. Background vocals,
|
||||
* translations and romanisations are left out, so a line reads as sung.
|
||||
*/
|
||||
private fun parseTtml(ttml: String): LyricsResult? {
|
||||
val parser = Xml.newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
setInput(StringReader(ttml))
|
||||
}
|
||||
val lines = mutableListOf<LyricLine>()
|
||||
var inP = false
|
||||
var lineBegin: Long? = null
|
||||
var firstSpanBegin: Long? = null
|
||||
var skipDepth = 0
|
||||
val text = StringBuilder()
|
||||
// The span being read, and the words of the line so far, each with the space after it.
|
||||
var spanBegin: Long? = null
|
||||
var spanEnd: Long? = null
|
||||
val words = mutableListOf<Triple<Long, Long, StringBuilder>>()
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when {
|
||||
parser.name == "p" -> {
|
||||
inP = true; text.clear(); firstSpanBegin = null; words.clear()
|
||||
lineBegin = attr(parser, "begin")?.let(::ttmlMs)
|
||||
}
|
||||
inP && skipDepth > 0 -> skipDepth++
|
||||
inP && parser.name == "span" -> {
|
||||
val role = attr(parser, "role")
|
||||
if (role == "x-bg" || role == "x-translation" || role == "x-roman") skipDepth = 1
|
||||
else {
|
||||
spanBegin = attr(parser, "begin")?.let(::ttmlMs)
|
||||
spanEnd = attr(parser, "end")?.let(::ttmlMs)
|
||||
if (firstSpanBegin == null) firstSpanBegin = spanBegin
|
||||
}
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> if (inP && skipDepth == 0) {
|
||||
val raw = parser.text
|
||||
text.append(raw)
|
||||
val begin = spanBegin
|
||||
val end = spanEnd
|
||||
if (begin != null && end != null && raw.isNotBlank()) {
|
||||
val word = raw.replace(Regex("\\s+"), " ").trimStart()
|
||||
words.add(Triple(begin, end, StringBuilder(word)))
|
||||
} else if (raw.isNotEmpty() && raw.isBlank() && words.isNotEmpty() && !words.last().third.endsWith(" ")) {
|
||||
words.last().third.append(' ')
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> when {
|
||||
inP && skipDepth > 0 -> skipDepth--
|
||||
inP && parser.name == "span" -> { spanBegin = null; spanEnd = null }
|
||||
parser.name == "p" -> {
|
||||
inP = false
|
||||
val timed = words.map { (b, e, w) -> LyricWord(b, e, w.toString()) }
|
||||
val line = if (timed.isNotEmpty()) timed.joinToString("") { it.text }.trim()
|
||||
else text.toString().replace(Regex("\\s+"), " ").trim()
|
||||
if (line.isNotEmpty()) lines.add(LyricLine(lineBegin ?: firstSpanBegin ?: -1, line, timed))
|
||||
}
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
if (lines.isEmpty()) return null
|
||||
val synced = lines.all { it.timeMs >= 0 } && lines.any { it.timeMs > 0 }
|
||||
return if (synced) LyricsResult(lines.sortedBy { it.timeMs }, synced = true)
|
||||
else LyricsResult(lines.map { LyricLine(0, it.text) }, synced = false)
|
||||
}
|
||||
|
||||
private fun attr(parser: XmlPullParser, localName: String): String? =
|
||||
(0 until parser.attributeCount).firstOrNull { parser.getAttributeName(it) == localName }
|
||||
?.let { parser.getAttributeValue(it) }
|
||||
|
||||
/** TTML times: `1:23.456`, `1:02:03.4`, `83.456s`, `250ms` or a bare number of seconds. */
|
||||
private fun ttmlMs(raw: String): Long? {
|
||||
val t = raw.trim()
|
||||
val seconds = when {
|
||||
':' in t -> t.split(':').fold(0.0) { acc, part -> acc * 60 + (part.toDoubleOrNull() ?: return null) }
|
||||
t.endsWith("ms") -> (t.dropLast(2).toDoubleOrNull() ?: return null) / 1000
|
||||
t.endsWith("s") -> t.dropLast(1).toDoubleOrNull() ?: return null
|
||||
t.endsWith("m") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 60
|
||||
t.endsWith("h") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 3600
|
||||
else -> t.toDoubleOrNull() ?: return null
|
||||
}
|
||||
return (seconds * 1000).toLong()
|
||||
}
|
||||
|
||||
// --- LRCLIB ---
|
||||
|
||||
private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
|
||||
if (artist.isBlank()) return null
|
||||
val query = buildString {
|
||||
@@ -111,6 +339,73 @@ object Lyrics {
|
||||
return LyricsResult(plain.lines().map { LyricLine(0, it) }, synced = false)
|
||||
}
|
||||
|
||||
// --- KuGou ---
|
||||
|
||||
/** Songs within this many seconds of the playing one count as the same recording. */
|
||||
private const val KUGOU_DURATION_SLACK = 8
|
||||
private val KUGOU_TIMED = Regex("""\[(\d\d):(\d\d)\.(\d{2,3})].*""")
|
||||
/** A credit line such as `[00:01.00]作词:…` or `[00:02.00]Composer: …`. */
|
||||
private val KUGOU_CREDIT = Regex(""".+].+[::].+""")
|
||||
private const val KUGOU_CREDIT_LINES = 30
|
||||
|
||||
private fun kuGou(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
|
||||
val keyword = buildString {
|
||||
append(kuGouTitle(title)).append(" - ").append(kuGouArtist(artist))
|
||||
if (album.isNotBlank()) append(' ').append(album)
|
||||
}
|
||||
val encoded = URLEncoder.encode(keyword, "UTF-8").replace("+", "%20")
|
||||
// First the song itself, matched by length, then its lyrics by the song's hash.
|
||||
val songs = get(
|
||||
"https://mobileservice.kugou.com/api/v3/search/song?version=9108&plat=0&pagesize=8&showtype=0&keyword=$encoded",
|
||||
)?.let { JSONObject(it).optJSONObject("data")?.optJSONArray("info") }
|
||||
if (songs != null) for (i in 0 until songs.length()) {
|
||||
val song = songs.getJSONObject(i)
|
||||
if (seconds > 0 && abs(song.optInt("duration") - seconds) > KUGOU_DURATION_SLACK) continue
|
||||
val hash = song.optString("hash").takeIf { it.isNotBlank() } ?: continue
|
||||
kuGouCandidate("hash=" + enc(hash))?.let { return it }
|
||||
}
|
||||
// Then a plain lyrics search by name.
|
||||
val byName = "keyword=$encoded" + if (seconds > 0) "&duration=${seconds * 1000}" else ""
|
||||
return kuGouCandidate(byName)
|
||||
}
|
||||
|
||||
private fun kuGouCandidate(query: String): LyricsResult? {
|
||||
val body = get("https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&$query") ?: return null
|
||||
val candidate = JSONObject(body).optJSONArray("candidates")?.optJSONObject(0) ?: return null
|
||||
val id = candidate.optLong("id").takeIf { it != 0L } ?: return null
|
||||
val accessKey = candidate.optString("accesskey").takeIf { it.isNotBlank() } ?: return null
|
||||
val download = get(
|
||||
"https://lyrics.kugou.com/download?fmt=lrc&charset=utf8&client=pc&ver=1&id=$id&accesskey=${enc(accessKey)}",
|
||||
) ?: return null
|
||||
val content = JSONObject(download).optString("content").takeIf { it.isNotBlank() } ?: return null
|
||||
val lrc = String(Base64.decode(content, Base64.DEFAULT), Charsets.UTF_8)
|
||||
val lines = parseLrc(kuGouTrim(lrc).joinToString("\n")).filter { it.text.isNotEmpty() }
|
||||
return if (lines.isEmpty()) null else LyricsResult(lines, synced = true)
|
||||
}
|
||||
|
||||
/** Keeps the timed lines and drops the songwriter and producer credits KuGou puts at either end. */
|
||||
private fun kuGouTrim(lrc: String): List<String> {
|
||||
val timed = lrc.lines().filter { it.matches(KUGOU_TIMED) }
|
||||
if (timed.isEmpty()) return timed
|
||||
val headLimit = minOf(KUGOU_CREDIT_LINES, timed.lastIndex)
|
||||
val head = (headLimit downTo 0).firstOrNull { timed[it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
|
||||
val rest = timed.drop(head)
|
||||
if (rest.isEmpty()) return rest
|
||||
val tailLimit = minOf(KUGOU_CREDIT_LINES, rest.lastIndex)
|
||||
val tail = (tailLimit downTo 0).firstOrNull { rest[rest.lastIndex - it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
|
||||
return rest.dropLast(tail)
|
||||
}
|
||||
|
||||
/** KuGou's titles leave out bracketed extras such as "(feat. …)" or "(Live)". */
|
||||
private fun kuGouTitle(title: String) = listOf("\\(.*\\)", "(.*)", "「.*」", "『.*』", "<.*>", "《.*》", "〈.*〉", "<.*>")
|
||||
.fold(title) { acc, pattern -> acc.replace(Regex(pattern), "") }.trim()
|
||||
|
||||
/** KuGou joins several artists with "、". */
|
||||
private fun kuGouArtist(artist: String) = artist.replace(", ", "、").replace(" & ", "、").replace(".", "")
|
||||
.replace(Regex("\\(.*\\)"), "").replace(Regex("(.*)"), "").trim()
|
||||
|
||||
// --- shared ---
|
||||
|
||||
/** `[01:23.45] words` - a line may carry several stamps when a chorus repeats. */
|
||||
private fun parseLrc(text: String): List<LyricLine> {
|
||||
val stamp = Regex("""\[(\d+):(\d+(?:\.\d+)?)]""")
|
||||
@@ -129,6 +424,7 @@ object Lyrics {
|
||||
connectTimeout = 5_000
|
||||
readTimeout = 8_000
|
||||
setRequestProperty("User-Agent", "Gearslip (car media host)")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
}
|
||||
return try {
|
||||
if (connection.responseCode != 200) null else connection.inputStream.bufferedReader().readText()
|
||||
|
||||
@@ -26,7 +26,7 @@ object WorkedPrompt {
|
||||
private var loaded = false
|
||||
private val flow = MutableStateFlow<String?>(null)
|
||||
|
||||
/** The car's name while the ask is waiting for an answer, otherwise null. */
|
||||
/** The car's name while the ask is waiting for an answer (empty when the car didn't say), otherwise null. */
|
||||
fun pending(context: Context): StateFlow<String?> {
|
||||
if (!loaded) {
|
||||
loaded = true
|
||||
@@ -99,17 +99,14 @@ object WorkedPrompt {
|
||||
private fun saved(context: Context): JSONObject? =
|
||||
prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
|
||||
/** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */
|
||||
/** "2019 Clio", "Renault Media Nav", or empty when the car didn't say. */
|
||||
private fun carName(car: JSONObject): String {
|
||||
val model = car.optString("car").trim()
|
||||
val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" }
|
||||
val unit = "${car.optString("make")} ${car.optString("model")}".trim()
|
||||
return when {
|
||||
model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ")
|
||||
unit.isNotEmpty() -> "your $unit"
|
||||
else -> "your car"
|
||||
model.isNotEmpty() -> listOfNotNull(year, model).joinToString(" ")
|
||||
else -> unit
|
||||
}
|
||||
}
|
||||
|
||||
const val TITLE_START = "Gearslip worked with "
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -11,6 +13,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -44,6 +47,7 @@ internal object GearslipLinks {
|
||||
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
|
||||
const val SOURCE = "https://github.com/twinksoftware/gearslip"
|
||||
const val CARS = "$SOURCE/blob/main/CARS.md"
|
||||
const val TRANSLATE = "https://hosted.weblate.org/engage/gearslip/"
|
||||
|
||||
/** This version's release notes on GitHub; every release is tagged v plus its version name. */
|
||||
val RELEASE_NOTES = "$SOURCE/releases/tag/v${app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME}"
|
||||
@@ -59,14 +63,14 @@ internal object GearslipLinks {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("monero:$MONERO")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}.onFailure {
|
||||
Toast.makeText(context, "No Monero wallet app found. Copy the address instead.", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(context, context.getString(R.string.about_no_wallet), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun copyMonero(context: Context) {
|
||||
context.getSystemService(ClipboardManager::class.java)
|
||||
?.setPrimaryClip(ClipData.newPlainText("Monero address", MONERO))
|
||||
Toast.makeText(context, "Monero address copied", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.about_monero_copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +88,7 @@ internal fun DonateRows() {
|
||||
SettingsRow(
|
||||
"Liberapay",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
|
||||
subtitle = "Give weekly or monthly",
|
||||
subtitle = stringResource(R.string.about_give_regularly),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
@@ -92,16 +96,16 @@ internal fun DonateRows() {
|
||||
SettingsRow(
|
||||
"Buy Me a Coffee",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.BUY_ME_A_COFFEE) },
|
||||
subtitle = "Give once or monthly, by card. Buy Me a Coffee keeps 5%",
|
||||
subtitle = stringResource(R.string.about_bmac_sub),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Give once",
|
||||
stringResource(R.string.about_give_once),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PAYMENT_LINK) },
|
||||
subtitle = "Any amount, by card",
|
||||
subtitle = stringResource(R.string.about_any_amount),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
@@ -112,7 +116,7 @@ internal fun DonateRows() {
|
||||
SettingsRow(
|
||||
"Monero",
|
||||
Modifier.clickable { moneroOpen = true },
|
||||
subtitle = "Open your wallet or copy the address",
|
||||
subtitle = stringResource(R.string.about_monero_sub),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
@@ -127,10 +131,10 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
|
||||
title = { Text("Support with Monero") },
|
||||
title = { Text(stringResource(R.string.about_monero_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Send any amount to this address from your Monero wallet.")
|
||||
Text(stringResource(R.string.about_monero_body))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
GearslipLinks.MONERO,
|
||||
@@ -143,20 +147,20 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
|
||||
confirmButton = {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { GearslipLinks.openMoneroWallet(context) }, Modifier.fillMaxWidth()) {
|
||||
Text("Open in wallet")
|
||||
Text(stringResource(R.string.about_open_wallet))
|
||||
}
|
||||
OutlinedButton(onClick = { GearslipLinks.copyMonero(context) }, Modifier.fillMaxWidth()) {
|
||||
Text("Copy address")
|
||||
Text(stringResource(R.string.about_copy_address))
|
||||
}
|
||||
TextButton(onClick = onDismiss, Modifier.fillMaxWidth()) {
|
||||
Text("Close")
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The About section's links: the chat room, the source code, and credits. */
|
||||
/** The About section's links: the chat room, translations, the source code, and credits. */
|
||||
@Composable
|
||||
internal fun AboutLinkRows() {
|
||||
val context = LocalContext.current
|
||||
@@ -168,25 +172,32 @@ internal fun AboutLinkRows() {
|
||||
)
|
||||
}
|
||||
SettingsRow(
|
||||
"Join the chat on Matrix",
|
||||
stringResource(R.string.about_matrix),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) },
|
||||
subtitle = "Talk with other drivers about cars, apps, and Gearslip",
|
||||
subtitle = stringResource(R.string.about_matrix_sub),
|
||||
icon = Icons.AutoMirrored.Filled.Send,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Source code",
|
||||
stringResource(R.string.about_translate),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.TRANSLATE) },
|
||||
subtitle = stringResource(R.string.about_translate_sub),
|
||||
icon = Icons.Filled.Edit,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
stringResource(R.string.about_source),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) },
|
||||
subtitle = "Read or change Gearslip's code under the AGPL",
|
||||
subtitle = stringResource(R.string.about_source_sub),
|
||||
icon = Icons.Filled.Build,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Voice credits",
|
||||
subtitle = "Speech recognition by sherpa-onnx (Apache 2.0), with Kroko ASR by Banafo " +
|
||||
"(CC BY-SA 4.0), and Parakeet and Canary by NVIDIA (CC BY 4.0)",
|
||||
stringResource(R.string.about_voice_credits),
|
||||
subtitle = stringResource(R.string.about_voice_credits_sub),
|
||||
icon = Icons.Filled.Favorite,
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -24,27 +33,30 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
|
||||
var showCommand by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepAsking,
|
||||
title = { Text("Play car audio over Bluetooth?") },
|
||||
title = { Text(stringResource(R.string.audio_bt_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Android treats music sent over USB like sharing your screen, so it asks every " +
|
||||
"drive. Over Bluetooth it never asks. You can change this later in Settings.",
|
||||
)
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.audio_bt_body))
|
||||
if (showCommand) {
|
||||
Text(
|
||||
"Or keep USB and stop the question for good: plug the phone into a " +
|
||||
"computer with USB debugging on, and run this once.",
|
||||
stringResource(R.string.audio_keep_usb),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow")
|
||||
} else {
|
||||
TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") }
|
||||
}
|
||||
// Stacked full width: three labels this long never fit the dialog's one button row.
|
||||
Column(Modifier.fillMaxWidth().padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onUseBluetooth, Modifier.fillMaxWidth()) { Text(stringResource(R.string.audio_use_bt)) }
|
||||
OutlinedButton(onClick = onKeepAsking, Modifier.fillMaxWidth()) { Text(stringResource(R.string.audio_keep_asking)) }
|
||||
if (!showCommand) {
|
||||
TextButton(onClick = { showCommand = true }, Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.audio_never_ask))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } },
|
||||
dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } },
|
||||
confirmButton = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,15 +69,13 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
|
||||
fun MicBlockedDialog(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Car audio needs the microphone permission") },
|
||||
title = { Text(stringResource(R.string.audio_mic_title)) },
|
||||
text = {
|
||||
Text(
|
||||
"Android lets an app send other apps' sound to the car only if it has the " +
|
||||
"microphone permission. It's turned off for Gearslip, so music plays on the " +
|
||||
"phone. Turn it on under Permissions, then Microphone.",
|
||||
stringResource(R.string.audio_mic_body),
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onOpenSettings) { Text("Open app settings") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Not now") } },
|
||||
confirmButton = { TextButton(onClick = onOpenSettings) { Text(stringResource(R.string.audio_open_settings)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.worked_not_now)) } },
|
||||
)
|
||||
}
|
||||
@@ -71,6 +71,14 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
|
||||
val projector = remember { ScreenProjector(context) }
|
||||
|
||||
// The preview is something to watch, like a map on a dash mount, so the phone stays awake on
|
||||
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
|
||||
val view = androidx.compose.ui.platform.LocalView.current
|
||||
DisposableEffect(view) {
|
||||
view.keepScreenOn = true
|
||||
onDispose { view.keepScreenOn = false }
|
||||
}
|
||||
|
||||
DisposableEffect(width, height, density) {
|
||||
CarEnvironment.start(context)
|
||||
// No surface yet: the SurfaceView below hands its own over once it exists, and the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -41,19 +43,18 @@ internal fun CarScreenSection() {
|
||||
|
||||
if (editingCar) {
|
||||
SizeDialog(
|
||||
title = "Car screen size",
|
||||
note = "Use this if the car's picture looks stretched. Enter the screen's real size, " +
|
||||
"such as 1280x480. It takes effect the next time you connect.",
|
||||
title = stringResource(R.string.screen_car_size),
|
||||
note = stringResource(R.string.screen_car_size_note),
|
||||
initial = size,
|
||||
clearLabel = "Use the car's",
|
||||
clearLabel = stringResource(R.string.screen_use_cars),
|
||||
onDismiss = { editingCar = false },
|
||||
onSave = { CarSettings.setScreenSize(it); editingCar = false },
|
||||
)
|
||||
}
|
||||
if (editingPreview) {
|
||||
SizeDialog(
|
||||
title = "Preview size",
|
||||
note = "The size the car preview draws at, such as 1920x720.",
|
||||
title = stringResource(R.string.screen_preview_size),
|
||||
note = stringResource(R.string.screen_preview_note),
|
||||
initial = previewCustom,
|
||||
clearLabel = null,
|
||||
onDismiss = { editingPreview = false },
|
||||
@@ -65,62 +66,62 @@ internal fun CarScreenSection() {
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
"Car screen",
|
||||
footer = "Gearslip uses the size the car reports unless you set one.",
|
||||
stringResource(R.string.screen_car_screen),
|
||||
footer = stringResource(R.string.screen_footer),
|
||||
) {
|
||||
SettingsRow(
|
||||
"Screen size",
|
||||
stringResource(R.string.screen_size),
|
||||
Modifier.clickable { editingCar = true },
|
||||
subtitle = size?.let { "$it, set by you" } ?: "From the car",
|
||||
subtitle = size?.let { stringResource(R.string.screen_set_by_you, it.toString()) } ?: stringResource(R.string.screen_from_car),
|
||||
)
|
||||
if (size != null) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Crop the edges",
|
||||
stringResource(R.string.screen_crop),
|
||||
Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) },
|
||||
subtitle = "For a car that cuts off the top and bottom, or the sides",
|
||||
subtitle = stringResource(R.string.screen_crop_sub),
|
||||
trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Squash to fit",
|
||||
stringResource(R.string.screen_squash),
|
||||
Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) },
|
||||
subtitle = "For a car that squeezes the whole picture, so circles look like ovals",
|
||||
subtitle = stringResource(R.string.screen_squash_sub),
|
||||
trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection("Car preview size") {
|
||||
SettingsSection(stringResource(R.string.screen_preview_title)) {
|
||||
SettingsRow(
|
||||
"Last car",
|
||||
stringResource(R.string.screen_last_car),
|
||||
Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) {
|
||||
CarSettings.setPreviewSize(PreviewSize.LAST_CAR)
|
||||
},
|
||||
subtitle = "The size of the last car you connected to",
|
||||
subtitle = stringResource(R.string.screen_last_car_sub),
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"This screen",
|
||||
stringResource(R.string.screen_this_screen),
|
||||
Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) {
|
||||
CarSettings.setPreviewSize(PreviewSize.FILL)
|
||||
},
|
||||
subtitle = "Fills this device's screen, for a head unit running Gearslip itself",
|
||||
subtitle = stringResource(R.string.screen_this_screen_sub),
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Custom",
|
||||
stringResource(R.string.screen_custom),
|
||||
Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) {
|
||||
if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM)
|
||||
},
|
||||
subtitle = previewCustom?.toString() ?: "Enter a size",
|
||||
subtitle = previewCustom?.toString() ?: stringResource(R.string.screen_enter_size),
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) },
|
||||
)
|
||||
if (preview == PreviewSize.CUSTOM) {
|
||||
SettingsDivider()
|
||||
SettingsRow("Change the custom size", Modifier.clickable { editingPreview = true })
|
||||
SettingsRow(stringResource(R.string.screen_change_custom), Modifier.clickable { editingPreview = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,14 +146,14 @@ private fun SizeDialog(
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(note)
|
||||
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text("Width") }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text("Height") }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_width)) }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_height)) }, singleLine = true, keyboardOptions = number)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text("Save") } },
|
||||
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text(stringResource(R.string.save)) } },
|
||||
dismissButton = {
|
||||
if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) }
|
||||
else TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
else TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -80,7 +82,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
"Certificate Setup",
|
||||
stringResource(R.string.certsetup_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
@@ -88,9 +90,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
if (installed) {
|
||||
"Cars accept only Google-issued certificates, and you have one loaded."
|
||||
stringResource(R.string.certsetup_loaded)
|
||||
} else {
|
||||
"Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from."
|
||||
stringResource(R.string.certsetup_choose)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -100,14 +102,24 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
|
||||
if (installed) {
|
||||
Text(
|
||||
if (manualProgress) "Certificate imported." else "Certificate installed.",
|
||||
if (manualProgress) stringResource(R.string.cert_imported) else stringResource(R.string.certsetup_installed),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
// A downloaded certificate works in most cars, not all: say where to go when it doesn't.
|
||||
if (!manualProgress) {
|
||||
Text(
|
||||
stringResource(R.string.certsetup_installed_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Continue")
|
||||
Text(stringResource(R.string.certsetup_continue))
|
||||
}
|
||||
Spacer(Modifier.height(40.dp))
|
||||
return@Column
|
||||
@@ -130,11 +142,10 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text("Download from aasdk")
|
||||
Text(stringResource(R.string.cert_download))
|
||||
}
|
||||
Text(
|
||||
"The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " +
|
||||
"Most cars accept it; some from around 2020 on don't.",
|
||||
stringResource(R.string.certsetup_aasdk_sub),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -146,7 +157,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) {
|
||||
DownloadingRow {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text("Downloading…", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.cert_downloading), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +168,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Download failed",
|
||||
stringResource(R.string.certsetup_failed),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
@@ -168,7 +179,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Retry")
|
||||
Text(stringResource(R.string.certsetup_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,9 +191,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = { importLauncher.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Import my own file") }
|
||||
) { Text(stringResource(R.string.cert_import)) }
|
||||
Text(
|
||||
"A .p12 file with its private key, from wherever you choose.",
|
||||
stringResource(R.string.certsetup_import_sub),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -197,7 +208,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) {
|
||||
Text("Skip car connection setup")
|
||||
Text(stringResource(R.string.certsetup_skip))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
@@ -216,7 +227,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
pendingUri = null
|
||||
manualProgress = true
|
||||
}.onFailure {
|
||||
reportError("Wrong password or not a valid PKCS#12 file with a private key.")
|
||||
reportError(context.getString(R.string.cert_import_failed))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -224,7 +235,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A tight horizontal row for the inline "Downloading…" spinner state. */
|
||||
/** A tight horizontal row for the inline stringResource(R.string.cert_downloading) spinner state. */
|
||||
@Composable
|
||||
private fun DownloadingRow(content: @Composable RowScope.() -> Unit) {
|
||||
Row(
|
||||
@@ -248,20 +259,20 @@ private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Certificate password") },
|
||||
title = { Text(stringResource(R.string.cert_password_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it; error = null },
|
||||
singleLine = true,
|
||||
label = { Text("Password") },
|
||||
label = { Text(stringResource(R.string.cert_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = error != null,
|
||||
supportingText = { Text(error ?: "Leave empty if the file has none.") },
|
||||
supportingText = { Text(error ?: stringResource(R.string.cert_password_hint)) },
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text(stringResource(R.string.cert_import_button)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import java.text.DateFormat
|
||||
@@ -28,20 +29,20 @@ data class CertSummary(
|
||||
val identity = CertProvider.loadSupplied(context)
|
||||
?: return CertSummary(
|
||||
CertProvider.Kind.SELF_SIGNED,
|
||||
"None loaded (self-signed; cars reject it)",
|
||||
context.getString(R.string.cert_none),
|
||||
null, null, null,
|
||||
)
|
||||
val cert = identity.certificate
|
||||
val name = friendlyName(cert.subjectX500Principal.name)
|
||||
val prefix = when (identity.kind) {
|
||||
CertProvider.Kind.IMPORTED -> "Imported"
|
||||
CertProvider.Kind.DOWNLOADED -> "Downloaded"
|
||||
CertProvider.Kind.ADB_STAGED -> "Staged over adb"
|
||||
CertProvider.Kind.SELF_SIGNED -> "Self-signed"
|
||||
val headline = when (identity.kind) {
|
||||
CertProvider.Kind.IMPORTED -> R.string.cert_kind_imported
|
||||
CertProvider.Kind.DOWNLOADED -> R.string.cert_kind_downloaded
|
||||
CertProvider.Kind.ADB_STAGED -> R.string.cert_kind_adb
|
||||
CertProvider.Kind.SELF_SIGNED -> R.string.cert_kind_self
|
||||
}
|
||||
return CertSummary(
|
||||
identity.kind,
|
||||
"$prefix: $name",
|
||||
context.getString(headline, name),
|
||||
cert.subjectX500Principal.name,
|
||||
cert.issuerX500Principal.name,
|
||||
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -29,7 +31,7 @@ fun CopyableCommand(command: String) {
|
||||
shape = MaterialTheme.shapes.small,
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(command))
|
||||
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.command_copied), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
@@ -41,7 +43,7 @@ fun CopyableCommand(command: String) {
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
"Tap to copy",
|
||||
stringResource(R.string.command_tap_copy),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -100,31 +102,30 @@ internal fun DonateDialog(live: Boolean) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { DonatePrompt.answer(context, donated = false) },
|
||||
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
|
||||
title = { Text("Glad that drive worked") },
|
||||
title = { Text(stringResource(R.string.donate_title)) },
|
||||
text = {
|
||||
Text(
|
||||
"Gearslip is free, with no ads and no tracking. If it gets you on the road, your " +
|
||||
"support keeps it going.",
|
||||
stringResource(R.string.donate_body),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
|
||||
Text("Support on Liberapay")
|
||||
Text(stringResource(R.string.donate_liberapay))
|
||||
}
|
||||
OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) {
|
||||
Text("Buy me a coffee")
|
||||
Text(stringResource(R.string.donate_bmac))
|
||||
}
|
||||
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
|
||||
Text("Give once by card")
|
||||
Text(stringResource(R.string.donate_card))
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) {
|
||||
Text("Monero")
|
||||
}
|
||||
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
|
||||
Text("Not now")
|
||||
Text(stringResource(R.string.worked_not_now))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.background
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
@@ -37,7 +43,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, SAVED_PLACES }
|
||||
|
||||
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||
@Composable
|
||||
@@ -47,8 +53,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 +63,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
|
||||
@@ -95,17 +101,25 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.consumeWindowInsets(WindowInsets.statusBars),
|
||||
) {
|
||||
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW && screen != Screen.INTRO) DebugBuildBanner()
|
||||
// On a debug build, Home's yellow header runs up behind the status bar, whose icons then go dark.
|
||||
val debugHeader = BuildConfig.DEBUG && screen == Screen.HOME
|
||||
val view = LocalView.current
|
||||
val darkTheme = gearslipDark()
|
||||
SideEffect {
|
||||
val window = (view.context as? Activity)?.window ?: return@SideEffect
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = debugHeader || !darkTheme
|
||||
}
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsTopHeight(WindowInsets.statusBars)
|
||||
.background(if (debugHeader) DebugYellow else Color.Transparent),
|
||||
)
|
||||
|
||||
Column(Modifier.weight(1f).consumeWindowInsets(WindowInsets.statusBars)) {
|
||||
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 +127,15 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
onOpenCarPreview = { backTo = Screen.HOME; screen = Screen.CAR_PREVIEW },
|
||||
onOpenLogs = { backTo = Screen.HOME; screen = Screen.LOGS },
|
||||
onDisconnect = onDisconnect,
|
||||
onOpenCarGroup = {
|
||||
if (it == CarGroup.PLACES) {
|
||||
screen = Screen.SAVED_PLACES
|
||||
} else {
|
||||
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(
|
||||
@@ -123,6 +145,7 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
atUsageNotes = settingsAtUsageNotes,
|
||||
)
|
||||
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
||||
Screen.SAVED_PLACES -> SavedPlacesScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = backTo })
|
||||
Screen.HELP -> HelpScreen(
|
||||
onBack = { screen = backTo },
|
||||
@@ -149,26 +172,3 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
PhoneTypingBar()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug builds only ([BuildConfig.DEBUG]); a release build never composes this. Sits above every
|
||||
* phone screen, below the status bar - not the car screen, which is a separate render target
|
||||
* ([app.seb3thehacker.gearslip.car.CarUi] carries its own corner badge) - so a debug install is
|
||||
* never mistaken for a release one.
|
||||
*/
|
||||
@Composable
|
||||
private fun DebugBuildBanner() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(28.dp),
|
||||
color = Color(0xFFF5C518),
|
||||
contentColor = Color.Black,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"DEBUG BUILD",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,32 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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 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 = gearslipDark()
|
||||
// The car's Dark mode setting, so the phone and the car screen stay one look.
|
||||
val darkStyle by app.seb3thehacker.gearslip.car.CarSettings.darkStyle.collectAsState()
|
||||
val scheme = (if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)).withTintedSurfaces(dark, darkStyle)
|
||||
MaterialTheme(colorScheme = scheme, content = content)
|
||||
}
|
||||
|
||||
/** Whether the phone app is dark: the car's App theme setting, so Light means light everywhere. */
|
||||
@Composable
|
||||
fun gearslipDark(): Boolean {
|
||||
val theme by app.seb3thehacker.gearslip.car.CarSettings.appTheme.collectAsState()
|
||||
return when (theme) {
|
||||
app.seb3thehacker.gearslip.car.AppTheme.PHONE -> isSystemInDarkTheme()
|
||||
app.seb3thehacker.gearslip.car.AppTheme.LIGHT -> false
|
||||
app.seb3thehacker.gearslip.car.AppTheme.DARK -> true
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -40,54 +43,48 @@ import androidx.compose.ui.unit.dp
|
||||
* that can, in one tap, in which case it is shown as a button instead.
|
||||
*/
|
||||
private class Step(
|
||||
val action: String,
|
||||
val detail: String? = null,
|
||||
@param:StringRes val action: Int,
|
||||
@param:StringRes val detail: Int? = null,
|
||||
val command: String? = null,
|
||||
val buttonLabel: String? = null,
|
||||
@param:StringRes val buttonLabel: Int? = null,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
private val FIRST_DRIVE = listOf(
|
||||
Step("Turn off Android Auto.", "Disable or uninstall the Android Auto app, so the car talks to Gearslip instead."),
|
||||
Step("Load a certificate.", "In Settings, download one or import your own."),
|
||||
Step(R.string.help_aa_off, R.string.help_aa_off_detail),
|
||||
Step(R.string.help_cert, R.string.help_cert_detail),
|
||||
Step(
|
||||
"Allow the microphone.",
|
||||
"Android won't let an app pass music to the car without it. Gearslip listens only when " +
|
||||
"you tap the mic or press the talk button on your wheel.",
|
||||
R.string.help_mic,
|
||||
R.string.help_mic_detail,
|
||||
),
|
||||
)
|
||||
|
||||
private fun inTheCar(onRequestCallScreening: () -> Unit) = listOf(
|
||||
Step(
|
||||
"Plug in, or connect wirelessly.",
|
||||
"Gearslip opens by itself when the car connects. For wireless, pair the phone with the " +
|
||||
"car first, then tap Connect wirelessly on Home.",
|
||||
R.string.help_plug_in,
|
||||
R.string.help_plug_in_detail,
|
||||
),
|
||||
Step(
|
||||
"Pair over Bluetooth for calls.",
|
||||
"Calls play through the car once your phone and the car are paired. Gearslip offers to " +
|
||||
"pair the first time you plug in.",
|
||||
R.string.help_pair,
|
||||
R.string.help_pair_detail,
|
||||
),
|
||||
Step(
|
||||
"Answer calls from the car screen.",
|
||||
"Set Gearslip as your caller ID and spam app. Without it, calls still ring, but only " +
|
||||
"the phone can answer them.",
|
||||
buttonLabel = "Set up",
|
||||
R.string.help_answer,
|
||||
R.string.help_answer_detail,
|
||||
buttonLabel = R.string.guide_set_up,
|
||||
onClick = onRequestCallScreening,
|
||||
),
|
||||
)
|
||||
|
||||
private val USB_AUDIO = listOf(
|
||||
Step(
|
||||
"Stop the audio prompt each drive.",
|
||||
"Connect the phone to a computer with USB debugging on, and run this once. Skip it and " +
|
||||
"you tap Start each time you set off.",
|
||||
R.string.help_prompt,
|
||||
R.string.help_prompt_detail,
|
||||
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
|
||||
),
|
||||
Step(
|
||||
"Keep notifications readable.",
|
||||
"Android hides what notifications say while it shares audio. Run this too, and Gearslip " +
|
||||
"lifts that only while music goes to the car.",
|
||||
R.string.help_readable,
|
||||
R.string.help_readable_detail,
|
||||
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
|
||||
),
|
||||
)
|
||||
@@ -104,10 +101,10 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("How to connect") },
|
||||
title = { Text(stringResource(R.string.help_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -122,21 +119,20 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(0.dp))
|
||||
SettingsSection("Which cars work") {
|
||||
SettingsSection(stringResource(R.string.help_which_cars)) {
|
||||
Text(
|
||||
"Most cars work. Starting around 2020, some cars added newer security " +
|
||||
"standards that reject Gearslip's certificate, and Gearslip can't run in those cars.",
|
||||
stringResource(R.string.help_which_cars_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||
)
|
||||
FilledTonalButton(
|
||||
onClick = { GearslipLinks.open(context, GearslipLinks.CARS) },
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) { Text("See the full list") }
|
||||
) { Text(stringResource(R.string.help_full_list)) }
|
||||
}
|
||||
Steps("Before your first drive", FIRST_DRIVE)
|
||||
Steps("In the car", car)
|
||||
if (usb) Steps("USB audio", USB_AUDIO)
|
||||
Steps(stringResource(R.string.help_first_drive), FIRST_DRIVE)
|
||||
Steps(stringResource(R.string.help_in_car), car)
|
||||
if (usb) Steps(stringResource(R.string.help_usb_audio), USB_AUDIO)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
@@ -160,14 +156,14 @@ private fun Steps(title: String, steps: List<Step>) {
|
||||
modifier = Modifier.width(16.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(step.action, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(step.action), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||
step.detail?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
step.command?.let { CopyableCommand(it) }
|
||||
if (step.onClick != null) {
|
||||
FilledTonalButton(onClick = step.onClick, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(step.buttonLabel ?: "Open")
|
||||
Text(stringResource(step.buttonLabel ?: R.string.guide_open))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
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(@param:StringRes val title: Int) {
|
||||
SCREEN(R.string.car_group_screen),
|
||||
NAV_BAR(R.string.car_group_nav_bar),
|
||||
SOUND(R.string.car_group_sound),
|
||||
PLACES(R.string.places_title),
|
||||
WARNINGS(R.string.car_group_warnings),
|
||||
MORE(R.string.car_group_more),
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun sizeChoices() = listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge))
|
||||
@Composable
|
||||
private fun themeChoices() = listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark))
|
||||
@Composable
|
||||
private fun darkChoices() = listOf(
|
||||
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
|
||||
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
|
||||
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
|
||||
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
|
||||
)
|
||||
@Composable
|
||||
private fun nightChoices() = listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night))
|
||||
@Composable
|
||||
private fun unitChoices() = listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.choice_units_imperial), Units.METRIC to stringResource(R.string.choice_units_metric))
|
||||
@Composable
|
||||
private fun navBarChoices() = listOf(
|
||||
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
|
||||
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
|
||||
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
|
||||
)
|
||||
@Composable
|
||||
private fun openChoices() = listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) }
|
||||
|
||||
/**
|
||||
* 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()
|
||||
val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
|
||||
val opens = openChoices()
|
||||
val sizes = sizeChoices()
|
||||
val themes = themeChoices()
|
||||
val navBars = navBarChoices()
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
fun str(id: Int) = context.getString(id)
|
||||
// Read again each time Home shows, so a place saved a moment ago is listed.
|
||||
val places = androidx.compose.runtime.remember { app.seb3thehacker.gearslip.car.SavedPlaces.all(context) }
|
||||
fun summary(group: CarGroup) = when (group) {
|
||||
CarGroup.SCREEN -> listOf(opens.label(open), sizes.label(scale), themes.label(theme))
|
||||
CarGroup.NAV_BAR -> listOf(navBars.label(navBar), str(if (playerLeft) R.string.summary_player_left else R.string.summary_player_right))
|
||||
CarGroup.SOUND -> listOf(
|
||||
str(if (pipeAudio) R.string.summary_usb_audio else R.string.summary_bluetooth_audio),
|
||||
str(if (speakDirections) R.string.summary_directions_on else R.string.summary_directions_off),
|
||||
)
|
||||
CarGroup.PLACES -> places.mapIndexedNotNull { i, place ->
|
||||
when {
|
||||
place.address.isBlank() -> null
|
||||
i == app.seb3thehacker.gearslip.car.SavedPlaces.HOME -> str(R.string.places_home)
|
||||
i == app.seb3thehacker.gearslip.car.SavedPlaces.WORK -> str(R.string.places_work)
|
||||
else -> place.label.ifBlank { context.getString(R.string.places_other, i - app.seb3thehacker.gearslip.car.SavedPlaces.WORK) }
|
||||
}
|
||||
}.ifEmpty { listOf(str(R.string.places_sub)) }
|
||||
CarGroup.WARNINGS -> listOf(
|
||||
str(if (bluetoothWarning && !pipeAudio) R.string.summary_bluetooth_on else R.string.summary_bluetooth_off),
|
||||
str(if (locationWarning) R.string.summary_location_on else R.string.summary_location_off),
|
||||
)
|
||||
CarGroup.MORE -> listOf(
|
||||
str(R.string.web_page),
|
||||
str(if (experimental) R.string.summary_experimental_shown else R.string.summary_experimental_hidden),
|
||||
)
|
||||
}.filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.home_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(
|
||||
stringResource(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(stringResource(group.title)) },
|
||||
navigationIcon = {
|
||||
androidx.compose.material3.IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp)
|
||||
.verticalScroll(androidx.compose.foundation.rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SettingsCard { GroupRows(group) }
|
||||
// The bigger pieces each keep their own card: the car screen's size and the preview's,
|
||||
// and the two voice models with their downloads.
|
||||
when (group) {
|
||||
CarGroup.SCREEN -> CarScreenSection()
|
||||
CarGroup.SOUND -> {
|
||||
CarAudioSection()
|
||||
VoiceModelSection()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.car_group_same),
|
||||
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 moreLyrics by CarSettings.moreLyrics.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||
val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
|
||||
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
|
||||
|
||||
when (group) {
|
||||
CarGroup.SCREEN -> {
|
||||
Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect)
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale)
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme)
|
||||
SettingsDivider()
|
||||
if (theme != AppTheme.LIGHT) {
|
||||
Choice(stringResource(R.string.car_dark_mode), darkChoices(), darkStyle, CarSettings::setDarkStyle)
|
||||
SettingsDivider()
|
||||
}
|
||||
Choice(stringResource(R.string.car_buttons), GsThemes.builtIn.map { it.id to it.name }, uiTheme, CarSettings::setUiTheme)
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_map_day_night), nightChoices(), night, CarSettings::setNightMode)
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_units), unitChoices(), units, CarSettings::setUnits)
|
||||
}
|
||||
CarGroup.NAV_BAR -> {
|
||||
Choice(stringResource(R.string.car_nav_bar), navBarChoices(), navBar, CarSettings::setNavBarMode)
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_player_nav_bar), listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)), playerTitle, CarSettings::setPlayerTitle)
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_player_beside_map), listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)), playerLeft, CarSettings::setPlayerLeft)
|
||||
SettingsDivider()
|
||||
Toggle(stringResource(R.string.car_autoplay), autoplay, CarSettings::setAutoplay)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
stringResource(R.string.car_more_lyrics),
|
||||
moreLyrics,
|
||||
CarSettings::setMoreLyrics,
|
||||
note = stringResource(R.string.car_more_lyrics_note),
|
||||
)
|
||||
}
|
||||
CarGroup.SOUND -> {
|
||||
Toggle(
|
||||
stringResource(R.string.car_spoken_directions),
|
||||
speakDirections,
|
||||
CarSettings::setSpeakDirections,
|
||||
note = stringResource(R.string.car_spoken_directions_note),
|
||||
)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
stringResource(R.string.car_phone_speech),
|
||||
systemSpeech,
|
||||
CarSettings::setSystemSpeech,
|
||||
note = stringResource(R.string.car_phone_speech_note),
|
||||
)
|
||||
}
|
||||
CarGroup.PLACES -> Unit
|
||||
CarGroup.WARNINGS -> {
|
||||
if (!pipeAudio) {
|
||||
Toggle(stringResource(R.string.car_warn_bluetooth), bluetoothWarning, CarSettings::setBluetoothWarning)
|
||||
SettingsDivider()
|
||||
}
|
||||
Toggle(stringResource(R.string.car_warn_location), locationWarning, CarSettings::setLocationWarning)
|
||||
}
|
||||
CarGroup.MORE -> {
|
||||
WebPageRow()
|
||||
SettingsDivider()
|
||||
Toggle(stringResource(R.string.car_experimental), experimental, CarSettings::setExperimentalFeaturesEnabled)
|
||||
if (experimental) {
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
stringResource(R.string.car_vehicle_data),
|
||||
carSensors,
|
||||
CarSettings::setCarSensorsEnabled,
|
||||
note = stringResource(R.string.car_vehicle_data_note),
|
||||
)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
stringResource(R.string.car_adaptive_video),
|
||||
adaptiveVideo,
|
||||
CarSettings::setAdaptiveVideoEnabled,
|
||||
note = stringResource(R.string.car_adaptive_video_note),
|
||||
)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
stringResource(R.string.car_cluster_video),
|
||||
clusterVideo,
|
||||
CarSettings::setClusterVideoEnabled,
|
||||
note = stringResource(R.string.car_cluster_video_note),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) },
|
||||
)
|
||||
}
|
||||
|
||||
/** The page the car's Browser app opens, typed here instead of on the car's keyboard. */
|
||||
@Composable
|
||||
private fun WebPageRow() {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
var url by remember { mutableStateOf(app.seb3thehacker.gearslip.AppSettings.startupUrl(context)) }
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
url,
|
||||
{
|
||||
url = it
|
||||
app.seb3thehacker.gearslip.AppSettings.setStartupUrl(context, it)
|
||||
},
|
||||
label = { Text(stringResource(R.string.web_page)) },
|
||||
placeholder = { Text("DuckDuckGo") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.web_page_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How music reaches the car: by Bluetooth as usual, or through Gearslip over USB. Same choice as setup. */
|
||||
@Composable
|
||||
internal fun CarAudioSection() {
|
||||
val usb by CarSettings.pipeAudio.collectAsState()
|
||||
SettingsSection(
|
||||
stringResource(R.string.car_audio),
|
||||
footer = stringResource(if (usb) R.string.car_audio_usb_footer else R.string.car_audio_bt_footer),
|
||||
) {
|
||||
SettingsRow(
|
||||
stringResource(R.string.car_audio_bt),
|
||||
Modifier.selectable(!usb, role = androidx.compose.ui.semantics.Role.RadioButton) { CarSettings.setPipeAudio(false) },
|
||||
subtitle = stringResource(R.string.car_audio_bt_sub),
|
||||
trailing = { androidx.compose.material3.RadioButton(selected = !usb, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
stringResource(R.string.guide_audio_usb),
|
||||
Modifier.selectable(usb, role = androidx.compose.ui.semantics.Role.RadioButton) { CarSettings.setPipeAudio(true) },
|
||||
subtitle = stringResource(R.string.car_audio_usb_sub),
|
||||
trailing = { androidx.compose.material3.RadioButton(selected = usb, onClick = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.speech.SpeechModels
|
||||
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@@ -19,7 +21,6 @@ import androidx.compose.foundation.layout.size
|
||||
import android.content.Context
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
|
||||
import android.provider.Settings
|
||||
@@ -68,6 +69,7 @@ fun HomeScreen(
|
||||
onOpenCarPreview: () -> Unit,
|
||||
onOpenLogs: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onOpenCarGroup: (CarGroup) -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
@@ -81,7 +83,12 @@ fun HomeScreen(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(padding),
|
||||
) {
|
||||
HomeHeader(onOpenSettings)
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
@@ -89,30 +96,6 @@ fun HomeScreen(
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
"Gearslip",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"v${LocalContext.current.appVersionName()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
if (live) BluetoothMissingCard()
|
||||
@@ -125,24 +108,28 @@ 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.
|
||||
Column(Modifier.padding(vertical = 16.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HomeShortcut(Icons.Filled.Info, "Help", onOpenHelp, Modifier.weight(1f))
|
||||
HomeShortcut(Icons.Filled.Info, stringResource(R.string.home_help), onOpenHelp, Modifier.weight(1f))
|
||||
// The preview draws the car screen on the phone, so it waits until no car is using it.
|
||||
if (!live) HomeShortcut(Icons.Filled.PlayArrow, "Preview", onOpenCarPreview, Modifier.weight(1f))
|
||||
HomeShortcut(Icons.AutoMirrored.Filled.List, "Logs", onOpenLogs, Modifier.weight(1f))
|
||||
if (!live) HomeShortcut(Icons.Filled.PlayArrow, stringResource(R.string.home_preview), onOpenCarPreview, Modifier.weight(1f))
|
||||
HomeShortcut(Icons.AutoMirrored.Filled.List, stringResource(R.string.home_logs), onOpenLogs, Modifier.weight(1f))
|
||||
}
|
||||
if (live) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Disconnect")
|
||||
Text(stringResource(R.string.home_disconnect))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sharing just went on from the "it worked" ask: a little thanks.
|
||||
if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated)
|
||||
@@ -151,6 +138,38 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Debug builds wear a yellow header, behind the status bar too, so they're never mistaken for a release install. */
|
||||
internal val DebugYellow = Color(0xFFF5C518)
|
||||
private val OnDebugYellow = Color(0xFF2A2200)
|
||||
|
||||
/** The app's name, version and Settings, fixed above Home's scrolling cards. */
|
||||
@Composable
|
||||
private fun HomeHeader(onOpenSettings: () -> Unit) {
|
||||
val debug = BuildConfig.DEBUG
|
||||
val content = if (debug) OnDebugYellow else MaterialTheme.colorScheme.onBackground
|
||||
Surface(color = if (debug) DebugYellow else Color.Transparent, contentColor = content) {
|
||||
Column(Modifier.fillMaxWidth().padding(start = 24.dp, end = 12.dp, top = 16.dp, bottom = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Gearslip",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onOpenSettings, modifier = Modifier.size(56.dp)) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings_title), modifier = Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
val version = "v${LocalContext.current.appVersionName()}"
|
||||
Text(
|
||||
if (debug) stringResource(R.string.home_debug_build, version) else version,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (debug) content else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One of Home's shortcuts at the bottom: an icon over a one-word label, small enough that all fit in a row. */
|
||||
@Composable
|
||||
private fun HomeShortcut(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
@@ -182,16 +201,15 @@ private fun BluetoothMissingCard() {
|
||||
val context = LocalContext.current
|
||||
// Yellow, not the theme's error red: music is missing, but nothing is broken. Material has no
|
||||
// warning colour, so these are fixed amber tones for light and dark.
|
||||
val dark = isSystemInDarkTheme()
|
||||
val dark = gearslipDark()
|
||||
val container = if (dark) Color(0xFF4A3B00) else Color(0xFFFFE08A)
|
||||
val content = if (dark) Color(0xFFFFE08A) else Color(0xFF3A2E00)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Bluetooth isn't connected", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.home_bt_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Music plays over Bluetooth, but your phone isn't connected to the car. Connect it " +
|
||||
"here or from the car's Bluetooth menu.",
|
||||
stringResource(R.string.home_bt_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
@@ -202,7 +220,7 @@ private fun BluetoothMissingCard() {
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
) { Text("Bluetooth settings") }
|
||||
) { Text(stringResource(R.string.bluetooth_settings)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,7 +233,7 @@ private fun VoiceDownloadCard() {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SettingsCard {
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Downloading voice, $percent%", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.home_voice_downloading, percent), style = MaterialTheme.typography.bodyLarge)
|
||||
LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
@@ -259,9 +277,23 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
|
||||
) {}
|
||||
}
|
||||
Column {
|
||||
Text(status.headline, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
||||
// The steady states read from strings.xml; a stage or a failure comes with its own text.
|
||||
val headline = when (status.phase) {
|
||||
SessionStatus.Phase.IDLE -> stringResource(R.string.status_idle)
|
||||
SessionStatus.Phase.CONNECTING -> stringResource(R.string.status_connecting)
|
||||
SessionStatus.Phase.PROJECTING -> stringResource(R.string.status_projecting)
|
||||
SessionStatus.Phase.DISCONNECTED -> stringResource(R.string.status_disconnected)
|
||||
SessionStatus.Phase.FAILED -> status.headline
|
||||
}
|
||||
val detail = when (status.phase) {
|
||||
SessionStatus.Phase.IDLE -> stringResource(R.string.status_idle_detail)
|
||||
SessionStatus.Phase.PROJECTING -> stringResource(R.string.status_projecting_detail)
|
||||
SessionStatus.Phase.DISCONNECTED -> stringResource(R.string.status_disconnected_detail)
|
||||
else -> status.detail
|
||||
}
|
||||
Text(headline, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(status.detail, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(detail, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
// The other way in: wireless, right under the cable's instructions.
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.media.Lyrics
|
||||
import app.seb3thehacker.gearslip.speech.SpeechModels
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The screen behind "Manage storage" on Gearslip's page in the phone's App info › Storage.
|
||||
* Clears each kind of stored data on its own: the lyrics cache, the voice models, old logs and
|
||||
* other temporary files. Some Android versions drop their own "Clear storage" button once an app
|
||||
* has this screen, so deleting everything is offered here too.
|
||||
*
|
||||
* The manifest names it in full: a relative name would be read against the installed package,
|
||||
* and the debug build's ".dev" package has no such class.
|
||||
*/
|
||||
class ManageSpaceActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent { GearslipTheme { StorageScreen(onBack = ::finish) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** What each kind of stored data takes up, measured on a background thread. */
|
||||
private class Usage(val lyricsSongs: Int, val lyrics: Long, val voice: Long, val logs: Long, val other: Long) {
|
||||
val total get() = lyrics + voice + logs + other
|
||||
}
|
||||
|
||||
private fun File.size(): Long = walkBottomUp().filter { it.isFile }.sumOf { it.length() }
|
||||
|
||||
private fun lyricsDir(context: Context) = File(context.cacheDir, Lyrics.LYRICS_DIR)
|
||||
private fun voiceDir(context: Context) = File(context.filesDir, "speech")
|
||||
|
||||
/** Every launch's log but the newest, which is the one this launch is still writing. */
|
||||
private fun oldLogs(context: Context): List<File> {
|
||||
val dir = context.getExternalFilesDir(null) ?: return emptyList()
|
||||
val logs = dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
|
||||
?.sortedByDescending { it.name }.orEmpty()
|
||||
return logs.drop(1) + listOfNotNull(File(context.cacheDir, "shared").takeIf { it.exists() })
|
||||
}
|
||||
|
||||
/** The cache directory without the lyrics: downloaded updates, shared logs, spoken directions. */
|
||||
private fun otherCache(context: Context): List<File> =
|
||||
context.cacheDir.listFiles()?.filter { it.name != Lyrics.LYRICS_DIR && it.name != "shared" }.orEmpty()
|
||||
|
||||
private fun measure(context: Context) = Usage(
|
||||
lyricsSongs = lyricsDir(context).listFiles()?.size ?: 0,
|
||||
lyrics = lyricsDir(context).size(),
|
||||
voice = voiceDir(context).size(),
|
||||
logs = oldLogs(context).sumOf { it.size() },
|
||||
other = otherCache(context).sumOf { it.size() },
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StorageScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var refresh by remember { mutableIntStateOf(0) }
|
||||
var usage by remember { mutableStateOf<Usage?>(null) }
|
||||
LaunchedEffect(refresh) { usage = withContext(Dispatchers.IO) { measure(context) } }
|
||||
fun clear(work: () -> Unit) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { work() }
|
||||
refresh++
|
||||
}
|
||||
}
|
||||
fun size(bytes: Long) = Formatter.formatShortFileSize(context, bytes)
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.storage_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
val u = usage
|
||||
Text(
|
||||
if (u == null) stringResource(R.string.storage_measuring) else stringResource(R.string.storage_total, size(u.total)),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SettingsCard {
|
||||
StorageRow(
|
||||
stringResource(R.string.storage_lyrics),
|
||||
u?.let { pluralStringResource(R.plurals.storage_lyrics_detail, it.lyricsSongs, it.lyricsSongs, size(it.lyrics)) },
|
||||
enabled = (u?.lyrics ?: 0) > 0,
|
||||
action = stringResource(R.string.storage_clear),
|
||||
) { clear { Lyrics.clearCache(context) } }
|
||||
SettingsDivider()
|
||||
StorageRow(
|
||||
stringResource(R.string.storage_voice),
|
||||
u?.let { stringResource(R.string.storage_voice_detail, size(it.voice)) },
|
||||
enabled = (u?.voice ?: 0) > 0,
|
||||
action = stringResource(R.string.storage_remove),
|
||||
) {
|
||||
clear {
|
||||
voiceDir(context).deleteRecursively()
|
||||
SpeechModels.init(context)
|
||||
}
|
||||
}
|
||||
SettingsDivider()
|
||||
StorageRow(
|
||||
stringResource(R.string.storage_logs),
|
||||
u?.let { stringResource(R.string.storage_logs_detail, size(it.logs)) },
|
||||
enabled = (u?.logs ?: 0) > 0,
|
||||
action = stringResource(R.string.storage_clear),
|
||||
) { clear { oldLogs(context).forEach { it.deleteRecursively() } } }
|
||||
SettingsDivider()
|
||||
StorageRow(
|
||||
stringResource(R.string.storage_other),
|
||||
u?.let { stringResource(R.string.storage_other_detail, size(it.other)) },
|
||||
enabled = (u?.other ?: 0) > 0,
|
||||
action = stringResource(R.string.storage_clear),
|
||||
) { clear { otherCache(context).forEach { it.deleteRecursively() } } }
|
||||
}
|
||||
DeleteEverything()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StorageRow(title: String, detail: String?, enabled: Boolean, action: String, onClick: () -> Unit) {
|
||||
SettingsRow(
|
||||
title,
|
||||
subtitle = detail ?: stringResource(R.string.storage_measuring),
|
||||
trailing = { TextButton(onClick = onClick, enabled = enabled) { Text(action) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Android's "Clear storage", which this screen replaces: two taps, so it can't happen by accident. */
|
||||
@Composable
|
||||
private fun DeleteEverything() {
|
||||
val context = LocalContext.current
|
||||
var armed by remember { mutableStateOf(false) }
|
||||
SettingsCard {
|
||||
SettingsRow(
|
||||
stringResource(R.string.storage_delete_all),
|
||||
subtitle = if (armed) {
|
||||
stringResource(R.string.storage_delete_armed)
|
||||
} else {
|
||||
stringResource(R.string.storage_delete_sub)
|
||||
},
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
trailing = {
|
||||
TextButton(onClick = {
|
||||
if (armed) context.getSystemService(ActivityManager::class.java)?.clearApplicationUserData()
|
||||
else armed = true
|
||||
}) { Text(stringResource(if (armed) R.string.storage_delete else R.string.storage_delete_ellipsis), color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -49,7 +51,7 @@ fun PhoneTypingBar(modifier: Modifier = Modifier) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth().imePadding()) {
|
||||
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = PhoneTyping::dismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close the car keyboard")
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.typing_close))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
@@ -57,14 +59,14 @@ fun PhoneTypingBar(modifier: Modifier = Modifier) {
|
||||
value = it
|
||||
PhoneTyping.type(it.text)
|
||||
},
|
||||
label = { Text("Type for the car screen") },
|
||||
label = { Text(stringResource(R.string.typing_label)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { PhoneTyping.submit() }),
|
||||
modifier = Modifier.weight(1f).focusRequester(focus),
|
||||
)
|
||||
IconButton(onClick = PhoneTyping::submit) {
|
||||
Icon(Icons.Filled.Search, contentDescription = "Search on the car screen")
|
||||
Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.typing_search))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
@@ -43,19 +46,22 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
|
||||
/** A free app that runs on Gearslip's car screen, and where to get it. */
|
||||
private class RecommendedApp(val name: String, val packageName: String, val url: String, val about: String)
|
||||
private class RecommendedApp(val name: String, val packageName: String, val url: String, @param:StringRes val about: Int)
|
||||
|
||||
private val recommended = listOf(
|
||||
"Maps" to listOf(
|
||||
RecommendedApp("Organic Maps", "app.organicmaps", "https://github.com/organicmaps/organicmaps", "Offline maps and routing."),
|
||||
RecommendedApp("Vela Maps", "app.vela", "https://github.com/PimpinPumpkin/Vela", "Turn-by-turn navigation without Google."),
|
||||
R.string.recommended_maps to listOf(
|
||||
RecommendedApp("Organic Maps", "app.organicmaps", "https://github.com/organicmaps/organicmaps", R.string.recommended_about_organic),
|
||||
RecommendedApp("Vela Maps", "app.vela", "https://github.com/PimpinPumpkin/Vela", R.string.recommended_about_vela),
|
||||
),
|
||||
"Music" to listOf(
|
||||
RecommendedApp("ViVi Music", "com.vivi.vivimusic", "https://github.com/vivizzz007/vivi-music", "A YouTube Music player."),
|
||||
RecommendedApp("Metrolist", "com.metrolist.music", "https://github.com/MetrolistGroup/Metrolist", "A YouTube Music player."),
|
||||
R.string.recommended_music to listOf(
|
||||
RecommendedApp("ViVi Music", "com.vivi.vivimusic", "https://github.com/vivizzz007/vivi-music", R.string.recommended_about_ytm),
|
||||
RecommendedApp("Metrolist", "com.metrolist.music", "https://github.com/MetrolistGroup/Metrolist", R.string.recommended_about_ytm),
|
||||
),
|
||||
"Podcasts" to listOf(
|
||||
RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", "Podcasts, chapters and all."),
|
||||
R.string.recommended_podcasts to listOf(
|
||||
RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", R.string.recommended_about_antennapod),
|
||||
),
|
||||
R.string.recommended_other to listOf(
|
||||
RecommendedApp("RF Sentinel", "com.rfsentinel.app", "https://github.com/CIS-C0/RFSentinel", R.string.recommended_about_rfsentinel),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -67,10 +73,10 @@ fun RecommendedAppsScreen(onBack: () -> Unit) {
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Recommended apps") },
|
||||
title = { Text(stringResource(R.string.recommended_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -84,7 +90,7 @@ fun RecommendedAppsScreen(onBack: () -> Unit) {
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
RECOMMENDED_INTRO,
|
||||
stringResource(R.string.recommended_intro),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -95,12 +101,9 @@ fun RecommendedAppsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hidden for now: off, neither Settings nor the setup guide shows the list. The code stays. */
|
||||
/** The list's entry in Settings, hidden for now. The setup guide always ends on the list. */
|
||||
internal const val SHOW_RECOMMENDED_APPS = false
|
||||
|
||||
internal const val RECOMMENDED_INTRO =
|
||||
"These free apps run on Gearslip's car screen. Each button opens the app's page on GitHub."
|
||||
|
||||
/** The same green as the launcher's "works" check. */
|
||||
private val INSTALLED_GREEN = Color(0xFF43A047)
|
||||
|
||||
@@ -112,7 +115,7 @@ internal fun RecommendedAppsList(modifier: Modifier = Modifier) {
|
||||
for ((section, apps) in recommended) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
section,
|
||||
stringResource(section),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
@@ -135,17 +138,19 @@ 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)
|
||||
Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
// 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(stringResource(app.about), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (icon != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
"Installed",
|
||||
stringResource(R.string.recommended_installed),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = INSTALLED_GREEN,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.materialIcon
|
||||
import androidx.compose.material.icons.materialPath
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.SavedPlace
|
||||
import app.seb3thehacker.gearslip.car.SavedPlaces
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Home, Work and three named places, each saved the moment it's typed. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SavedPlacesScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val places = remember { SavedPlaces.all(context).toMutableList() }
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.places_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.places_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
for (i in 0 until SavedPlaces.COUNT) {
|
||||
PlaceCard(i, places[i]) { place ->
|
||||
places[i] = place
|
||||
SavedPlaces.set(context, i, place)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceCard(index: Int, initial: SavedPlace, onChange: (SavedPlace) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var label by remember { mutableStateOf(initial.label) }
|
||||
var address by remember { mutableStateOf(initial.address) }
|
||||
var finding by remember { mutableStateOf(false) }
|
||||
var notFound by remember { mutableStateOf(false) }
|
||||
val custom = index > SavedPlaces.WORK
|
||||
|
||||
fun save() = onChange(SavedPlace(if (custom) label.trim() else "", address.trim()))
|
||||
fun findHere() {
|
||||
finding = true
|
||||
notFound = false
|
||||
scope.launch {
|
||||
val here = SavedPlaces.here(context)
|
||||
finding = false
|
||||
if (here == null) {
|
||||
notFound = true
|
||||
} else {
|
||||
address = here
|
||||
save()
|
||||
}
|
||||
}
|
||||
}
|
||||
val askLocation = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
if (SavedPlaces.hasLocationPermission(context)) findHere() else notFound = true
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Icon(
|
||||
when (index) {
|
||||
SavedPlaces.HOME -> Icons.Filled.Home
|
||||
SavedPlaces.WORK -> Briefcase
|
||||
else -> Icons.Filled.Place
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
when (index) {
|
||||
SavedPlaces.HOME -> stringResource(R.string.places_home)
|
||||
SavedPlaces.WORK -> stringResource(R.string.places_work)
|
||||
else -> label.ifBlank { stringResource(R.string.places_other, index - SavedPlaces.WORK) }
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
if (custom) {
|
||||
OutlinedTextField(
|
||||
value = label,
|
||||
onValueChange = { label = it; save() },
|
||||
label = { Text(stringResource(R.string.places_name)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = { address = it; notFound = false; save() },
|
||||
label = { Text(stringResource(R.string.places_address)) },
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (notFound) {
|
||||
Text(
|
||||
stringResource(R.string.places_not_found),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (SavedPlaces.hasLocationPermission(context)) {
|
||||
findHere()
|
||||
} else {
|
||||
askLocation.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
|
||||
}
|
||||
},
|
||||
enabled = !finding,
|
||||
) {
|
||||
if (finding) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Filled.LocationOn, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(if (finding) R.string.places_finding else R.string.places_here))
|
||||
}
|
||||
if (address.isNotBlank() || label.isNotBlank()) {
|
||||
TextButton(onClick = { label = ""; address = ""; notFound = false; save() }) {
|
||||
Text(stringResource(R.string.places_clear))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Material's "work" briefcase, which only ships in the extended icon set. */
|
||||
private val Briefcase: ImageVector by lazy {
|
||||
materialIcon("Filled.Work") {
|
||||
materialPath {
|
||||
moveTo(20f, 6f); horizontalLineToRelative(-4f); verticalLineTo(4f)
|
||||
curveToRelative(0f, -1.11f, -0.89f, -2f, -2f, -2f); horizontalLineToRelative(-4f)
|
||||
curveTo(8.89f, 2f, 8f, 2.89f, 8f, 4f); verticalLineToRelative(2f); horizontalLineTo(4f)
|
||||
curveToRelative(-1.11f, 0f, -1.99f, 0.89f, -1.99f, 2f); lineTo(2f, 19f)
|
||||
curveToRelative(0f, 1.11f, 0.89f, 2f, 2f, 2f); horizontalLineToRelative(16f)
|
||||
curveToRelative(1.11f, 0f, 2f, -0.89f, 2f, -2f); verticalLineTo(8f)
|
||||
curveToRelative(0f, -1.11f, -0.89f, -2f, -2f, -2f); close()
|
||||
moveTo(14f, 6f); horizontalLineToRelative(-4f); verticalLineTo(4f); horizontalLineToRelative(4f)
|
||||
verticalLineToRelative(2f); close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -83,6 +86,16 @@ fun SettingsScreen(
|
||||
atUsageNotes: Boolean = false,
|
||||
) {
|
||||
val scroll = rememberScrollState()
|
||||
// Built a section per frame after the first two, so the screen shows at once instead of
|
||||
// freezing while all of it composes; once built, it scrolls without composing anything new.
|
||||
var built by remember { mutableIntStateOf(2) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (built < SECTIONS) {
|
||||
withFrameNanos { }
|
||||
built++
|
||||
}
|
||||
}
|
||||
fun ready(n: Int) = built > n
|
||||
// Opened from Home's "it worked" card: start at usage notes.
|
||||
var usageNotesY by remember { mutableIntStateOf(-1) }
|
||||
if (atUsageNotes) {
|
||||
@@ -96,10 +109,10 @@ fun SettingsScreen(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
title = { Text(stringResource(R.string.settings_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -117,36 +130,36 @@ fun SettingsScreen(
|
||||
SettingsSection {
|
||||
val context = LocalContext.current
|
||||
SettingsRow(
|
||||
"What's new",
|
||||
stringResource(R.string.settings_whats_new),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.RELEASE_NOTES) },
|
||||
subtitle = "Read the ${BuildConfig.VERSION_NAME} release notes on GitHub",
|
||||
subtitle = stringResource(R.string.settings_whats_new_sub, BuildConfig.VERSION_NAME),
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
}
|
||||
SettingsSection(
|
||||
"Support Gearslip",
|
||||
footer = "Gearslip is free, with no ads and no tracking. Your support keeps it going.",
|
||||
stringResource(R.string.settings_support),
|
||||
footer = stringResource(R.string.settings_support_footer),
|
||||
) { DonateRows() }
|
||||
ConnectionSection()
|
||||
CarAudioSection()
|
||||
CarScreenSection()
|
||||
VoiceModelSection()
|
||||
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||
SettingsSection(
|
||||
"Usage notes",
|
||||
if (ready(2)) ConnectionSection()
|
||||
if (SHOW_RECOMMENDED_APPS && ready(6)) AppsSection(onOpenRecommendedApps)
|
||||
if (ready(7)) SettingsSection(
|
||||
stringResource(R.string.settings_usage_notes),
|
||||
Modifier.onGloballyPositioned { usageNotesY = it.positionInParent().y.toInt() },
|
||||
header = USAGE_STATS_INTRO,
|
||||
header = usageStatsIntro(LocalContext.current),
|
||||
) { UsageStatsRows() }
|
||||
SettingsSection("Updates") { UpdateRow() }
|
||||
CertificateSection()
|
||||
AboutSection(onReplayTutorial)
|
||||
if (BuildConfig.DEBUG) DebugSection()
|
||||
if (ready(8)) SettingsSection(stringResource(R.string.settings_updates)) { UpdateRow() }
|
||||
if (ready(9)) CertificateSection()
|
||||
if (ready(10)) AboutSection(onReplayTutorial)
|
||||
if (BuildConfig.DEBUG && ready(11)) DebugSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How many sections Settings builds one frame at a time; see [ready]. */
|
||||
private const val SECTIONS = 12
|
||||
|
||||
@Composable
|
||||
private fun Arrow() = Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
@@ -157,11 +170,11 @@ private fun Arrow() = Icon(
|
||||
/** The setup guide replay, and where Gearslip lives outside the app. */
|
||||
@Composable
|
||||
private fun AboutSection(onReplayTutorial: () -> Unit) {
|
||||
SettingsSection("About") {
|
||||
SettingsSection(stringResource(R.string.settings_about)) {
|
||||
SettingsRow(
|
||||
"Replay the setup guide",
|
||||
stringResource(R.string.settings_replay),
|
||||
Modifier.clickable(onClick = onReplayTutorial),
|
||||
subtitle = "Go through the first-run steps again",
|
||||
subtitle = stringResource(R.string.settings_replay_sub),
|
||||
icon = Icons.Filled.Refresh,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
@@ -175,14 +188,14 @@ private fun AboutSection(onReplayTutorial: () -> Unit) {
|
||||
private fun ConnectionSection() {
|
||||
val context = LocalContext.current
|
||||
var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) }
|
||||
SettingsSection("Connection") {
|
||||
SettingsSection(stringResource(R.string.settings_connection)) {
|
||||
SettingsRow(
|
||||
"Use Gearslip in the car",
|
||||
stringResource(R.string.settings_use_in_car),
|
||||
Modifier.toggleable(enabled, role = Role.Switch) {
|
||||
enabled = it
|
||||
AppSettings.setGearslipEnabled(context, it)
|
||||
},
|
||||
subtitle = if (enabled) "Gearslip answers when you plug into a car" else "Off: Android Auto can take the connection instead",
|
||||
subtitle = stringResource(if (enabled) R.string.settings_use_in_car_on else R.string.settings_use_in_car_off),
|
||||
icon = Icons.Filled.Settings,
|
||||
trailing = { Switch(checked = enabled, onCheckedChange = null) },
|
||||
)
|
||||
@@ -200,38 +213,22 @@ private fun DebugSection() {
|
||||
subtitle = "Posts a message notification to read on the car",
|
||||
icon = Icons.AutoMirrored.Filled.Send,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How music reaches the car: by Bluetooth as usual, or through Gearslip over USB. Same choice as setup. */
|
||||
@Composable
|
||||
private fun CarAudioSection() {
|
||||
val usb by CarSettings.pipeAudio.collectAsState()
|
||||
SettingsSection(
|
||||
"Car audio",
|
||||
footer = if (usb) "Run the command in Help once, and USB stops asking." else "Calls go over Bluetooth either way.",
|
||||
) {
|
||||
SettingsRow(
|
||||
"Bluetooth (Recommended)",
|
||||
Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) },
|
||||
subtitle = "No prompt each drive, and your map app's voice reaches the car",
|
||||
trailing = { RadioButton(selected = !usb, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
val badge by CarSettings.debugBadge.collectAsState()
|
||||
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",
|
||||
trailing = { RadioButton(selected = usb, onClick = null) },
|
||||
"Debug badge on the car screen",
|
||||
Modifier.clickable { CarSettings.setDebugBadge(!badge) },
|
||||
subtitle = "Turn it off for screenshots",
|
||||
trailing = { Switch(checked = badge, onCheckedChange = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
|
||||
SettingsSection("Apps") {
|
||||
SettingsSection(stringResource(R.string.settings_apps)) {
|
||||
SettingsRow(
|
||||
"Recommended apps",
|
||||
stringResource(R.string.recommended_title),
|
||||
Modifier.clickable(onClick = onOpenRecommendedApps),
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
@@ -258,21 +255,25 @@ private fun CertificateSection() {
|
||||
if (uri != null) pendingUri = uri
|
||||
}
|
||||
|
||||
SettingsSection("Certificate", footer = notice ?: "Changes take effect on the next connection.") {
|
||||
// With the aasdk certificate loaded, the footer says what to do when a car rejects it.
|
||||
val certFooter = stringResource(
|
||||
if (summary?.kind == CertProvider.Kind.DOWNLOADED) R.string.cert_downloaded_footer else R.string.cert_next_connection,
|
||||
)
|
||||
SettingsSection(stringResource(R.string.settings_certificate), footer = notice ?: certFooter) {
|
||||
val s = summary
|
||||
val loaded = s != null && s.kind != CertProvider.Kind.SELF_SIGNED
|
||||
val hasDetails = s?.subject != null || s?.issuer != null
|
||||
SettingsRow(
|
||||
s?.headline ?: "Checking…",
|
||||
s?.headline ?: stringResource(R.string.cert_checking),
|
||||
if (hasDetails) Modifier.clickable { detailsOpen = !detailsOpen } else Modifier,
|
||||
subtitle = s?.validUntil?.let { "Valid until $it" },
|
||||
subtitle = s?.validUntil?.let { stringResource(R.string.cert_valid_until, it) },
|
||||
icon = if (s == null || loaded) Icons.Filled.Lock else Icons.Filled.Warning,
|
||||
tint = if (s == null || loaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
trailing = if (hasDetails) {
|
||||
{
|
||||
Icon(
|
||||
if (detailsOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (detailsOpen) "Hide details" else "Show details",
|
||||
contentDescription = stringResource(if (detailsOpen) R.string.cert_hide_details else R.string.cert_show_details),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -283,39 +284,39 @@ private fun CertificateSection() {
|
||||
Modifier.padding(start = 56.dp, end = 16.dp, bottom = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
s?.subject?.let { Detail("Subject", it) }
|
||||
s?.issuer?.let { Detail("Issuer", it) }
|
||||
s?.subject?.let { Detail(stringResource(R.string.cert_subject), it) }
|
||||
s?.issuer?.let { Detail(stringResource(R.string.cert_issuer), it) }
|
||||
}
|
||||
}
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
if (downloading) "Downloading…" else "Download from aasdk",
|
||||
stringResource(if (downloading) R.string.cert_downloading else R.string.cert_download),
|
||||
Modifier.clickable(enabled = !downloading) {
|
||||
downloading = true
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { runCatching { CertProvider.downloadAndImport(context) } }
|
||||
downloading = false
|
||||
result.onSuccess {
|
||||
notice = "Certificate downloaded."
|
||||
notice = context.getString(R.string.cert_downloaded)
|
||||
refresh++
|
||||
}.onFailure {
|
||||
notice = "Download failed: ${it.message}"
|
||||
notice = context.getString(R.string.cert_download_failed, it.message.toString())
|
||||
}
|
||||
}
|
||||
},
|
||||
subtitle = "The certificate the open-source aasdk project publishes on GitHub. Gearslip doesn't include one",
|
||||
subtitle = stringResource(R.string.cert_download_sub),
|
||||
icon = Icons.Filled.Refresh,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Import my own file",
|
||||
stringResource(R.string.cert_import),
|
||||
Modifier.clickable { picker.launch(arrayOf("*/*")) },
|
||||
subtitle = "A .p12 file with its private key, from wherever you choose",
|
||||
subtitle = stringResource(R.string.cert_import_sub),
|
||||
icon = Icons.Filled.Add,
|
||||
)
|
||||
val removable = when (s?.kind) {
|
||||
CertProvider.Kind.IMPORTED -> "Remove imported certificate"
|
||||
CertProvider.Kind.DOWNLOADED -> "Remove downloaded certificate"
|
||||
CertProvider.Kind.IMPORTED -> stringResource(R.string.cert_remove_imported)
|
||||
CertProvider.Kind.DOWNLOADED -> stringResource(R.string.cert_remove_downloaded)
|
||||
else -> null
|
||||
}
|
||||
if (removable != null) {
|
||||
@@ -324,7 +325,7 @@ private fun CertificateSection() {
|
||||
removable,
|
||||
Modifier.clickable {
|
||||
if (s?.kind == CertProvider.Kind.IMPORTED) CertProvider.removeImported(context) else CertProvider.removeDownloaded(context)
|
||||
notice = if (s?.kind == CertProvider.Kind.IMPORTED) "Removed the imported certificate." else "Removed the downloaded certificate."
|
||||
notice = context.getString(if (s?.kind == CertProvider.Kind.IMPORTED) R.string.cert_removed_imported else R.string.cert_removed_downloaded)
|
||||
refresh++
|
||||
},
|
||||
icon = Icons.Filled.Delete,
|
||||
@@ -344,10 +345,10 @@ private fun CertificateSection() {
|
||||
}
|
||||
result.onSuccess {
|
||||
pendingUri = null
|
||||
notice = "Certificate imported."
|
||||
notice = context.getString(R.string.cert_imported)
|
||||
refresh++
|
||||
}.onFailure {
|
||||
reportError("Could not import: wrong password, or not a PKCS#12 file with a private key.")
|
||||
reportError(context.getString(R.string.cert_import_failed))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -370,20 +371,20 @@ private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Certificate password") },
|
||||
title = { Text(stringResource(R.string.cert_password_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it; error = null },
|
||||
singleLine = true,
|
||||
label = { Text("Password") },
|
||||
label = { Text(stringResource(R.string.cert_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = error != null,
|
||||
supportingText = { Text(error ?: "Leave empty if the file has none.") },
|
||||
supportingText = { Text(error ?: stringResource(R.string.cert_password_hint)) },
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text(stringResource(R.string.cert_import_button)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.speech.SpeechModels
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -93,6 +95,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 +105,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 +120,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,
|
||||
)
|
||||
@@ -154,8 +162,8 @@ private fun hasNotificationAccess(context: Context): Boolean {
|
||||
* who set up before the notes existed see once. Leaving it without turning them on is a no.
|
||||
*/
|
||||
private fun usageNotesPage(context: Context) = GuidePage(
|
||||
title = "Help other drivers.",
|
||||
explanation = USAGE_STATS_INTRO,
|
||||
title = context.getString(R.string.guide_usage_title),
|
||||
explanation = usageStatsIntro(context),
|
||||
icon = { HeartIcon(it) },
|
||||
content = {
|
||||
UsageStatsChoices()
|
||||
@@ -169,9 +177,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
// --- Safety: always first, and never skipped by an already-granted check ---------
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Drive safely.",
|
||||
explanation = "Gearslip is unofficial. No carmaker made or checked it, and it can fail " +
|
||||
"without warning. Set it up before you go, then keep your eyes on the road.",
|
||||
title = context.getString(R.string.guide_safety_title),
|
||||
explanation = context.getString(R.string.guide_safety_body),
|
||||
icon = glyph(Icons.Filled.Warning),
|
||||
),
|
||||
)
|
||||
@@ -181,9 +188,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Microphone.",
|
||||
explanation = "Gearslip uses it to send your music to the car and to hear you when you " +
|
||||
"ask for something or reply to a message. What you say stays on the phone.",
|
||||
title = context.getString(R.string.guide_mic_title),
|
||||
explanation = context.getString(R.string.guide_mic_body),
|
||||
icon = { MicIcon(it) },
|
||||
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
),
|
||||
@@ -192,9 +198,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Location.",
|
||||
explanation = "The car's map and nearby searches need it. Without it, the dashboard " +
|
||||
"shows no map.",
|
||||
title = context.getString(R.string.guide_location_title),
|
||||
explanation = context.getString(R.string.guide_location_body),
|
||||
icon = glyph(Icons.Filled.LocationOn),
|
||||
permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
|
||||
),
|
||||
@@ -203,8 +208,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.READ_CALENDAR))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Calendar.",
|
||||
explanation = "The car screen shows your next event. Gearslip only reads your calendar.",
|
||||
title = context.getString(R.string.guide_calendar_title),
|
||||
explanation = context.getString(R.string.guide_calendar_body),
|
||||
icon = glyph(Icons.Filled.DateRange),
|
||||
permissions = arrayOf(Manifest.permission.READ_CALENDAR),
|
||||
),
|
||||
@@ -220,9 +225,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, callPermissions)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Calls and contacts.",
|
||||
explanation = "The car shows who's calling by name, and its Phone app can dial a " +
|
||||
"number or call one of your contacts. Gearslip only reads them.",
|
||||
title = context.getString(R.string.guide_calls_title),
|
||||
explanation = context.getString(R.string.guide_calls_body),
|
||||
icon = glyph(Icons.Filled.Phone),
|
||||
permissions = callPermissions,
|
||||
),
|
||||
@@ -231,9 +235,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.BLUETOOTH_CONNECT))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Bluetooth.",
|
||||
explanation = "The first time you plug in, Gearslip offers to pair your phone with the " +
|
||||
"car, so calls play through its speakers.",
|
||||
title = context.getString(R.string.guide_bluetooth_title),
|
||||
explanation = context.getString(R.string.guide_bluetooth_body),
|
||||
icon = glyph(Icons.Filled.Call),
|
||||
permissions = arrayOf(Manifest.permission.BLUETOOTH_CONNECT),
|
||||
),
|
||||
@@ -243,9 +246,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Notifications.",
|
||||
explanation = "While Gearslip is connected, it shows a notification with a button " +
|
||||
"to stop. Without this, Android hides it.",
|
||||
title = context.getString(R.string.guide_notifications_title),
|
||||
explanation = context.getString(R.string.guide_notifications_body),
|
||||
icon = glyph(Icons.Filled.Notifications),
|
||||
permissions = arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
),
|
||||
@@ -254,11 +256,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!hasNotificationAccess(context)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Messages.",
|
||||
explanation = "The car shows your messages, reads them aloud and lets you reply. " +
|
||||
"Turn Gearslip on in the list this opens.",
|
||||
title = context.getString(R.string.guide_messages_title),
|
||||
explanation = context.getString(R.string.guide_messages_body),
|
||||
icon = glyph(Icons.Filled.Email),
|
||||
actionLabel = "Open notification access",
|
||||
actionLabel = context.getString(R.string.guide_open_notification_access),
|
||||
onAction = {
|
||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
},
|
||||
@@ -272,11 +273,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (androidAutoPackage != null) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Turn off Android Auto.",
|
||||
explanation = "Android Auto is still installed, and it fights Gearslip for the car's " +
|
||||
"USB connection. Disable or uninstall it on the screen this opens.",
|
||||
title = context.getString(R.string.guide_android_auto_title),
|
||||
explanation = context.getString(R.string.guide_android_auto_body),
|
||||
icon = glyph(Icons.Filled.Close),
|
||||
actionLabel = "Open Android Auto's app info",
|
||||
actionLabel = context.getString(R.string.guide_open_android_auto),
|
||||
onAction = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", androidAutoPackage, null)),
|
||||
@@ -289,9 +289,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
// --- Default handler ---------------------------------------------------------------
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Choose Gearslip.",
|
||||
explanation = "If your phone asks which app to open when you plug into the car, pick " +
|
||||
"Gearslip and tap Always.",
|
||||
title = context.getString(R.string.guide_default_title),
|
||||
explanation = context.getString(R.string.guide_default_body),
|
||||
icon = glyph(Icons.Filled.Check),
|
||||
),
|
||||
)
|
||||
@@ -299,8 +298,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
// --- Car audio: always asked, and it decides whether the adb pages below show ---------
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Car audio.",
|
||||
explanation = "Pick how music reaches the car. Calls go over Bluetooth either way.",
|
||||
title = context.getString(R.string.guide_audio_title),
|
||||
explanation = context.getString(R.string.guide_audio_body),
|
||||
icon = glyph(Icons.Filled.PlayArrow),
|
||||
content = { AudioChoice() },
|
||||
),
|
||||
@@ -310,13 +309,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!SpeechModels.RECOMMENDED.all { SpeechModels.isReady(context, it) }) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Voice.",
|
||||
explanation = "Ask for directions, music, or a call by voice. Your phone turns speech " +
|
||||
"into text by itself, and nothing you say leaves it. It needs " +
|
||||
"${SpeechModels.RECOMMENDED.sumOf { it.downloadMb }} MB, downloaded once. Use Wi-Fi if you can.",
|
||||
title = context.getString(R.string.guide_voice_title),
|
||||
explanation = context.getString(R.string.guide_voice_body, SpeechModels.RECOMMENDED.sumOf { it.downloadMb }),
|
||||
icon = { MicIcon(it) },
|
||||
optional = true,
|
||||
actionLabel = "Download voice",
|
||||
actionLabel = context.getString(R.string.guide_download_voice),
|
||||
onAction = { SpeechModels.downloadRecommended(context) },
|
||||
content = { VoiceDownloadStatus() },
|
||||
),
|
||||
@@ -327,9 +324,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!hasProjectMediaOp(context)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "No audio prompt.",
|
||||
explanation = "Run this from a computer with USB debugging on, and Android trusts " +
|
||||
"Gearslip with your audio for good. Without it, you tap Start once each drive.",
|
||||
title = context.getString(R.string.guide_audio_prompt_title),
|
||||
explanation = context.getString(R.string.guide_audio_prompt_body),
|
||||
icon = glyph(Icons.Filled.Build),
|
||||
optional = true,
|
||||
usbOnly = true,
|
||||
@@ -340,10 +336,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.WRITE_SECURE_SETTINGS))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Readable notifications.",
|
||||
explanation = "While audio goes to the car, Android hides what notifications say, " +
|
||||
"as if you were sharing your screen. This lets Gearslip show them during a " +
|
||||
"drive and hide them again after.",
|
||||
title = context.getString(R.string.guide_readable_title),
|
||||
explanation = context.getString(R.string.guide_readable_body),
|
||||
icon = glyph(Icons.Filled.Build),
|
||||
optional = true,
|
||||
usbOnly = true,
|
||||
@@ -354,31 +348,34 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!hasCallScreeningRole(context)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Answer calls.",
|
||||
explanation = "Make Gearslip your caller ID app, and the car can answer and decline " +
|
||||
"calls. Without it, calls still ring, but only the phone can answer them.",
|
||||
title = context.getString(R.string.guide_answer_title),
|
||||
explanation = context.getString(R.string.guide_answer_body),
|
||||
icon = glyph(Icons.Filled.Call),
|
||||
optional = true,
|
||||
actionLabel = "Set up",
|
||||
actionLabel = context.getString(R.string.guide_set_up),
|
||||
onAction = onRequestCallScreening,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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 = context.getString(R.string.guide_apps_title),
|
||||
explanation = context.getString(R.string.recommended_intro),
|
||||
icon = { StarIcon(it) },
|
||||
content = { RecommendedAppsList() },
|
||||
),
|
||||
)
|
||||
add(
|
||||
GuidePage(
|
||||
title = context.getString(R.string.guide_buttons_title),
|
||||
explanation = context.getString(R.string.guide_buttons_body),
|
||||
icon = { ButtonsIcon(it) },
|
||||
content = { ThemePicker(it.c, it.cq, it.cqSp, key = "guide-buttons") },
|
||||
),
|
||||
)
|
||||
add(usageNotesPage(context))
|
||||
}
|
||||
|
||||
@@ -393,9 +390,9 @@ private fun VoiceDownloadStatus() {
|
||||
val progress by SpeechModels.progress.collectAsState()
|
||||
val states = SpeechModels.RECOMMENDED.map { SpeechModels.state(it).collectAsState().value }
|
||||
val line = when {
|
||||
progress != null -> "Downloading, $progress%. You can carry on; it keeps going."
|
||||
states.all { it == SpeechModels.State.Ready } -> "Voice is ready."
|
||||
states.any { it is SpeechModels.State.Failed } -> "The download didn't finish. Tap Download voice to try again."
|
||||
progress != null -> stringResource(R.string.guide_voice_downloading, progress ?: 0)
|
||||
states.all { it == SpeechModels.State.Ready } -> stringResource(R.string.guide_voice_ready)
|
||||
states.any { it is SpeechModels.State.Failed } -> stringResource(R.string.guide_voice_failed)
|
||||
else -> null
|
||||
}
|
||||
LaunchedEffect(Unit) { SpeechModels.init(context) }
|
||||
@@ -411,13 +408,13 @@ private fun AudioChoice() {
|
||||
LaunchedEffect(Unit) { if (!CarSettings.pipeAudioChosen) CarSettings.setPipeAudio(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
AudioCard(
|
||||
"Bluetooth", "Recommended",
|
||||
"No prompt each drive, and your map app's voice reaches the car.",
|
||||
stringResource(R.string.guide_audio_bluetooth), stringResource(R.string.guide_recommended),
|
||||
stringResource(R.string.guide_audio_bluetooth_body),
|
||||
selected = !usb,
|
||||
) { CarSettings.setPipeAudio(false) }
|
||||
AudioCard(
|
||||
"USB", null,
|
||||
"Clearer sound with no delay. Android asks to share audio once each drive.",
|
||||
stringResource(R.string.guide_audio_usb), null,
|
||||
stringResource(R.string.guide_audio_usb_body),
|
||||
selected = usb,
|
||||
) { CarSettings.setPipeAudio(true) }
|
||||
}
|
||||
@@ -509,10 +506,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,
|
||||
@@ -524,7 +522,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
Spacer(Modifier.height(cq(7f)))
|
||||
if (page.optional) {
|
||||
Text(
|
||||
"OPTIONAL", color = c.primary,
|
||||
stringResource(R.string.guide_optional), color = c.primary,
|
||||
style = TextStyle(fontSize = cqSp(3.4f), fontWeight = FontWeight.Bold, letterSpacing = cqSp(0.4f)),
|
||||
modifier = Modifier.up(entrance(index, 150, 700), cq(7f)),
|
||||
)
|
||||
@@ -550,15 +548,18 @@ 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,
|
||||
stringResource(R.string.guide_already_allowed), color = c.primary,
|
||||
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,20 +572,42 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
val next = { index += 1 }
|
||||
when {
|
||||
page.permissions != null && !alreadyGranted -> {
|
||||
PillRow(c, ::cq, ::cqSp, back, "Allow") { launcher.launch(page.permissions) }
|
||||
Pill(c, ::cqSp, "Skip for now", PillStyle.TEXT, Modifier.align(Alignment.CenterHorizontally), next)
|
||||
PillRow(c, ::cq, ::cqSp, back, stringResource(R.string.guide_allow)) { launcher.launch(page.permissions) }
|
||||
Pill(c, ::cqSp, stringResource(R.string.guide_skip), PillStyle.TEXT, Modifier.align(Alignment.CenterHorizontally), next)
|
||||
}
|
||||
page.onAction != null -> {
|
||||
Pill(c, ::cqSp, page.actionLabel ?: "Open", PillStyle.OUTLINED, Modifier.fillMaxWidth().padding(bottom = cq(3f)), page.onAction)
|
||||
PillRow(c, ::cq, ::cqSp, back, "Next", next)
|
||||
Pill(c, ::cqSp, page.actionLabel ?: stringResource(R.string.guide_open), PillStyle.OUTLINED, Modifier.fillMaxWidth().padding(bottom = cq(3f)), page.onAction)
|
||||
PillRow(c, ::cq, ::cqSp, back, stringResource(R.string.guide_next), next)
|
||||
}
|
||||
else -> PillRow(c, ::cq, ::cqSp, back, if (index == pages.lastIndex) "Done" else "Next", next)
|
||||
else -> PillRow(c, ::cq, ::cqSp, back, stringResource(if (index == pages.lastIndex) R.string.guide_done else R.string.guide_next), next)
|
||||
}
|
||||
Spacer(Modifier.height(cq(6f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(
|
||||
stringResource(R.string.guide_scroll_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. */
|
||||
@@ -621,7 +644,7 @@ internal fun PillRow(
|
||||
onBack: (() -> Unit)?, label: String, onClick: () -> Unit,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
|
||||
if (onBack != null) Pill(c, cqSp, "Back", PillStyle.OUTLINED, Modifier.weight(1f), onBack)
|
||||
if (onBack != null) Pill(c, cqSp, stringResource(R.string.guide_back), PillStyle.OUTLINED, Modifier.weight(1f), onBack)
|
||||
Pill(c, cqSp, label, PillStyle.FILLED, Modifier.weight(if (onBack != null) 1.4f else 1f), onClick)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -42,7 +44,6 @@ import app.seb3thehacker.gearslip.SessionStatus
|
||||
import app.seb3thehacker.gearslip.update.UpdateChecker
|
||||
import app.seb3thehacker.gearslip.update.UpdateChecker.State
|
||||
|
||||
private const val UNPLUG_FIRST = "Unplug from the car to install. Installing closes Gearslip."
|
||||
|
||||
/** Shown on Home once a newer version is found; hidden otherwise. */
|
||||
@Composable
|
||||
@@ -64,37 +65,37 @@ fun UpdateCard() {
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp)) {
|
||||
Text("Gearslip ${release.version} is out", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.update_card_title, release.version), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
when (val s = state) {
|
||||
is State.Downloading -> {
|
||||
Text("Downloading… ${(s.fraction * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(stringResource(R.string.update_downloading, (s.fraction * 100).toInt()), style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LinearProgressIndicator(progress = { s.fraction }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
is State.Ready -> {
|
||||
val connected = UpdateChecker.carConnected
|
||||
Text(
|
||||
if (connected) UNPLUG_FIRST else "Downloaded. Android asks before it installs.",
|
||||
if (connected) stringResource(R.string.update_unplug_first) else stringResource(R.string.update_downloaded_ask),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text("Install") }
|
||||
Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text(stringResource(R.string.update_install)) }
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
(s as? State.Failed)?.message ?: "Download it now. Android asks before it installs.",
|
||||
(s as? State.Failed)?.message ?: stringResource(R.string.update_download_now),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { UpdateChecker.download(context, release) }) {
|
||||
Text(if (s is State.Failed) "Try again" else "Update")
|
||||
Text(stringResource(if (s is State.Failed) R.string.update_try_again else R.string.update_update))
|
||||
}
|
||||
if (release.pageUrl.isNotBlank()) {
|
||||
TextButton(onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(release.pageUrl)))
|
||||
}) { Text("What's new") }
|
||||
}) { Text(stringResource(R.string.settings_whats_new)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,13 +107,14 @@ fun UpdateCard() {
|
||||
}
|
||||
|
||||
/** What the update check found, in a line. */
|
||||
@Composable
|
||||
private fun statusLine(state: State): String = when (state) {
|
||||
State.Idle -> if (BuildConfig.DEBUG) "Debug builds check only when you tap Check now" else "Gearslip checks once a day"
|
||||
State.Checking -> "Checking…"
|
||||
State.UpToDate -> "You have the latest version"
|
||||
is State.Available -> "Version ${state.release.version} is out"
|
||||
is State.Downloading -> "Downloading… ${(state.fraction * 100).toInt()}%"
|
||||
is State.Ready -> if (UpdateChecker.carConnected) UNPLUG_FIRST else "Version ${state.release.version} is ready to install"
|
||||
State.Idle -> stringResource(if (BuildConfig.DEBUG) R.string.update_debug_manual else R.string.update_checks_daily)
|
||||
State.Checking -> stringResource(R.string.update_checking)
|
||||
State.UpToDate -> stringResource(R.string.update_latest)
|
||||
is State.Available -> stringResource(R.string.update_version_out, state.release.version)
|
||||
is State.Downloading -> stringResource(R.string.update_downloading, (state.fraction * 100).toInt())
|
||||
is State.Ready -> if (UpdateChecker.carConnected) stringResource(R.string.update_unplug_first) else stringResource(R.string.update_version_ready, state.release.version)
|
||||
is State.Failed -> state.message
|
||||
}
|
||||
|
||||
@@ -135,7 +137,7 @@ internal fun UpdateRow() {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val channelName = CHANNELS.firstOrNull { it.first == channel }?.second
|
||||
SettingsRow(
|
||||
"Updates",
|
||||
stringResource(R.string.settings_updates),
|
||||
Modifier.clickable { open = true },
|
||||
subtitle = listOfNotNull(channelName, statusLine(state)).joinToString(" · "),
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
@@ -169,22 +171,23 @@ private fun UpdateDialog(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var auto by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
|
||||
val actionLabels = listOf(R.string.update_download, R.string.update_install, R.string.update_try_again, R.string.update_check_now).map { stringResource(it) }
|
||||
val action: Pair<String, () -> Unit>? = when (state) {
|
||||
State.Checking, is State.Downloading -> null
|
||||
is State.Available -> "Download" to { UpdateChecker.download(context, state.release) }
|
||||
is State.Ready -> if (UpdateChecker.carConnected) null else "Install" to { UpdateChecker.install(context) }
|
||||
is State.Failed -> "Try again" to {
|
||||
is State.Available -> actionLabels[0] to { UpdateChecker.download(context, state.release) }
|
||||
is State.Ready -> if (UpdateChecker.carConnected) null else actionLabels[1] to { UpdateChecker.install(context) }
|
||||
is State.Failed -> actionLabels[2] to {
|
||||
state.release?.let { UpdateChecker.download(context, it) } ?: UpdateChecker.check(context)
|
||||
}
|
||||
else -> "Check now" to { UpdateChecker.check(context) }
|
||||
else -> actionLabels[3] to { UpdateChecker.check(context) }
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Updates") },
|
||||
title = { Text(stringResource(R.string.settings_updates)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Version ${BuildConfig.VERSION_NAME}. ${statusLine(state)}.",
|
||||
stringResource(R.string.update_dialog_status, BuildConfig.VERSION_NAME, statusLine(state)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
@@ -218,9 +221,9 @@ private fun UpdateDialog(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Check automatically", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.update_check_auto), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
if (BuildConfig.DEBUG) "Debug builds check only when you tap Check now" else "Once a day",
|
||||
stringResource(if (BuildConfig.DEBUG) R.string.update_debug_manual else R.string.update_once_a_day),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -232,6 +235,6 @@ private fun UpdateDialog(
|
||||
confirmButton = {
|
||||
action?.let { (label, run) -> TextButton(onClick = run) { Text(label) } }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.guide_done)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -53,21 +56,9 @@ import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
|
||||
internal const val USAGE_STATS_TITLE = "Help show which cars work"
|
||||
|
||||
private const val INTRO_START =
|
||||
"Turn this on, and Gearslip sends a short note when you open it and after each drive. "
|
||||
private const val INTRO_PRIVACY =
|
||||
"The note holds no name, account, or location. A random number counts your phone once, and the server keeps no IP address."
|
||||
private const val INTRO_END =
|
||||
" The notes show which cars work and where the others fail."
|
||||
|
||||
/** The pitch for the usage notes, in setup and Settings, with the privacy promise in bold so it isn't missed. */
|
||||
internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString {
|
||||
append(INTRO_START)
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(INTRO_PRIVACY) }
|
||||
append(INTRO_END)
|
||||
}
|
||||
internal fun usageStatsIntro(context: Context): AnnotatedString =
|
||||
AnnotatedString.fromHtml(context.getString(R.string.usage_intro))
|
||||
|
||||
/** The usage notes card for the setup guide: the same rows Settings shows. */
|
||||
@Composable
|
||||
@@ -90,9 +81,9 @@ internal fun ColumnScope.UsageStatsRows() {
|
||||
DisposableEffect(Unit) { onDispose { UsageStats.choicesClosed(context) } }
|
||||
|
||||
SettingsRow(
|
||||
"Send usage notes",
|
||||
stringResource(R.string.usage_send),
|
||||
Modifier.toggleable(on, role = Role.Switch) { on = it; save() },
|
||||
subtitle = buildNote(),
|
||||
subtitle = buildNote(context),
|
||||
icon = Icons.Filled.Send,
|
||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||
)
|
||||
@@ -100,31 +91,31 @@ internal fun ColumnScope.UsageStatsRows() {
|
||||
Column {
|
||||
SettingsDivider()
|
||||
Text(
|
||||
"Also share",
|
||||
stringResource(R.string.usage_also_share),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
Choice("Android version and system", choices.android) { choices = choices.copy(android = it); save() }
|
||||
Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() }
|
||||
Choice("Each car and whether it connected", choices.cars) {
|
||||
Choice(stringResource(R.string.usage_share_android), choices.android) { choices = choices.copy(android = it); save() }
|
||||
Choice(stringResource(R.string.usage_share_phone), choices.phone) { choices = choices.copy(phone = it); save() }
|
||||
Choice(stringResource(R.string.usage_share_cars), choices.cars) {
|
||||
choices = choices.copy(cars = it)
|
||||
save()
|
||||
}
|
||||
Choice("Each drive and how it ended", choices.failures) {
|
||||
Choice(stringResource(R.string.usage_share_drives), choices.failures) {
|
||||
choices = choices.copy(failures = it)
|
||||
save()
|
||||
}
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"What gets sent",
|
||||
stringResource(R.string.usage_what_sent),
|
||||
Modifier.clickable { noteOpen = !noteOpen },
|
||||
subtitle = "Every field, exactly as it goes out",
|
||||
subtitle = stringResource(R.string.usage_what_sent_sub),
|
||||
icon = Icons.Filled.Info,
|
||||
trailing = {
|
||||
Icon(
|
||||
if (noteOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (noteOpen) "Hide the note" else "Show the note",
|
||||
contentDescription = stringResource(if (noteOpen) R.string.usage_hide_note else R.string.usage_show_note),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
@@ -167,15 +158,15 @@ private fun notePreview(context: Context): String {
|
||||
val preview = UsageStats.preview(context, everything = true)
|
||||
// Until the first drive the list is empty, so show what one entry looks like.
|
||||
val example = if (preview.optJSONArray("sessions")?.length() == 0) {
|
||||
"\n\nExample, not sent: one drive adds this to \"sessions\"\n" + UsageStats.exampleSession.toString(2)
|
||||
"\n\n" + context.getString(R.string.usage_example) + "\n" + UsageStats.exampleSession.toString(2)
|
||||
} else ""
|
||||
return preview.toString(2) + example
|
||||
}
|
||||
|
||||
/** What's different about where this build's notes go; null for a normal release. */
|
||||
private fun buildNote(): String? = when {
|
||||
BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing"
|
||||
BuildConfig.DEBUG -> "Debug build: notes are kept apart and never counted in the stats"
|
||||
private fun buildNote(context: Context): String? = when {
|
||||
BuildConfig.STATS_URL.isBlank() -> context.getString(R.string.usage_no_server)
|
||||
BuildConfig.DEBUG -> context.getString(R.string.usage_debug_build)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -197,18 +188,18 @@ internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modif
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null)
|
||||
Text(
|
||||
WorkedPrompt.TITLE_START + car,
|
||||
if (car.isEmpty()) stringResource(R.string.worked_title_unknown) else stringResource(R.string.worked_title, car),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Share that so the next driver knows it works? No name, account, or location.",
|
||||
stringResource(R.string.worked_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
buildNote()?.let {
|
||||
buildNote(context)?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
Row(
|
||||
@@ -217,11 +208,11 @@ internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modif
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) {
|
||||
Text("What gets sent")
|
||||
Text(stringResource(R.string.usage_what_sent))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") }
|
||||
Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") }
|
||||
OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text(stringResource(R.string.worked_not_now)) }
|
||||
Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text(stringResource(R.string.worked_share)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -35,11 +37,9 @@ internal fun VoiceModelSection() {
|
||||
remember { SpeechModels.init(context) }
|
||||
val selected by SpeechModels.selected.collectAsState()
|
||||
val final by SpeechModels.final.collectAsState()
|
||||
SettingsSection(title = "Voice: live words") {
|
||||
SettingsSection(title = stringResource(R.string.voice_live_title)) {
|
||||
Text(
|
||||
"The voice assistant turns speech into text on this phone, and nothing you say leaves it. " +
|
||||
"It needs two models, about 270 MB together, downloaded once. " +
|
||||
"This one writes your words as you speak.",
|
||||
stringResource(R.string.voice_live_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
@@ -49,10 +49,9 @@ internal fun VoiceModelSection() {
|
||||
ModelRow(model, model == selected)
|
||||
}
|
||||
}
|
||||
SettingsSection(title = "Voice: final words") {
|
||||
SettingsSection(title = stringResource(R.string.voice_final_title)) {
|
||||
Text(
|
||||
"When you stop talking, this model reads the whole phrase again. It catches names and " +
|
||||
"places the live model misses.",
|
||||
stringResource(R.string.voice_final_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
@@ -67,9 +66,9 @@ internal fun VoiceModelSection() {
|
||||
) {
|
||||
RadioButton(final == null, onClick = null, modifier = Modifier.padding(8.dp))
|
||||
Column {
|
||||
Text("None", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_none), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Keep the live words. Fastest, least accurate",
|
||||
stringResource(R.string.voice_none_sub),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -95,12 +94,12 @@ private fun ModelRow(model: Model, chosen: Boolean) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(chosen, onClick = null, modifier = Modifier.padding(8.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("${model.label} · ${model.downloadMb} MB", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_model_size, model.label, model.downloadMb), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
when (val s = state) {
|
||||
State.Ready -> "Downloaded. ${model.detail}"
|
||||
is State.Downloading -> "Downloading, ${s.percent}%"
|
||||
is State.Failed -> "Download failed: ${s.reason}"
|
||||
State.Ready -> stringResource(R.string.voice_downloaded, model.detail)
|
||||
is State.Downloading -> stringResource(R.string.voice_downloading, s.percent)
|
||||
is State.Failed -> stringResource(R.string.cert_download_failed, s.reason)
|
||||
State.Missing -> model.detail
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -109,9 +108,9 @@ private fun ModelRow(model: Model, chosen: Boolean) {
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
when (state) {
|
||||
State.Ready -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text("Delete") }
|
||||
is State.Downloading -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text("Cancel") }
|
||||
else -> OutlinedButton(onClick = { SpeechModels.download(context, model) }) { Text("Download") }
|
||||
State.Ready -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text(stringResource(R.string.storage_delete)) }
|
||||
is State.Downloading -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text(stringResource(R.string.cancel)) }
|
||||
else -> OutlinedButton(onClick = { SpeechModels.download(context, model) }) { Text(stringResource(R.string.update_download)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||