mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Add phone call handling on the car screen
Incoming calls now show the caller (name via contacts, else number) on the car UI with answer/decline, using a call screening service so the car sees a call before it rings. Also adds pinnable nav bar shortcuts, lowers minSdk to 31 for wider device support, and bumps to 0.0.04-pre.
This commit is contained in:
1 parent
48be745659
commit
043dc11251
24 files changed
+1233
-353
No files matched your search
+2
-1
@@ -22,4 +22,5 @@ keystore.properties
|
||||
/docs/cert-sweep.sh
|
||||
archive/
|
||||
CLAUDE.md
|
||||
*.pdf
|
||||
*.pdf
|
||||
extraction/
|
||||
@@ -30,10 +30,10 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 35
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 3
|
||||
versionName = "0.0.03-pre"
|
||||
versionCode = 4
|
||||
versionName = "0.0.04-pre"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -39,6 +39,18 @@
|
||||
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!--
|
||||
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
||||
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
|
||||
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
||||
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
||||
and spam app too - an on-device consent, not a permission declared here.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
|
||||
<!--
|
||||
Declared, not just requested. Templated apps decide whether to talk to a host by asking
|
||||
whether it holds this permission (HostValidator.isValidHost checks it before falling back
|
||||
@@ -158,5 +170,21 @@
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!--
|
||||
Sees incoming calls before they ring, so the car screen can show the caller and
|
||||
decline it. Inert until the driver sets Gearslip as the phone's caller ID and spam
|
||||
app, under Settings > Apps > Default apps - the same consent a call-blocking app asks
|
||||
for, not a privileged one.
|
||||
-->
|
||||
<service
|
||||
android:name=".call.GearslipCallScreening"
|
||||
android:exported="true"
|
||||
android:label="Gearslip calls"
|
||||
android:permission="android.permission.BIND_SCREENING_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.telecom.CallScreeningService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -104,6 +104,19 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
||||
|
||||
/**
|
||||
* Caller name, incoming number and call state; declined just means no phone card on the car
|
||||
* screen. Asked for up front, like location and the calendar, so nothing prompts mid-drive.
|
||||
*/
|
||||
private val callPermissions = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { granted -> if (granted.values.any { it }) app.seb3thehacker.gearslip.call.CarCalls.start(this) }
|
||||
|
||||
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
|
||||
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
|
||||
@@ -120,6 +133,15 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
||||
val callPerms = arrayOf(
|
||||
android.Manifest.permission.READ_PHONE_STATE,
|
||||
android.Manifest.permission.READ_CALL_LOG,
|
||||
android.Manifest.permission.READ_CONTACTS,
|
||||
android.Manifest.permission.ANSWER_PHONE_CALLS,
|
||||
)
|
||||
if (callPerms.any { checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED }) {
|
||||
callPermissions.launch(callPerms)
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
@@ -137,7 +159,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
|
||||
setContent {
|
||||
GearslipTheme {
|
||||
GearslipApp(onDisconnect = ::closeAccessory)
|
||||
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +203,19 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
/** Launches the system dialog that offers Gearslip the call-screening role, if it isn't held already. */
|
||||
private fun requestCallScreeningRole() {
|
||||
val roleManager = getSystemService(android.app.role.RoleManager::class.java)
|
||||
if (roleManager == null || !roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_CALL_SCREENING)) {
|
||||
GearslipLog.w("calls: call screening role unavailable on this phone")
|
||||
return
|
||||
}
|
||||
if (roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_CALL_SCREENING)) return
|
||||
runCatching {
|
||||
callScreeningRole.launch(roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_CALL_SCREENING))
|
||||
}.onFailure { GearslipLog.e("could not ask for the call screening role", it) }
|
||||
}
|
||||
|
||||
// --- Media audio --------------------------------------------------------------------
|
||||
|
||||
private fun requestAudioCapture() {
|
||||
|
||||
@@ -181,16 +181,22 @@ class GearslipRunner(
|
||||
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||
SessionReport.protocolVersion(major, minor)
|
||||
|
||||
// Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than
|
||||
// asserting 1.6 is the permissive choice: we only need to reach service discovery,
|
||||
// not to honour whatever that version implies.
|
||||
// PROTOCOL-DOWNGRADE EXPERIMENT. The 2018 Uconnect speaks 1.3 and ACCEPTS the JVC cert;
|
||||
// the 2025 unit speaks 4.3 and rejects it at AuthComplete (-3). If the 4.3 identity check
|
||||
// is tied to the *negotiated* protocol version rather than baked into the firmware,
|
||||
// claiming an older version makes the newer unit skip the check entirely. CLAIM_VERSION
|
||||
// = null restores the previous echo-the-head-unit behaviour.
|
||||
val claim = CLAIM_VERSION ?: (major to minor)
|
||||
val response = byteArrayOf(
|
||||
((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(),
|
||||
((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(),
|
||||
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
|
||||
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
|
||||
0, 0, // STATUS_SUCCESS
|
||||
)
|
||||
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
||||
log.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
|
||||
log.i(
|
||||
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
|
||||
if (CLAIM_VERSION != null) " [FORCED; head unit offered $major.$minor]" else "",
|
||||
)
|
||||
state = State.TLS_HANDSHAKE
|
||||
SessionStatus.connecting("Securing the link")
|
||||
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
||||
@@ -787,6 +793,13 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Force the protocol version Gearslip claims in VersionResponse, as (major, minor), or
|
||||
* null to echo the head unit. 1.3 is what the 2018 Uconnect (which accepts the JVC cert)
|
||||
* speaks. This is the 2025 identity-check downgrade experiment: see onVersionRequest.
|
||||
*/
|
||||
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
|
||||
|
||||
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||
const val MSG_VERSION_REQUEST = 1
|
||||
const val MSG_VERSION_RESPONSE = 2
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.ContactsContract
|
||||
import android.telecom.TelecomManager
|
||||
import android.telephony.TelephonyCallback
|
||||
import android.telephony.TelephonyManager
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
|
||||
data class Caller(val number: String, val name: String?) {
|
||||
val label: String get() = name ?: number.ifBlank { "Unknown number" }
|
||||
}
|
||||
|
||||
sealed interface CallUiState {
|
||||
data object None : CallUiState
|
||||
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
|
||||
data class Active(val caller: Caller?) : CallUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the phone's call state for the car screen, and answers or declines through the two
|
||||
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
|
||||
* and [GearslipCallScreening]'s response to decline one that is still ringing.
|
||||
*
|
||||
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip
|
||||
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
|
||||
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
|
||||
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
|
||||
* only ever carries media playback.
|
||||
*/
|
||||
object CarCalls {
|
||||
|
||||
private val _state = MutableStateFlow<CallUiState>(CallUiState.None)
|
||||
val state: StateFlow<CallUiState> = _state.asStateFlow()
|
||||
|
||||
private var telephony: TelephonyManager? = null
|
||||
private var watcher: TelephonyCallback? = null
|
||||
private var pendingRespond: ((allow: Boolean) -> Unit)? = null
|
||||
private var ringingCaller: Caller? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
if (watcher != null) return
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
|
||||
val manager = context.getSystemService(TelephonyManager::class.java) ?: return
|
||||
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||
override fun onCallStateChanged(state: Int) = onPhoneState(state)
|
||||
}
|
||||
runCatching { manager.registerTelephonyCallback(context.mainExecutor, callback) }
|
||||
.onSuccess { telephony = manager; watcher = callback }
|
||||
.onFailure { GearslipLog.w("calls: could not watch call state: ${it.message}") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val callback = watcher ?: return
|
||||
runCatching { telephony?.unregisterTelephonyCallback(callback) }
|
||||
telephony = null
|
||||
watcher = null
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
|
||||
private fun onPhoneState(state: Int) {
|
||||
when (state) {
|
||||
TelephonyManager.CALL_STATE_IDLE -> {
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||
pendingRespond = null
|
||||
_state.value = CallUiState.Active(ringingCaller)
|
||||
}
|
||||
TelephonyManager.CALL_STATE_RINGING -> {
|
||||
// The number itself only ever arrives through the screening service; if this
|
||||
// fires first, the ringing card appears once incomingCall() catches up.
|
||||
ringingCaller?.let { _state.value = CallUiState.Ringing(it, pendingRespond != null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by [GearslipCallScreening] the moment a call comes in - the only place with a number. */
|
||||
fun incomingCall(context: Context, number: String?, respond: (allow: Boolean) -> Unit) {
|
||||
val caller = Caller(number.orEmpty(), number?.let { lookUpName(context, it) })
|
||||
ringingCaller = caller
|
||||
pendingRespond = respond
|
||||
_state.value = CallUiState.Ringing(caller, canDecline = true)
|
||||
}
|
||||
|
||||
/** The screening window closed by itself - the call rings normally now, and can't be declined from here. */
|
||||
fun screeningExpired() {
|
||||
pendingRespond = null
|
||||
val caller = ringingCaller ?: return
|
||||
if (_state.value is CallUiState.Ringing) _state.value = CallUiState.Ringing(caller, canDecline = false)
|
||||
}
|
||||
|
||||
fun decline() {
|
||||
val respond = pendingRespond ?: return
|
||||
pendingRespond = null
|
||||
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun answer(context: Context) {
|
||||
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
|
||||
return
|
||||
}
|
||||
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
|
||||
runCatching { telecom.acceptRingingCall() }
|
||||
.onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun lookUpName(context: Context, number: String): String? {
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
|
||||
return runCatching {
|
||||
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
|
||||
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
|
||||
internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
|
||||
Handler(Looper.getMainLooper()).postDelayed(action, ms)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.telecom.Call
|
||||
import android.telecom.CallScreeningService
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
|
||||
/**
|
||||
* Sees every incoming call before it rings, so the car screen can show who is calling and
|
||||
* decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
|
||||
* as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
|
||||
* for, not a privileged permission.
|
||||
*
|
||||
* The system gives an app a few seconds to answer before it lets the call ring on its own, so a
|
||||
* response is sent either when the driver taps a button or when that window is about to close,
|
||||
* whichever comes first. Answering a call that has already passed this point still works, since
|
||||
* [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
|
||||
*/
|
||||
class GearslipCallScreening : CallScreeningService() {
|
||||
|
||||
override fun onScreenCall(callDetails: Call.Details) {
|
||||
val number = callDetails.handle?.schemeSpecificPart
|
||||
var responded = false
|
||||
|
||||
val respond: (Boolean) -> Unit = { allow ->
|
||||
if (!responded) {
|
||||
responded = true
|
||||
runCatching {
|
||||
respondToCall(
|
||||
callDetails,
|
||||
CallResponse.Builder()
|
||||
.setDisallowCall(!allow)
|
||||
.setRejectCall(!allow)
|
||||
.setSkipNotification(false)
|
||||
.build(),
|
||||
)
|
||||
}.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
CarCalls.incomingCall(applicationContext, number, respond)
|
||||
|
||||
// Answer for the system if nobody has by the time its own patience runs out, so a call
|
||||
// Gearslip is slow to show never gets silently blocked.
|
||||
mainThreadDelay(SCREENING_WINDOW_MS) {
|
||||
respond(true)
|
||||
CarCalls.screeningExpired()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SCREENING_WINDOW_MS = 3_500L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
|
||||
/**
|
||||
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the
|
||||
* whole screen, because nothing else matters more right now; an active call is a thin band along
|
||||
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
|
||||
*/
|
||||
@Composable
|
||||
fun CallOverlay(modifier: Modifier = Modifier) {
|
||||
val state by CarCalls.state.collectAsState()
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
when (val s = state) {
|
||||
// Covers the box regardless of where the caller aligned it, so the alignment below is
|
||||
// only ever felt by the active-call banner, which is the one that isn't full-screen.
|
||||
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
|
||||
is CallUiState.Active -> ActiveCallBanner(s, modifier)
|
||||
CallUiState.None -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline: () -> Unit) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerLowest) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Call,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Incoming call",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
s.caller.label,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
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)
|
||||
}
|
||||
if (!s.canDecline) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Already ringing out loud - answer here, or let it ring.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
FilledIconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(72.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(containerColor = color, contentColor = Color.White),
|
||||
) { Icon(icon, contentDescription = label, modifier = Modifier.size(30.dp)) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.height(0.dp))
|
||||
Text(
|
||||
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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
|
||||
@@ -13,11 +11,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 androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -27,32 +22,26 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import android.media.session.PlaybackState
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
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.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import kotlinx.coroutines.delay
|
||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The home screen: the map on the left, what is playing on the right.
|
||||
* The home screen: the map on the left, lyrics on the right when asked for.
|
||||
*
|
||||
* The map is whatever navigation app was used last, brought back on its own. The player only
|
||||
* takes its share of the screen only while something is playing or paused; otherwise the map has all of it.
|
||||
* The map is whatever navigation app was used last, brought back on its own. Playback itself is
|
||||
* controlled from the nav bar's pill everywhere, including here - this column only opens when the
|
||||
* pill's lyrics button is on, and closes itself back to a full-width map once nothing is playing.
|
||||
*/
|
||||
@Composable
|
||||
fun CarHome() {
|
||||
@@ -65,21 +54,19 @@ fun CarHome() {
|
||||
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
|
||||
|
||||
val navigator = LocalCarNavigator.current
|
||||
val showPlayer = phase == CarMedia.Phase.READY && now.isActive && !navigator.playerMinimised
|
||||
// Once playback ends there is nothing left to minimise; the next song starts in place.
|
||||
LaunchedEffect(now.isActive) { if (!now.isActive) navigator.restorePlayer() }
|
||||
val showLyrics = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen
|
||||
|
||||
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(if (showPlayer) 2.1f else 1f)
|
||||
.weight(if (showLyrics) 2.1f else 1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
|
||||
) {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
if (showPlayer) {
|
||||
MiniPlayer(Modifier.weight(1f).fillMaxHeight())
|
||||
if (showLyrics) {
|
||||
LyricsColumn(now, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +85,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val hasLast = CarSettings.lastNav.value != null
|
||||
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)
|
||||
@@ -107,110 +95,56 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
TextButton(onClick = navigator::apps) { Text("Choose another app") }
|
||||
}
|
||||
else -> {
|
||||
// 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(
|
||||
if (hasLast) "Starting the map…" else "No map app chosen yet.",
|
||||
if (hasLast) "Map stopped." else "No map app chosen yet.",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (!hasLast) {
|
||||
if (hasLast) {
|
||||
TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") }
|
||||
} else {
|
||||
Text(
|
||||
"Open Car apps and connect a navigation app; it will start here next time.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
|
||||
}
|
||||
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Previous, play/pause, next and a seek bar, and nothing else: the full controls are in the media screen. */
|
||||
/**
|
||||
* Lyrics for whatever the nav bar's pill is playing, beside the map. Transport controls live in
|
||||
* the pill itself now, not here - this column has exactly one job.
|
||||
*/
|
||||
@Composable
|
||||
private fun MiniPlayer(modifier: Modifier) {
|
||||
val media = CarServices.media
|
||||
val now by media.now.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
var position by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(now) {
|
||||
while (true) {
|
||||
position = now.currentPosition()
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LyricsColumn(now: NowPlaying, modifier: Modifier) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
Surface(
|
||||
onClick = { navigator.media() },
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(EMBEDDED_RADIUS),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(Modifier.size(128.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist,
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (now.durationMs > 0) {
|
||||
WavyProgress(
|
||||
fraction = position.toFloat() / now.durationMs,
|
||||
playing = now.state == PlaybackState.STATE_PLAYING,
|
||||
onSeek = { media.seek((it * now.durationMs).toLong()) },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
now.title.ifEmpty { "Lyrics" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = { media.previous() }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(MediaIcons.Previous, "Previous", Modifier.size(30.dp))
|
||||
}
|
||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(60.dp)) {
|
||||
Icon(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(30.dp))
|
||||
IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val compact = PaddingValues(horizontal = 8.dp)
|
||||
FilledTonalButton(
|
||||
onClick = { navigator.media(lyrics = true) },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = compact,
|
||||
) { Text("Lyrics", maxLines = 1, softWrap = false) }
|
||||
FilledTonalButton(
|
||||
onClick = navigator::minimisePlayer,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = compact,
|
||||
) { Text("Minimise", maxLines = 1, softWrap = false) }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -60,23 +63,29 @@ private class Tile(
|
||||
val glyph: ImageVector? = null,
|
||||
/** Tested from start to finish with Gearslip; drawn with a check mark. */
|
||||
val verified: Boolean = false,
|
||||
val pinned: Boolean = false,
|
||||
/** Null for the built-in tiles (Web, Screen sharing, Settings) - nothing to pin them as. */
|
||||
val onLongClick: (() -> Unit)? = null,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
|
||||
private class Entry(
|
||||
internal class Entry(
|
||||
val label: String,
|
||||
val icon: ImageBitmap?,
|
||||
val template: TemplateApp? = null,
|
||||
val media: MediaApp? = null,
|
||||
)
|
||||
) {
|
||||
/** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
|
||||
val componentId: String? get() = (template?.component ?: media?.component)?.flattenToString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed car and media apps with their icons. Scanning the package manager and decoding
|
||||
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
|
||||
* which is when new installs are picked up.
|
||||
*/
|
||||
private object LauncherCache {
|
||||
internal object LauncherCache {
|
||||
@Volatile var entries: List<Entry>? = null
|
||||
|
||||
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
|
||||
@@ -96,6 +105,16 @@ private object LauncherCache {
|
||||
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
|
||||
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
|
||||
|
||||
/**
|
||||
* Connects or opens [entry] - what tapping its tile does, shared with the nav bar's pinned
|
||||
* shortcuts and its open-app icon so there is exactly one place that knows how to launch either
|
||||
* kind of app.
|
||||
*/
|
||||
internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
|
||||
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||
}
|
||||
|
||||
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
||||
@Composable
|
||||
fun CarLauncher() {
|
||||
@@ -108,15 +127,20 @@ fun CarLauncher() {
|
||||
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||
}
|
||||
|
||||
val pinned by CarSettings.pinnedApps.collectAsState()
|
||||
|
||||
val tiles = listOf(
|
||||
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
|
||||
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
|
||||
) + installed.map { entry ->
|
||||
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
|
||||
Tile(entry.label, entry.icon, verified = pkg != null && KnownApps.works(pkg)) {
|
||||
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||
}
|
||||
val id = entry.componentId
|
||||
Tile(
|
||||
entry.label, entry.icon,
|
||||
verified = pkg != null && KnownApps.works(pkg),
|
||||
pinned = id != null && id in pinned,
|
||||
onLongClick = id?.let { { CarSettings.togglePin(it) } },
|
||||
) { launchEntry(entry, navigator, frame) }
|
||||
} + listOf(
|
||||
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
|
||||
)
|
||||
@@ -164,12 +188,19 @@ private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AppTile(tile: Tile) {
|
||||
val shape = MaterialTheme.shapes.extraLarge
|
||||
Surface(
|
||||
onClick = tile.onClick,
|
||||
modifier = Modifier.height(120.dp),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier
|
||||
.height(120.dp)
|
||||
.let { if (tile.pinned) it.border(2.dp, MaterialTheme.colorScheme.primary, shape) else it }
|
||||
// combinedClickable, not Surface's own onClick, so a long press can mean something
|
||||
// different from a tap - pinning is deliberately the same gesture everywhere apps
|
||||
// are shown (here and, once pinned, the shortcut itself on the nav bar).
|
||||
.combinedClickable(onClick = tile.onClick, onLongClick = tile.onLongClick),
|
||||
shape = shape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
@@ -24,58 +25,81 @@ sealed interface CarScreen {
|
||||
data class App(val id: String) : CarScreen
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
||||
/**
|
||||
* Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by
|
||||
* the on-screen one; an open app can claim it first (a browser going back a page) through
|
||||
* [backInterceptor].
|
||||
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
|
||||
* every screen that isn't reached from [home] is pushed onto a trail (like a browser's history,
|
||||
* or a file manager's breadcrumb bar) rather than replacing the last one outright - that's what
|
||||
* lets the breadcrumb bar show "Home > Apps > Settings" and jump straight back to any of them,
|
||||
* and lets [back] undo exactly one step instead of always dumping you back at Home. An open app
|
||||
* can claim a back step first (a browser going back a page) through [backInterceptor].
|
||||
*/
|
||||
class CarNavigator(startId: String) {
|
||||
|
||||
var current: CarScreen by mutableStateOf(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
private val stack = mutableStateListOf(
|
||||
Crumb(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
"Home",
|
||||
),
|
||||
)
|
||||
private set
|
||||
|
||||
/** The whole trail, root first. Size 1 means "at Home" - the breadcrumb bar hides then. */
|
||||
val trail: List<Crumb> get() = stack
|
||||
|
||||
val current: CarScreen get() = stack.last().screen
|
||||
|
||||
var backInterceptor: (() -> Boolean)? = null
|
||||
|
||||
fun home() { current = CarScreen.Home }
|
||||
|
||||
/**
|
||||
* Minimised, the home screen's player folds into the nav bar and the map takes the width.
|
||||
* Opening the media screen from the bar restores it, so leaving that screen lands on the
|
||||
* home screen with the player back in place.
|
||||
*/
|
||||
var playerMinimised by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Set by the player's Lyrics button; the media screen reads it once when it opens. */
|
||||
var lyricsRequested = false
|
||||
|
||||
fun minimisePlayer() { playerMinimised = true }
|
||||
|
||||
fun restorePlayer() { playerMinimised = false }
|
||||
|
||||
fun media(lyrics: Boolean = false) {
|
||||
lyricsRequested = lyrics
|
||||
playerMinimised = false
|
||||
current = CarScreen.Media
|
||||
/** 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) {
|
||||
if (stack.last().screen == screen) return
|
||||
stack.add(Crumb(screen, label))
|
||||
}
|
||||
|
||||
fun apps() { current = CarScreen.Apps }
|
||||
/** 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"))
|
||||
}
|
||||
|
||||
fun settings() { current = CarScreen.Settings }
|
||||
/**
|
||||
* The nav bar's now-playing pill is the player everywhere; this is only whether the home
|
||||
* screen is additionally showing lyrics for it in the column beside the map. Toggled by the
|
||||
* pill's own lyrics button, so it means the same thing wherever the pill is visible, even
|
||||
* though only the home screen actually has the room to show it.
|
||||
*/
|
||||
var lyricsOpen by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun dashboard() { current = CarScreen.Dashboard }
|
||||
fun toggleLyrics() { lyricsOpen = !lyricsOpen }
|
||||
|
||||
fun weather() { current = CarScreen.Weather }
|
||||
fun media() { push(CarScreen.Media, "Media") }
|
||||
|
||||
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
|
||||
fun apps() { push(CarScreen.Apps, "Apps") }
|
||||
|
||||
fun open(id: String) { current = CarScreen.App(id) }
|
||||
fun settings() { push(CarScreen.Settings, "Settings") }
|
||||
|
||||
fun dashboard() { push(CarScreen.Dashboard, "Dashboard") }
|
||||
|
||||
fun weather() { push(CarScreen.Weather, "Weather") }
|
||||
|
||||
fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") }
|
||||
|
||||
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
|
||||
|
||||
/** 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. */
|
||||
fun jumpTo(index: Int) {
|
||||
while (stack.size > index + 1) stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
|
||||
/** One step back, same as tapping the crumb before the current one. */
|
||||
fun back() {
|
||||
if (backInterceptor?.invoke() == true) return
|
||||
current = CarScreen.Home
|
||||
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ object CarServices {
|
||||
fun init(appContext: Context) {
|
||||
context = appContext.applicationContext
|
||||
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
||||
app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
|
||||
}
|
||||
|
||||
// --- map -----------------------------------------------------------------------------
|
||||
@@ -54,6 +55,12 @@ object CarServices {
|
||||
nav.disconnect()
|
||||
}
|
||||
|
||||
/** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */
|
||||
suspend fun reconnectNav(frame: CarEnvironment.Frame) {
|
||||
navStopped = false
|
||||
autostart(frame)
|
||||
}
|
||||
|
||||
// --- media ---------------------------------------------------------------------------
|
||||
|
||||
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
||||
@@ -105,6 +112,7 @@ object CarServices {
|
||||
/** The car went away: nothing should keep running for a screen that no longer exists. */
|
||||
fun shutdown() {
|
||||
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
|
||||
app.seb3thehacker.gearslip.call.CarCalls.stop()
|
||||
runCatching { nav.disconnect() }
|
||||
runCatching { media.disconnect() }
|
||||
CarAudio.release()
|
||||
|
||||
@@ -24,6 +24,11 @@ object CarSettings {
|
||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||
private const val KEY_THEME = "car_app_theme"
|
||||
private const val KEY_PINNED = "car_pinned_apps"
|
||||
|
||||
/** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to
|
||||
* still be on the bar, not off the edge of a long list. */
|
||||
const val MAX_PINNED = 3
|
||||
|
||||
/** Id of the app to open on connect; [HOME] opens the launcher. */
|
||||
const val HOME = "home"
|
||||
@@ -39,6 +44,10 @@ object CarSettings {
|
||||
private val autoplayFlow = MutableStateFlow(false)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
|
||||
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
|
||||
|
||||
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
||||
val lastNav: StateFlow<String?> = lastNavFlow
|
||||
@@ -73,6 +82,8 @@ object CarSettings {
|
||||
nightFlow.value = runCatching {
|
||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||
}.getOrDefault(NightMode.AUTO)
|
||||
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
||||
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||
}
|
||||
|
||||
fun setScale(value: Float) {
|
||||
@@ -115,4 +126,17 @@ object CarSettings {
|
||||
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
||||
CarEnvironment.refresh()
|
||||
}
|
||||
|
||||
/** Long-pressed onto or off the nav bar. Silently does nothing past [MAX_PINNED] rather than
|
||||
* bumping the oldest pin - a driver who filled the bar on purpose shouldn't lose one by accident. */
|
||||
fun togglePin(component: String) {
|
||||
val current = pinnedFlow.value
|
||||
val next = when {
|
||||
component in current -> current - component
|
||||
current.size >= MAX_PINNED -> return
|
||||
else -> current + component
|
||||
}
|
||||
pinnedFlow.value = next
|
||||
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -19,9 +20,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.background
|
||||
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.Notifications
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -44,6 +46,7 @@ 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
|
||||
@@ -55,8 +58,12 @@ 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.host.CarAppConnection
|
||||
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
|
||||
|
||||
@@ -129,11 +136,12 @@ fun CarUi() {
|
||||
CarApps.find("calibrate")?.content?.invoke()
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize().padding(pad)) {
|
||||
BreadcrumbBar(navigator)
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> CarHome()
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
@@ -144,6 +152,8 @@ fun CarUi() {
|
||||
if (screen !is CarScreen.Notifications) {
|
||||
NotificationPopup(Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
// Above the popup: a call is more urgent than any notification.
|
||||
CallOverlay(Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
CarNavBar(navigator)
|
||||
}
|
||||
@@ -154,68 +164,166 @@ fun CarUi() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Home and Apps on the left; now-playing pill in the middle; 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.
|
||||
* "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,
|
||||
) {
|
||||
IconButton(onClick = navigator::back, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", Modifier.size(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 darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||
val now by rememberNow()
|
||||
|
||||
// The home screen shows what's playing itself, unless it was minimised; everywhere else it lives in this bar.
|
||||
val showNowPlaying = navigator.current != CarScreen.Home || navigator.playerMinimised
|
||||
val weather by Weather.state.collectAsState()
|
||||
val weatherData = (weather as? WeatherState.Ready)?.data
|
||||
|
||||
// The one app that's actually open and not otherwise reachable from this bar: media has its
|
||||
// own pill already, so the only "open app" icon worth showing is whichever map is connected.
|
||||
val navStatus by CarServices.nav.status.collectAsState()
|
||||
val lastNav by CarSettings.lastNav.collectAsState()
|
||||
val openMapComponent = lastNav?.takeIf { navStatus.phase == CarAppConnection.Phase.RUNNING }
|
||||
|
||||
val context = LocalContext.current
|
||||
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||
val entries by produceState(LauncherCache.entries ?: emptyList(), context) {
|
||||
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||
}
|
||||
val pinnedEntries = remember(entries, pinnedIds) {
|
||||
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
}
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
|
||||
// One row, so nothing can sit on top of anything else: the player takes whatever width the
|
||||
// fixed items leave and shortens its title to fit.
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).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.
|
||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
|
||||
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
|
||||
if (showNowPlaying) NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
||||
}
|
||||
|
||||
(weather as? WeatherState.Ready)?.data?.let { w ->
|
||||
NavChip(navigator.current == CarScreen.Weather, { navigator.weather() }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
WeatherIcon(w.code, w.isDay, size = 28.dp)
|
||||
Text("${w.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
openMapComponent?.let { component ->
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavAppIcon(component, contentDescription = "Map", onClick = navigator::home)
|
||||
}
|
||||
pinnedEntries.forEach { entry ->
|
||||
Spacer(Modifier.width(6.dp))
|
||||
entry.componentId?.let { id ->
|
||||
NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else ""
|
||||
val light = if (darkOutside) "Night" else "Day"
|
||||
Text(
|
||||
listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
|
||||
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
||||
}
|
||||
|
||||
// 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.
|
||||
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
NavItem(
|
||||
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, padding = 8.dp,
|
||||
) { navigator.notifications() }
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
NavItem(
|
||||
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
|
||||
) { navigator.notifications() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||
@Composable
|
||||
private fun NavAppIcon(component: String, contentDescription: String?, onClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val pkg = remember(component) { android.content.ComponentName.unflattenFromString(component)?.packageName }
|
||||
val icon by produceState<androidx.compose.ui.graphics.ImageBitmap?>(null, pkg) {
|
||||
value = pkg?.let {
|
||||
runCatching { context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap() }.getOrNull()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon?.let { Image(it, contentDescription, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 () -> Unit) {
|
||||
@@ -231,14 +339,14 @@ private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, padding: androidx.compose.ui.unit.Dp = 12.dp, onClick: () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
|
||||
@@ -254,7 +362,11 @@ internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis(
|
||||
}
|
||||
}
|
||||
|
||||
/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
|
||||
/**
|
||||
* The one persistent player, everywhere - not just a Home-screen extra. Art and title open the
|
||||
* full media screen; skip and play/pause work right here; the last button toggles the lyrics
|
||||
* panel beside the map on Home (see [CarHome]), the pill's own lyrics affordance wherever it sits.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val media = CarServices.media
|
||||
@@ -302,9 +414,65 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
|
||||
Modifier.size(26.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(28.dp))
|
||||
FilledIconButton(
|
||||
onClick = navigator::toggleLyrics,
|
||||
modifier = Modifier.size(44.dp),
|
||||
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
) {
|
||||
LyricsGlyph(Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A card with two lines of text on it - read as "lyrics"/"captions" at a glance, which no icon in
|
||||
* the core Material set does (the closest, a bullet list, reads as a menu, not song text). Drawn
|
||||
* rather than a vector asset since the whole glyph is two shapes.
|
||||
*/
|
||||
@Composable
|
||||
private fun LyricsGlyph(modifier: Modifier = Modifier) {
|
||||
val tint = androidx.compose.material3.LocalContentColor.current
|
||||
androidx.compose.foundation.Canvas(modifier) {
|
||||
val stroke = size.minDimension * 0.11f
|
||||
drawRoundRect(
|
||||
color = tint,
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.22f),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
|
||||
)
|
||||
val lineStyle = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke * 0.85f, cap = androidx.compose.ui.graphics.StrokeCap.Round)
|
||||
val startX = size.width * 0.24f
|
||||
listOf(0.40f, 0.64f).forEach { fy ->
|
||||
val endX = size.width * (if (fy < 0.5f) 0.76f else 0.62f)
|
||||
drawLine(tint, androidx.compose.ui.geometry.Offset(startX, size.height * fy), androidx.compose.ui.geometry.Offset(endX, size.height * fy), strokeWidth = lineStyle.width, cap = lineStyle.cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the same way [LyricsGlyph] is.
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -77,10 +77,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
val browse by media.browse.collectAsStateWithLifecycle()
|
||||
val rejection by media.rejection.collectAsStateWithLifecycle()
|
||||
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
||||
val navigator = LocalCarNavigator.current
|
||||
var tab by remember {
|
||||
mutableStateOf(if (navigator.lyricsRequested) Tab.LYRICS else Tab.BROWSE).also { navigator.lyricsRequested = false }
|
||||
}
|
||||
var tab by remember { mutableStateOf(Tab.BROWSE) }
|
||||
|
||||
// The connection outlives this screen: the home screen's player is the same one.
|
||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||
@@ -145,7 +142,7 @@ private enum class Tab { BROWSE, LYRICS }
|
||||
* the current line in the accent colour; unsynced ones are plain scrolling text.
|
||||
*/
|
||||
@Composable
|
||||
private fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
|
||||
internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
|
||||
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
||||
value = LyricsState.Loading
|
||||
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.graphics.Rect
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -13,6 +16,12 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -21,20 +30,29 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.car.app.model.PlaceListMapTemplate
|
||||
import androidx.car.app.navigation.model.Lane
|
||||
import androidx.car.app.navigation.model.LaneDirection
|
||||
import androidx.car.app.navigation.model.MapTemplate
|
||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutingInfo
|
||||
import androidx.car.app.navigation.model.Step
|
||||
import androidx.car.app.navigation.model.TravelEstimate
|
||||
import app.seb3thehacker.gearslip.host.clockTime
|
||||
import app.seb3thehacker.gearslip.host.color
|
||||
import app.seb3thehacker.gearslip.host.image
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.remainingTime
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
@@ -96,7 +114,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
if (!pan) template.destinationTravelEstimate?.let {
|
||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
|
||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +124,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
if (info.isLoading) {
|
||||
ChromeSurface(modifier) {
|
||||
Text("Finding a route…", Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
|
||||
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -120,89 +138,174 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(
|
||||
distance,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(distance, style = ChromeType.headline)
|
||||
}
|
||||
Text(
|
||||
step.cue.text(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
val road = step.road.text()
|
||||
if (road.isNotEmpty()) {
|
||||
Text(
|
||||
road,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Neither is sent by every app - most say only "turn left" and trust the driver to
|
||||
// find the lane themselves - so both are drawn only when there is something to draw.
|
||||
info.junctionImage?.let { junction ->
|
||||
Spacer(Modifier.height(10.dp))
|
||||
JunctionImage(junction)
|
||||
}
|
||||
LaneGuidance(step, Modifier.padding(top = 10.dp))
|
||||
info.nextStep?.let { next ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"then",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
next.cue.text(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Time and distance left, along the bottom where it stays out of the map's way. */
|
||||
/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
|
||||
@Composable
|
||||
private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
|
||||
val bitmap = icon.image(LocalContext.current) ?: return
|
||||
Image(
|
||||
bitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = modifier.fillMaxWidth().heightIn(max = 120.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
|
||||
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
|
||||
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
|
||||
* everything else uses, never dimmed by transparency.
|
||||
*/
|
||||
@Composable
|
||||
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
|
||||
step.lanesImage?.let {
|
||||
JunctionImage(it, modifier)
|
||||
return
|
||||
}
|
||||
val lanes = step.lanes.orEmpty()
|
||||
if (lanes.isEmpty()) return
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
lanes.forEach { lane -> LaneTile(lane) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneTile(lane: Lane) {
|
||||
val directions = lane.directions.orEmpty()
|
||||
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
|
||||
val recommended = shown?.isRecommended == true
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (recommended) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = if (recommended) "Recommended lane" else "Lane",
|
||||
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
|
||||
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
||||
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
|
||||
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
|
||||
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
|
||||
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
|
||||
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
|
||||
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
|
||||
LaneDirection.SHAPE_SHARP_LEFT -> -135f
|
||||
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. A floating card rather than a
|
||||
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
|
||||
* that happens to hold three numbers. 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
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() && trip.isEmpty()) return
|
||||
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
contentColor = Color.White,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
val detail = listOfNotNull(
|
||||
arrival.takeIf { it.isNotEmpty() },
|
||||
distance.takeIf { it.isNotEmpty() },
|
||||
trip.takeIf { it.isNotEmpty() },
|
||||
).joinToString(" • ")
|
||||
|
||||
// The same solid card the manoeuvre card above it uses - one surface for the whole map
|
||||
// chrome, rather than this one spot styled on its own as a bar over the map.
|
||||
ChromeSurface(modifier, shadowElevation = 8.dp) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = estimate.remainingTimeColor.color(dark, Color.White),
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
// 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.
|
||||
.clickable { CarServices.nav.stopNavigating() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = "Stop navigating",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(
|
||||
distance,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = estimate.remainingDistanceColor.color(dark, Color.White),
|
||||
)
|
||||
}
|
||||
if (trip.isNotEmpty()) {
|
||||
Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = ChromeType.headline,
|
||||
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
}
|
||||
if (detail.isNotEmpty()) {
|
||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,7 +361,10 @@ private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Mod
|
||||
template.mapController?.mapActionStrip,
|
||||
fullHeight = template.contentTemplate.needsFullHeight(),
|
||||
) {
|
||||
ContentTemplate(template.contentTemplate, Modifier)
|
||||
// A pane floating over the map is a place or a route the app is proposing, so its one
|
||||
// action - Start - counts itself down rather than waiting to be pressed; the same pane
|
||||
// filling a whole screen elsewhere gets no such push.
|
||||
ContentTemplate(template.contentTemplate, Modifier, autoStartPane = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +382,7 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
|
||||
template.header?.startHeaderAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
)
|
||||
if (template.pane != null) PaneRows(template.pane)
|
||||
if (template.pane != null) PaneRows(template.pane, autoStart = true)
|
||||
else ItemListColumn(template.itemList)
|
||||
}
|
||||
}
|
||||
@@ -322,13 +428,13 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
|
||||
)
|
||||
Box(Modifier.weight(1f, fill = false)) {
|
||||
if (template.isLoading) {
|
||||
Text("Finding routes…", Modifier.padding(14.dp))
|
||||
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||
} else {
|
||||
ItemListColumn(template.itemList)
|
||||
}
|
||||
}
|
||||
template.navigateAction?.let {
|
||||
ActionRow(listOf(it), Modifier.padding(12.dp))
|
||||
AutoStartButton(it, Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,7 +452,7 @@ 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))
|
||||
if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||
else ItemListColumn(itemList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ 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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.car.app.model.GridTemplate
|
||||
@@ -51,6 +52,7 @@ import androidx.car.app.media.model.MediaPlaybackTemplate
|
||||
import androidx.car.app.OnDoneCallback
|
||||
import androidx.car.app.serialization.Bundleable
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.host.isDrawable
|
||||
import app.seb3thehacker.gearslip.host.inputChanged
|
||||
import app.seb3thehacker.gearslip.host.inputSubmitted
|
||||
import app.seb3thehacker.gearslip.host.submitted
|
||||
@@ -65,12 +67,12 @@ import app.seb3thehacker.gearslip.host.textChanged
|
||||
* the same function either way - the only difference is how much room they get.
|
||||
*/
|
||||
@Composable
|
||||
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier) {
|
||||
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStartPane: Boolean = false) {
|
||||
when (template) {
|
||||
null -> Unit
|
||||
is ListTemplate -> ListContent(template, modifier)
|
||||
is GridTemplate -> GridContent(template, modifier)
|
||||
is PaneTemplate -> PaneContent(template, modifier)
|
||||
is PaneTemplate -> PaneContent(template, modifier, autoStartPane)
|
||||
is MessageTemplate -> MessageContent(template, modifier)
|
||||
is LongMessageTemplate -> LongMessageContent(template, modifier)
|
||||
is SearchTemplate -> SearchContent(template, modifier)
|
||||
@@ -126,7 +128,7 @@ private fun GridContent(template: GridTemplate, modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
||||
private fun PaneContent(template: PaneTemplate, modifier: Modifier, autoStart: Boolean = false) {
|
||||
ContentSurface(modifier) {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
@@ -134,7 +136,7 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
template.actionStrip,
|
||||
)
|
||||
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane)
|
||||
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane, autoStart = autoStart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,27 +145,45 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
||||
@Composable
|
||||
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
|
||||
ContentSurface(modifier) {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
template.header?.startHeaderAction ?: template.headerAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
template.actionStrip,
|
||||
)
|
||||
// The header carries only the back arrow, not a title - a message template's own title
|
||||
// is usually the long-form question ("Would you like to download the map...") that
|
||||
// reads fine centred in the body but doesn't fit a one-line header without truncating.
|
||||
val backAction = template.header?.startHeaderAction ?: template.headerAction
|
||||
HeaderBar("", backAction, template.header?.endHeaderActions.orEmpty(), template.actionStrip)
|
||||
if (template.isLoading) {
|
||||
Loading()
|
||||
return@ContentSurface
|
||||
}
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// A back arrow already undoes this screen, so an app's own "Cancel" button would just
|
||||
// be a second way to do the one thing the header already offers.
|
||||
val actions = template.actions.orEmpty().filter { it.isDrawable() }.let { list ->
|
||||
if (backAction != null) list.filterNot { it.title.text().equals("Cancel", ignoreCase = true) } else list
|
||||
}
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxWidth().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
template.icon?.let {
|
||||
CarGlyph(it, Modifier.size(32.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
CarGlyph(it, Modifier.size(40.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
Text(template.title.text(), style = ChromeType.title, textAlign = TextAlign.Center)
|
||||
val detail = template.message.text()
|
||||
if (detail.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
detail,
|
||||
style = ChromeType.body,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (actions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ActionRow(actions, Modifier.fillMaxWidth(0.6f), large = true)
|
||||
}
|
||||
Text(template.message.text(), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +201,7 @@ private fun LongMessageContent(template: LongMessageTemplate, modifier: Modifier
|
||||
)
|
||||
Text(
|
||||
template.message.text(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
@@ -245,7 +265,7 @@ private fun SearchField(query: String, hint: String, modifier: Modifier = Modifi
|
||||
) {
|
||||
Text(
|
||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = ChromeType.body,
|
||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -279,7 +299,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
||||
if (instructions.isNotEmpty()) {
|
||||
Text(
|
||||
instructions,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = ChromeType.body,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
@@ -298,7 +318,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
||||
if (additional.isNotEmpty()) {
|
||||
Text(
|
||||
additional,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
@@ -319,7 +339,7 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
|
||||
if (error.isNotEmpty()) {
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
@@ -343,8 +363,7 @@ private fun Callout(value: String, modifier: Modifier) {
|
||||
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = ChromeType.headline,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
@@ -385,7 +404,7 @@ private fun TabContent(template: TabTemplate, modifier: Modifier) {
|
||||
CarGlyph(it, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(tab.title.text(), style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
||||
Text(tab.title.text(), style = ChromeType.label, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,7 +469,7 @@ private fun SectionRows(section: Section<*>) {
|
||||
|
||||
if (items.isEmpty()) {
|
||||
val message = section.noItemsMessage.text()
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||
return
|
||||
}
|
||||
items.filterIsInstance<CarRow>().forEach { RowItem(it) }
|
||||
@@ -474,7 +493,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
@@ -496,6 +515,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 = MaterialTheme.typography.bodyLarge)
|
||||
Text("Loading…", style = ChromeType.title)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.foundation.Image
|
||||
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
|
||||
@@ -82,19 +84,21 @@ import app.seb3thehacker.gearslip.host.text
|
||||
/**
|
||||
* The app keeps drawing its map across the whole surface - the visible area we report tells it
|
||||
* where to put its own markers, not where to stop painting. So anything the host lays over the
|
||||
* map is opaque by default, or the map reads through it.
|
||||
* map is opaque, always, in the same one colour: a card that lets the map show through in places
|
||||
* reads as broken glass, not as a deliberate choice, and a driver has no way to tell which was
|
||||
* meant. There is no alpha knob here on purpose - every card, bar and strip in the template
|
||||
* chrome shares this surface, so nothing drawn over a map is ever see-through.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ChromeSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = RoundedCornerShape(16.dp),
|
||||
alpha: Float = 1f,
|
||||
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shadowElevation = shadowElevation,
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = shape,
|
||||
modifier = modifier,
|
||||
@@ -102,6 +106,32 @@ internal fun ChromeSurface(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one type scale for everything a templated app's screen draws. Apps hand the host plain
|
||||
* strings with no size of their own - the Car App Library deliberately keeps that decision out of
|
||||
* their hands - so without a shared scale, each template's text ends up whatever size felt right
|
||||
* when that template was written, and two apps' otherwise-identical rows read as different sizes
|
||||
* of importance. Every car chrome file should reach for one of these four rather than naming a
|
||||
* Material size directly.
|
||||
*/
|
||||
internal object ChromeType {
|
||||
/** The one number or word a card exists to show: distance to a turn, minutes left, a PIN. */
|
||||
val headline: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)
|
||||
|
||||
/** A row's name, a header, a manoeuvre's instruction - the line the rest is supporting. */
|
||||
val title: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
|
||||
/** Supporting detail: a road, an address, a message's body, a second line under a title. */
|
||||
val body: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.titleMedium
|
||||
|
||||
/** The smallest text Gearslip draws over a map - still large enough to read while moving. */
|
||||
val label: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.bodyLarge
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
|
||||
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
|
||||
@@ -109,7 +139,7 @@ internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier
|
||||
Text(
|
||||
"${template.javaClass.simpleName} isn't drawn yet",
|
||||
Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = ChromeType.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -142,8 +172,7 @@ internal fun HeaderBar(
|
||||
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = ChromeType.title,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -285,12 +314,7 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(title, style = ChromeType.title, maxLines = 1)
|
||||
}
|
||||
} else if (title.isEmpty()) {
|
||||
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
|
||||
@@ -304,12 +328,82 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
|
||||
if (hasGlyph) Spacer(Modifier.width(8.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
|
||||
Text(title, style = ChromeType.body, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A large action that fires on its own after five seconds, filling to show the countdown - for
|
||||
* the one button on a map-preview pane, where the app is proposing a single place or route and a
|
||||
* driver glancing at the car screen has already decided. Tapping it, same as any other button,
|
||||
* fires it at once; the fill is only ever a preview of what a touch already does.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
|
||||
var fraction by remember(action.identity()) { mutableStateOf(0f) }
|
||||
var fired by remember(action.identity()) { mutableStateOf(false) }
|
||||
fun fire() {
|
||||
if (fired) return
|
||||
fired = true
|
||||
action.onClickDelegate.click("auto-start")
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(action.identity()) {
|
||||
val steps = 60
|
||||
repeat(steps) { step ->
|
||||
kotlinx.coroutines.delay(AUTO_START_MS / steps)
|
||||
fraction = (step + 1) / steps.toFloat()
|
||||
}
|
||||
fire()
|
||||
}
|
||||
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||
val background = action.backgroundColor.color(dark, MaterialTheme.colorScheme.primary)
|
||||
val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary
|
||||
val hasGlyph = action.icon != null
|
||||
|
||||
Surface(
|
||||
// An unfilled track, solid, not the button's own colour dimmed with alpha - so the map
|
||||
// behind never shows through however much of the five seconds is left.
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
modifier = modifier.fillMaxWidth().clickable(enabled = action.isEnabled) { fire() },
|
||||
) {
|
||||
androidx.compose.foundation.layout.Box {
|
||||
// The countdown itself: a plain fill sweeping left to right underneath the label.
|
||||
// matchParentSize(), not fillMaxHeight() - this Box sits beside a label Box that
|
||||
// wraps its own content, and the outer Box sizes itself to its tallest child; a
|
||||
// plain fillMaxHeight() here has nothing bounded to fill up to but the whole screen,
|
||||
// which is what was stretching the whole button that tall. matchParentSize() instead
|
||||
// takes whatever size the label ends up being, without itself voting on what that is.
|
||||
androidx.compose.foundation.layout.Box(Modifier.matchParentSize()) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.fillMaxHeight()
|
||||
.background(background),
|
||||
)
|
||||
}
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
||||
Text(
|
||||
action.title.text().ifEmpty { "Start" },
|
||||
style = ChromeType.title,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -376,8 +470,7 @@ internal fun PaneTitle(title: String) {
|
||||
if (title.isEmpty()) return
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = ChromeType.title,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -394,7 +487,7 @@ internal fun ItemListColumn(
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
val message = single?.noItemsMessage.text()
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
|
||||
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.
|
||||
@@ -430,7 +523,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
item.title.text(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
style = ChromeType.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -438,7 +531,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -449,17 +542,24 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [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) {
|
||||
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() }
|
||||
Column(modifier) {
|
||||
rows.forEach { RowItem(it, large = true) }
|
||||
ActionRow(
|
||||
pane?.actions.orEmpty(),
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
large = true,
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,18 +596,20 @@ internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolea
|
||||
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 = if (large) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (large) FontWeight.SemiBold else null,
|
||||
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 = if (large) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.car.app.suggestion.ISuggestionHost
|
||||
import androidx.car.app.hardware.ICarHardwareHost
|
||||
import androidx.car.app.hardware.ICarHardwareResult
|
||||
import androidx.car.app.navigation.INavigationHost
|
||||
import androidx.car.app.navigation.INavigationManager
|
||||
import androidx.car.app.serialization.Bundleable
|
||||
import androidx.car.app.versioning.CarAppApiLevels
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
@@ -81,6 +82,8 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
private var carApp: ICarApp? = null
|
||||
private var appManager: IAppManager? = null
|
||||
/** Only present for an app that asked for it - most apps aren't navigation apps. */
|
||||
private var navigationManager: INavigationManager? = null
|
||||
/** Main thread only, both of these - the handoff is a race between them either way round. */
|
||||
private var surfaceCallback: ISurfaceCallback? = null
|
||||
private var lent: Lent? = null
|
||||
@@ -189,6 +192,7 @@ class CarAppConnection(private val context: Context) {
|
||||
binding = null
|
||||
this.carApp = null
|
||||
appManager = null
|
||||
navigationManager = null
|
||||
_panMode.value = false
|
||||
surfaceCallback = null
|
||||
component = null
|
||||
@@ -269,6 +273,37 @@ class CarAppConnection(private val context: Context) {
|
||||
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
|
||||
requestTemplate()
|
||||
}) { app.getManager(CarContext.APP_SERVICE, it) }
|
||||
|
||||
// Best-effort and silent: an app that never asked for NavigationManager (not every
|
||||
// templated app navigates) simply has no binder to hand back, which is normal, not a
|
||||
// failure worth surfacing.
|
||||
runCatching {
|
||||
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
|
||||
override fun onSuccess(response: Bundleable?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
navigationManager = when (value) {
|
||||
is INavigationManager -> value
|
||||
is IBinder -> INavigationManager.Stub.asInterface(value)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
override fun onFailure(response: Bundleable?) = Unit
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the app itself to stop navigating - the proper end of a route, not just leaving its
|
||||
* screen. Ending only the on-screen guidance (a back press) leaves the app's own
|
||||
* "am I navigating" flag set, which is what stopped a fresh route from starting afterwards;
|
||||
* this is the call [NavigationManagerCallback.onStopNavigation] on the app's side answers,
|
||||
* and it is expected to clear that flag and present its own post-navigation screen.
|
||||
*/
|
||||
fun stopNavigating() {
|
||||
val manager = navigationManager ?: return
|
||||
GearslipLog.i("host: asking the app to stop navigating")
|
||||
runCatching { manager.onStopNavigation(noop("stop navigation")) }
|
||||
.onFailure { GearslipLog.w("host: could not stop navigation: ${it.message}") }
|
||||
}
|
||||
|
||||
/** The app describes its current screen; this is what the host draws. */
|
||||
|
||||
@@ -91,6 +91,17 @@ fun remainingTime(seconds: Long): String {
|
||||
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
|
||||
}
|
||||
|
||||
/** "1:10 AM" - the clock at the destination, not the phone's own, in case a route crosses a zone. */
|
||||
fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
|
||||
val at = this ?: return ""
|
||||
val shifted = java.util.Date(at.timeSinceEpochMillis + at.zoneOffsetSeconds * 1000L)
|
||||
return runCatching {
|
||||
java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(shifted)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the app something was tapped.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@ package app.seb3thehacker.gearslip.ui
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.HardwareBuffer
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -70,7 +71,9 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var viewSize by remember { mutableStateOf(0 to 0) }
|
||||
var fullscreen by remember { mutableStateOf(false) }
|
||||
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
|
||||
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
|
||||
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val width = frame.width
|
||||
val height = frame.height
|
||||
@@ -149,7 +152,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
if (fullscreen) {
|
||||
Fullscreen(landscape = width > height, onExit = { fullscreen = false }) {
|
||||
Fullscreen(onBack) {
|
||||
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
|
||||
// whole height, whichever the car's shape allows, and the rest stays black.
|
||||
frameContent(Modifier)
|
||||
@@ -174,7 +177,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
|
||||
Row {
|
||||
TextButton(onClick = onBack) { Text("Back") }
|
||||
TextButton(onClick = { fullscreen = true }) { Text("Fullscreen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,34 +186,32 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
* Fills the phone screen with [content], centred and letterboxed.
|
||||
*
|
||||
* A car screen is wider than it is tall, so a portrait phone would waste most of its height on
|
||||
* one. While this is showing, the activity is turned to match and the system bars go away - both
|
||||
* are undone on the way out, so the rest of the phone UI is unaffected.
|
||||
* one. This only shows once the phone is already turned sideways (the caller drives it off the
|
||||
* phone's own orientation), so there is no rotation left to force here - just the system bars to
|
||||
* hide while it's up and restore on the way out.
|
||||
*/
|
||||
@Composable
|
||||
private fun Fullscreen(
|
||||
landscape: Boolean,
|
||||
onExit: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val activity = LocalContext.current.activity()
|
||||
|
||||
DisposableEffect(activity, landscape) {
|
||||
DisposableEffect(activity) {
|
||||
if (activity == null) return@DisposableEffect onDispose { }
|
||||
|
||||
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
val previousOrientation = activity.requestedOrientation
|
||||
if (landscape) activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
|
||||
onDispose {
|
||||
insets.show(WindowInsetsCompat.Type.systemBars())
|
||||
activity.requestedOrientation = previousOrientation
|
||||
}
|
||||
onDispose { insets.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
|
||||
BackHandler(onBack = onExit)
|
||||
// Turning the phone back upright is what normally leaves fullscreen; back still leaves the
|
||||
// preview screen entirely rather than doing nothing, in case the phone's own rotation lock
|
||||
// is on and turning it sideways/back isn't an option.
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black),
|
||||
@@ -222,10 +222,10 @@ private fun Fullscreen(
|
||||
// The frame swallows every touch inside it, so leaving needs either the back gesture or
|
||||
// a button of its own. This one sits in the corner the car UI leaves emptiest.
|
||||
FilledTonalIconButton(
|
||||
onClick = onExit,
|
||||
onClick = onBack,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Leave fullscreen")
|
||||
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW }
|
||||
|
||||
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||
@Composable
|
||||
fun GearslipApp(onDisconnect: () -> Unit) {
|
||||
fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
|
||||
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
|
||||
|
||||
@@ -26,6 +26,7 @@ fun GearslipApp(onDisconnect: () -> Unit) {
|
||||
onOpenSettings = { screen = Screen.SETTINGS },
|
||||
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
||||
onDisconnect = onDisconnect,
|
||||
onRequestCallScreening = onRequestCallScreening,
|
||||
)
|
||||
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
|
||||
|
||||
@@ -47,6 +47,7 @@ fun HomeScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenCarPreview: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onRequestCallScreening: () -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
@@ -79,7 +80,7 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SetupSteps()
|
||||
SetupSteps(onRequestCallScreening)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -184,12 +185,19 @@ private fun Context.appVersionName(): String =
|
||||
/**
|
||||
* One step of the setup list: a sentence that says what to do, and optionally how.
|
||||
*
|
||||
* [command] is for the one step that cannot be done on the phone. It is shown as something to
|
||||
* copy rather than to read, because a mistyped package name fails silently.
|
||||
* [command] is for a step that cannot be done on the phone; it is shown as something to copy
|
||||
* rather than to read, because a mistyped package name fails silently. [onClick] is for a step
|
||||
* 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, val command: String? = null)
|
||||
private class Step(
|
||||
val action: String,
|
||||
val detail: String? = null,
|
||||
val command: String? = null,
|
||||
val buttonLabel: String? = null,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
private val SETUP_STEPS = listOf(
|
||||
private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
|
||||
Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
|
||||
Step(
|
||||
"On GrapheneOS, exempt Gearslip from exploit protections.",
|
||||
@@ -216,6 +224,14 @@ private val SETUP_STEPS = listOf(
|
||||
"music is going to the car, then puts it back.",
|
||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
||||
),
|
||||
Step(
|
||||
"Optional: let the car screen answer and decline calls.",
|
||||
"This sets Gearslip as your phone's caller ID and spam-blocking app, the same kind of " +
|
||||
"consent a call-blocking app asks for. Without it, calls still ring, but only the " +
|
||||
"phone itself can answer or decline them.",
|
||||
buttonLabel = "Set up",
|
||||
onClick = onRequestCallScreening,
|
||||
),
|
||||
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
|
||||
)
|
||||
|
||||
@@ -254,7 +270,8 @@ private fun CopyableCommand(command: String) {
|
||||
|
||||
/** What to do before the first drive, in order. The same width as the status card above it. */
|
||||
@Composable
|
||||
private fun SetupSteps() {
|
||||
private fun SetupSteps(onRequestCallScreening: () -> Unit) {
|
||||
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
@@ -268,7 +285,7 @@ private fun SetupSteps() {
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
SETUP_STEPS.forEachIndexed { index, step ->
|
||||
steps.forEachIndexed { index, step ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
"${index + 1}",
|
||||
@@ -287,6 +304,11 @@ private fun SetupSteps() {
|
||||
)
|
||||
}
|
||||
step.command?.let { CopyableCommand(it) }
|
||||
if (step.onClick != null) {
|
||||
FilledTonalButton(onClick = step.onClick, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(step.buttonLabel ?: "Open")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,5 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "Gearslip"
|
||||
include(":gearslip")
|
||||
include(":ksdump")
|
||||
|
||||
Reference in new issue
Block a user