mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
back buttons on every screen, exit to the car's own UI, smoother link
Every screen reached from Home has a themed back button in its header. A new Exit tile asks the car to show its own interface, as Android Auto's does. All writes to the car now go through one thread, so reading never waits on a write; LIVI no longer stalls for two seconds at a time. Touches go to the front of the main thread's queue.
This commit is contained in:
1 parent
1adeb6f7e1
commit
0eeb486d88
13 files changed
+246
-55
No files matched your search
@@ -0,0 +1,28 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Who has the car's screen: Gearslip, or the car's own interface. Android Auto's "Exit" hands the
|
||||
* screen back to the car with the session still running, so music keeps playing; the car's own
|
||||
* Android Auto button brings Gearslip back. [GearslipRunner] fills in [request] while connected.
|
||||
*/
|
||||
object CarFocus {
|
||||
|
||||
private val projectedFlow = MutableStateFlow(true)
|
||||
|
||||
/** False while the car shows its own interface. */
|
||||
val projected: StateFlow<Boolean> = projectedFlow
|
||||
|
||||
@Volatile internal var request: (() -> Unit)? = null
|
||||
|
||||
/** Asks the car to show its own interface. Does nothing when no car is connected. */
|
||||
fun exitToCar() {
|
||||
request?.invoke()
|
||||
}
|
||||
|
||||
internal fun set(projected: Boolean) {
|
||||
projectedFlow.value = projected
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ import java.io.FileOutputStream
|
||||
* rotation or theme change from recreating it and dropping the connection.
|
||||
*/
|
||||
class GearslipActivity : ComponentActivity(), Projection {
|
||||
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
|
||||
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
private var runner: GearslipRunner? = null
|
||||
@@ -323,7 +325,9 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
|
||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||
runOnUiThread {
|
||||
// To the front of the main thread's queue: a touch waiting behind other work is lag the
|
||||
// driver feels, while the work it jumps ahead of can wait a frame.
|
||||
mainHandler.postAtFrontOfQueue {
|
||||
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
||||
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
||||
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
||||
|
||||
@@ -41,6 +41,9 @@ class GearslipRunner(
|
||||
private val assembler = Frames.Assembler()
|
||||
private lateinit var identityTask: FutureTask<PhoneTls>
|
||||
|
||||
/** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */
|
||||
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor { Thread(it, "gearslip-writer") }
|
||||
|
||||
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
||||
private val tls: PhoneTls by lazy { identityTask.get() }
|
||||
|
||||
@@ -127,6 +130,9 @@ class GearslipRunner(
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
writer.shutdownNow()
|
||||
CarFocus.request = null
|
||||
CarFocus.set(true)
|
||||
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
||||
videoSource?.stop()
|
||||
videoSource = null
|
||||
@@ -589,6 +595,8 @@ class GearslipRunner(
|
||||
val at = points[actionIndex]
|
||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||
}
|
||||
// Time the next frame against this tap: see [reportTouchLatency].
|
||||
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
|
||||
projection?.onTouch(action, actionIndex, points)
|
||||
}
|
||||
|
||||
@@ -691,14 +699,17 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
// VideoFocusNotification { focus = 1; unsolicited = 2 }
|
||||
val fields = Wire.fields(body)
|
||||
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
|
||||
// The head unit repeats this while it waits for frames; only act once.
|
||||
if (!videoStarted) {
|
||||
videoStarted = true
|
||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||
startVideoSource()
|
||||
}
|
||||
val mode = Wire.varint(fields, 1)?.toInt() ?: VIDEO_FOCUS_PROJECTED
|
||||
log.i("<- VideoFocusNotification: mode=$mode unsolicited=${Wire.varint(fields, 2)}")
|
||||
onVideoFocus(mode)
|
||||
}
|
||||
|
||||
// Not in aasdk's direction of travel, but LIVI sends it: log what it asks for.
|
||||
MSG_VIDEO_FOCUS_REQUEST -> {
|
||||
val fields = Wire.fields(body)
|
||||
log.i("<- VideoFocusRequest from the head unit: mode=${Wire.varint(fields, 2)} reason=${Wire.varint(fields, 3)}")
|
||||
}
|
||||
|
||||
MSG_MEDIA_ACK -> {
|
||||
@@ -772,6 +783,7 @@ class GearslipRunner(
|
||||
frameRate = if (config.frameRate == 1) 60 else 30,
|
||||
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
||||
onCodecConfig = { csd ->
|
||||
lastCodecConfig = csd
|
||||
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
||||
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
||||
log.hex(" csd", csd, limit = 64)
|
||||
@@ -800,6 +812,8 @@ class GearslipRunner(
|
||||
* makes it produce fewer frames, each one whole.
|
||||
*/
|
||||
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
|
||||
// The car is showing its own screen and would throw these away.
|
||||
if (!videoProjected) return
|
||||
synchronized(ackLock) {
|
||||
val waitStart = System.currentTimeMillis()
|
||||
while (running && inFlight >= maxUnacked) {
|
||||
@@ -809,6 +823,7 @@ class GearslipRunner(
|
||||
if (since > ACK_STALL_MS) {
|
||||
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis() // one warning per stall, not one per frame
|
||||
break
|
||||
}
|
||||
ackLock.wait(ACK_STALL_MS - since + 1)
|
||||
@@ -828,15 +843,11 @@ class GearslipRunner(
|
||||
resyncAttempts = 0
|
||||
lastKeyframeSentAt = System.currentTimeMillis()
|
||||
}
|
||||
try {
|
||||
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||
} catch (e: java.io.IOException) {
|
||||
if (running) log.w("video send failed, stopping the encoder: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
return
|
||||
}
|
||||
inFlight++
|
||||
// Counted before it's queued: the writer may send it and the ack come back before this
|
||||
// thread runs again, and an ack counted ahead of its frame would leave the window full.
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||
reportTouchLatency(presentationTimeUs)
|
||||
framesSent++
|
||||
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
||||
log.i(
|
||||
@@ -846,6 +857,74 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the car is showing Gearslip; false while it shows its own interface. */
|
||||
@Volatile private var videoProjected = true
|
||||
/** The encoder's SPS/PPS, resent when the car hands its screen back. */
|
||||
@Volatile private var lastCodecConfig: ByteArray? = null
|
||||
|
||||
/**
|
||||
* The car says who has its screen. The first grant starts the video. After that the car may
|
||||
* take its screen back (our Exit, or its own buttons) and later return it: while it has the
|
||||
* screen nothing is sent, and on return the stream restarts from the codec config and a fresh
|
||||
* keyframe, since the car's decoder may have been reset in between.
|
||||
*/
|
||||
private fun onVideoFocus(mode: Int) {
|
||||
val projected = mode == VIDEO_FOCUS_PROJECTED || mode == VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||
if (!videoStarted) {
|
||||
// The head unit repeats this while it waits for frames; only start once.
|
||||
if (!projected) return
|
||||
videoStarted = true
|
||||
CarFocus.request = ::requestNativeFocus
|
||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||
startVideoSource()
|
||||
return
|
||||
}
|
||||
if (projected == videoProjected) return
|
||||
videoProjected = projected
|
||||
CarFocus.set(projected)
|
||||
if (projected) {
|
||||
log.i("the car handed its screen back - restarting the stream")
|
||||
// Frames sent just before the car took over may never be acked.
|
||||
synchronized(ackLock) {
|
||||
inFlight = 0
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
|
||||
videoSource?.requestSyncFrame()
|
||||
} else {
|
||||
log.i("the car is showing its own screen - video paused, audio carries on")
|
||||
}
|
||||
}
|
||||
|
||||
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
|
||||
private fun requestNativeFocus() {
|
||||
if (!videoStarted) return
|
||||
// VideoFocusRequestNotification { mode = 2; reason = 3 }
|
||||
val request = Protobuf.varintField(2, VIDEO_FOCUS_NATIVE.toLong()) +
|
||||
Protobuf.varintField(3, VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong())
|
||||
send(MSG_VIDEO_FOCUS_REQUEST, request, encrypted = true, channel = videoChannelId)
|
||||
log.i("-> VideoFocusRequest(mode=NATIVE, reason=LAUNCH_NATIVE)")
|
||||
}
|
||||
|
||||
/** Arrival time of the last tap still waiting to be seen in a frame; 0 when none is. */
|
||||
@Volatile private var touchAtUs = 0L
|
||||
|
||||
/**
|
||||
* How long a tap takes to reach the car, split where the phone can see it: the UI drawing a
|
||||
* frame after the tap, the encoder compressing it, and the send. A frame's timestamp is when
|
||||
* the UI finished drawing it, on the same clock as [touchAtUs]. The head unit's own decode
|
||||
* and display come on top and can't be seen from here.
|
||||
*/
|
||||
private fun reportTouchLatency(presentationTimeUs: Long) {
|
||||
val touchAt = touchAtUs
|
||||
if (touchAt == 0L || presentationTimeUs <= touchAt) return
|
||||
touchAtUs = 0L
|
||||
val nowUs = System.nanoTime() / 1000
|
||||
val draw = (presentationTimeUs - touchAt) / 1000
|
||||
val encode = (nowUs - presentationTimeUs) / 1000
|
||||
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
|
||||
}
|
||||
|
||||
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||
limit == null -> value.toFloat()
|
||||
value < 0 -> 0f
|
||||
@@ -872,35 +951,49 @@ class GearslipRunner(
|
||||
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
|
||||
) + body
|
||||
|
||||
synchronized(output) {
|
||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||
} else {
|
||||
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
||||
// separately, and carry the total plaintext length in the FIRST frame.
|
||||
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
||||
var offset = 0
|
||||
while (offset < payload.size) {
|
||||
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
||||
val chunk = payload.copyOfRange(offset, offset + size)
|
||||
val frameType = when {
|
||||
offset == 0 -> Frames.TYPE_FIRST
|
||||
offset + size >= payload.size -> Frames.TYPE_LAST
|
||||
else -> Frames.TYPE_MIDDLE
|
||||
// Every write goes through one thread, in order. The reading thread must never wait on
|
||||
// a write: a head unit like LIVI writes its ack before it reads on, so a reader stuck
|
||||
// behind a big video frame (to answer a ping) and a head unit stuck writing that ack
|
||||
// held each other up until something timed out, about two seconds a time.
|
||||
try {
|
||||
writer.execute {
|
||||
try {
|
||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||
} else {
|
||||
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
||||
// separately, and carry the total plaintext length in the FIRST frame.
|
||||
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
||||
var offset = 0
|
||||
while (offset < payload.size) {
|
||||
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
||||
val chunk = payload.copyOfRange(offset, offset + size)
|
||||
val frameType = when {
|
||||
offset == 0 -> Frames.TYPE_FIRST
|
||||
offset + size >= payload.size -> Frames.TYPE_LAST
|
||||
else -> Frames.TYPE_MIDDLE
|
||||
}
|
||||
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
||||
output.write(
|
||||
Frames.build(
|
||||
channel, encrypted, framePayload, messageType, frameType,
|
||||
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
||||
),
|
||||
)
|
||||
offset += size
|
||||
}
|
||||
splitMessages++
|
||||
}
|
||||
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
||||
output.write(
|
||||
Frames.build(
|
||||
channel, encrypted, framePayload, messageType, frameType,
|
||||
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
||||
),
|
||||
)
|
||||
offset += size
|
||||
output.flush()
|
||||
} catch (e: java.io.IOException) {
|
||||
if (running) log.w("send failed, stopping: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
}
|
||||
splitMessages++
|
||||
}
|
||||
output.flush()
|
||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||
// The session is over; nothing more goes out.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,6 +1132,9 @@ class GearslipRunner(
|
||||
const val STREAM_MEDIA = 3
|
||||
const val CODEC_H264_BP = 3
|
||||
const val VIDEO_FOCUS_PROJECTED = 1
|
||||
const val VIDEO_FOCUS_NATIVE = 2
|
||||
const val VIDEO_FOCUS_PROJECTED_NO_INPUT = 4
|
||||
const val VIDEO_FOCUS_REASON_LAUNCH_NATIVE = 2
|
||||
const val SESSION_ID = 1
|
||||
const val MSG_INPUT_REPORT = 32769
|
||||
const val MSG_KEY_BINDING_REQUEST = 32770
|
||||
|
||||
@@ -60,7 +60,7 @@ fun CarDashboardScreen() {
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
ScreenHeader("Today")
|
||||
WeatherCard(context)
|
||||
AgendaCard(context)
|
||||
NotificationsSection()
|
||||
|
||||
@@ -167,7 +167,7 @@ internal fun rememberAllEntries(): List<Entry> {
|
||||
}
|
||||
|
||||
/** The launcher's groups, in the order they're laid out. */
|
||||
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS }
|
||||
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT }
|
||||
|
||||
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||
entry.builtIn?.let {
|
||||
@@ -175,6 +175,7 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||
BuiltInApps.Phone -> AppGroup.PHONE
|
||||
BuiltInApps.Web -> AppGroup.WEB
|
||||
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
||||
BuiltInApps.Exit -> AppGroup.EXIT
|
||||
else -> AppGroup.SETTINGS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ fun CarSettingsScreen() {
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
ScreenHeader("Settings")
|
||||
|
||||
ChoiceRow(
|
||||
"Open on connect",
|
||||
|
||||
@@ -40,11 +40,26 @@ import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.CarFocus
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
|
||||
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
|
||||
internal const val BUILT_IN_PREFIX = "gearslip:"
|
||||
|
||||
/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
|
||||
/** Material's "directions car", drawn here because only the core icon set is bundled. */
|
||||
private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f, 24f).addPath(
|
||||
PathParser().parsePathString(
|
||||
"M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1" +
|
||||
"c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99z" +
|
||||
"M6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16z" +
|
||||
"M17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" +
|
||||
"M5,11l1.5,-4.5h11L19,11L5,11z",
|
||||
).toNodes(),
|
||||
fill = SolidColor(Color.Black),
|
||||
).build()
|
||||
|
||||
internal class BuiltInApp(
|
||||
val id: String,
|
||||
val label: String,
|
||||
@@ -60,7 +75,10 @@ internal object BuiltInApps {
|
||||
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 all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings)
|
||||
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
|
||||
val Exit = BuiltInApp("exit", "Exit", CarGlyph, CarScreen.Home) { CarFocus.exitToCar() }
|
||||
|
||||
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
|
||||
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ fun PhoneAppsScreen() {
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
ScreenHeader("Screen sharing")
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (!TouchRelayService.enabled) {
|
||||
TouchRelayWarning()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
@@ -101,13 +101,7 @@ fun PhoneDialerScreen() {
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
// The tabs share the title's row, so the list and keypad get the height back.
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Phone",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ScreenHeader("Phone") {
|
||||
if (hasPermissions) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
|
||||
/**
|
||||
* The top of every screen reached from Home: a back button in the current theme, the title, and
|
||||
* room on the right for the screen's own controls. A car touchscreen has no hardware back button,
|
||||
* so this is the way out of a screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
Row(
|
||||
modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ fun VehicleDataScreen() {
|
||||
val readings by CarSensors.readings.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
||||
Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
ScreenHeader("Vehicle data")
|
||||
Text(
|
||||
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
||||
"watch a row change while you do the thing (brake, drive, headlights) to confirm it.",
|
||||
|
||||
@@ -41,6 +41,7 @@ fun WeatherScreen() {
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
ScreenHeader("Weather")
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -53,6 +55,8 @@ fun WebApp(content: String) {
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||
ScreenHeader("Web", Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
AndroidView(
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
factory = { context ->
|
||||
|
||||
Reference in new issue
Block a user