mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +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
+1232
-352
No files matched your search
@@ -23,3 +23,4 @@ keystore.properties
|
|||||||
archive/
|
archive/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
*.pdf
|
*.pdf
|
||||||
|
extraction/
|
||||||
@@ -30,10 +30,10 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "app.seb3thehacker.gearslip"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 35
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "0.0.03-pre"
|
versionName = "0.0.04-pre"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -39,6 +39,18 @@
|
|||||||
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||||
tools:ignore="ProtectedPermissions" />
|
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
|
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
|
whether it holds this permission (HostValidator.isValidHost checks it before falling back
|
||||||
@@ -158,5 +170,21 @@
|
|||||||
<action android:name="android.service.notification.NotificationListenerService" />
|
<action android:name="android.service.notification.NotificationListenerService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -104,6 +104,19 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
) { 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
|
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
|
||||||
@@ -120,6 +133,15 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) !=
|
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) !=
|
||||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
) audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
) 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()
|
enableEdgeToEdge()
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
@@ -137,7 +159,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
GearslipTheme {
|
GearslipTheme {
|
||||||
GearslipApp(onDisconnect = ::closeAccessory)
|
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +203,19 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
GearslipLog.flush()
|
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 --------------------------------------------------------------------
|
// --- Media audio --------------------------------------------------------------------
|
||||||
|
|
||||||
private fun requestAudioCapture() {
|
private fun requestAudioCapture() {
|
||||||
|
|||||||
@@ -181,16 +181,22 @@ class GearslipRunner(
|
|||||||
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||||
SessionReport.protocolVersion(major, minor)
|
SessionReport.protocolVersion(major, minor)
|
||||||
|
|
||||||
// Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than
|
// PROTOCOL-DOWNGRADE EXPERIMENT. The 2018 Uconnect speaks 1.3 and ACCEPTS the JVC cert;
|
||||||
// asserting 1.6 is the permissive choice: we only need to reach service discovery,
|
// the 2025 unit speaks 4.3 and rejects it at AuthComplete (-3). If the 4.3 identity check
|
||||||
// not to honour whatever that version implies.
|
// 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(
|
val response = byteArrayOf(
|
||||||
((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(),
|
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
|
||||||
((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(),
|
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
|
||||||
0, 0, // STATUS_SUCCESS
|
0, 0, // STATUS_SUCCESS
|
||||||
)
|
)
|
||||||
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
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
|
state = State.TLS_HANDSHAKE
|
||||||
SessionStatus.connecting("Securing the link")
|
SessionStatus.connecting("Securing the link")
|
||||||
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
||||||
@@ -787,6 +793,13 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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
|
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||||
const val MSG_VERSION_REQUEST = 1
|
const val MSG_VERSION_REQUEST = 1
|
||||||
const val MSG_VERSION_RESPONSE = 2
|
const val MSG_VERSION_RESPONSE = 2
|
||||||
|
|||||||
@@ -0,0 +1,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
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.FilledIconButton
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -27,32 +22,26 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.ui.draw.clip
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
import app.seb3thehacker.gearslip.media.CarMedia
|
import app.seb3thehacker.gearslip.media.CarMedia
|
||||||
import app.seb3thehacker.gearslip.media.MediaArt
|
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||||
import kotlinx.coroutines.delay
|
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
|
* The map is whatever navigation app was used last, brought back on its own. Playback itself is
|
||||||
* takes its share of the screen only while something is playing or paused; otherwise the map has all of it.
|
* 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
|
@Composable
|
||||||
fun CarHome() {
|
fun CarHome() {
|
||||||
@@ -65,21 +54,19 @@ fun CarHome() {
|
|||||||
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
|
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
|
||||||
|
|
||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
val showPlayer = phase == CarMedia.Phase.READY && now.isActive && !navigator.playerMinimised
|
val showLyrics = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen
|
||||||
// Once playback ends there is nothing left to minimise; the next song starts in place.
|
|
||||||
LaunchedEffect(now.isActive) { if (!now.isActive) navigator.restorePlayer() }
|
|
||||||
|
|
||||||
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(if (showPlayer) 2.1f else 1f)
|
.weight(if (showLyrics) 2.1f else 1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
|
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
|
||||||
) {
|
) {
|
||||||
MapPane(navStatus, frame)
|
MapPane(navStatus, frame)
|
||||||
}
|
}
|
||||||
if (showPlayer) {
|
if (showLyrics) {
|
||||||
MiniPlayer(Modifier.weight(1f).fillMaxHeight())
|
LyricsColumn(now, Modifier.weight(1f).fillMaxHeight())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +85,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
val hasLast = CarSettings.lastNav.value != null
|
val hasLast = CarSettings.lastNav.value != null
|
||||||
|
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
when (status.phase) {
|
when (status.phase) {
|
||||||
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
|
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
|
||||||
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
|
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") }
|
TextButton(onClick = navigator::apps) { Text("Choose another app") }
|
||||||
}
|
}
|
||||||
else -> {
|
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(
|
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,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
if (!hasLast) {
|
if (hasLast) {
|
||||||
|
TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") }
|
||||||
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Open Car apps and connect a navigation app; it will start here next time.",
|
"Open Car apps and connect a navigation app; it will start here next time.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
@Composable
|
||||||
private fun MiniPlayer(modifier: Modifier) {
|
private fun LyricsColumn(now: NowPlaying, 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { navigator.media() },
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = RoundedCornerShape(EMBEDDED_RADIUS),
|
shape = RoundedCornerShape(EMBEDDED_RADIUS),
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
Modifier.fillMaxSize().padding(18.dp),
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
Text(
|
||||||
verticalArrangement = Arrangement.Center,
|
now.title.ifEmpty { "Lyrics" },
|
||||||
) {
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Box(Modifier.size(128.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
|
fontWeight = FontWeight.SemiBold,
|
||||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
maxLines = 1,
|
||||||
}
|
overflow = TextOverflow.Ellipsis,
|
||||||
Spacer(Modifier.height(10.dp))
|
modifier = Modifier.weight(1f),
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
} else {
|
IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
Spacer(Modifier.height(8.dp))
|
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth())
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -60,23 +63,29 @@ private class Tile(
|
|||||||
val glyph: ImageVector? = null,
|
val glyph: ImageVector? = null,
|
||||||
/** Tested from start to finish with Gearslip; drawn with a check mark. */
|
/** Tested from start to finish with Gearslip; drawn with a check mark. */
|
||||||
val verified: Boolean = false,
|
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,
|
val onClick: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
|
/** 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 label: String,
|
||||||
val icon: ImageBitmap?,
|
val icon: ImageBitmap?,
|
||||||
val template: TemplateApp? = null,
|
val template: TemplateApp? = null,
|
||||||
val media: MediaApp? = 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
|
* 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,
|
* 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.
|
* which is when new installs are picked up.
|
||||||
*/
|
*/
|
||||||
private object LauncherCache {
|
internal object LauncherCache {
|
||||||
@Volatile var entries: List<Entry>? = null
|
@Volatile var entries: List<Entry>? = null
|
||||||
|
|
||||||
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
|
/** 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. */
|
/** 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) }
|
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. */
|
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
||||||
@Composable
|
@Composable
|
||||||
fun CarLauncher() {
|
fun CarLauncher() {
|
||||||
@@ -108,15 +127,20 @@ fun CarLauncher() {
|
|||||||
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val pinned by CarSettings.pinnedApps.collectAsState()
|
||||||
|
|
||||||
val tiles = listOf(
|
val tiles = listOf(
|
||||||
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
|
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
|
||||||
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
|
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
|
||||||
) + installed.map { entry ->
|
) + installed.map { entry ->
|
||||||
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
|
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
|
||||||
Tile(entry.label, entry.icon, verified = pkg != null && KnownApps.works(pkg)) {
|
val id = entry.componentId
|
||||||
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
Tile(
|
||||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
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(
|
} + listOf(
|
||||||
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
|
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
|
@Composable
|
||||||
private fun AppTile(tile: Tile) {
|
private fun AppTile(tile: Tile) {
|
||||||
|
val shape = MaterialTheme.shapes.extraLarge
|
||||||
Surface(
|
Surface(
|
||||||
onClick = tile.onClick,
|
modifier = Modifier
|
||||||
modifier = Modifier.height(120.dp),
|
.height(120.dp)
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
.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,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
@@ -24,58 +25,81 @@ sealed interface CarScreen {
|
|||||||
data class App(val id: String) : 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
|
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
|
||||||
* the on-screen one; an open app can claim it first (a browser going back a page) through
|
* every screen that isn't reached from [home] is pushed onto a trail (like a browser's history,
|
||||||
* [backInterceptor].
|
* 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) {
|
class CarNavigator(startId: String) {
|
||||||
|
|
||||||
var current: CarScreen by mutableStateOf(
|
private val stack = mutableStateListOf(
|
||||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
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
|
var backInterceptor: (() -> Boolean)? = null
|
||||||
|
|
||||||
fun home() { current = CarScreen.Home }
|
/** 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) {
|
||||||
* Minimised, the home screen's player folds into the nav bar and the map takes the width.
|
if (stack.last().screen == screen) return
|
||||||
* Opening the media screen from the bar restores it, so leaving that screen lands on the
|
stack.add(Crumb(screen, label))
|
||||||
* 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
fun back() {
|
||||||
if (backInterceptor?.invoke() == true) return
|
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) {
|
fun init(appContext: Context) {
|
||||||
context = appContext.applicationContext
|
context = appContext.applicationContext
|
||||||
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
||||||
|
app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- map -----------------------------------------------------------------------------
|
// --- map -----------------------------------------------------------------------------
|
||||||
@@ -54,6 +55,12 @@ object CarServices {
|
|||||||
nav.disconnect()
|
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 ---------------------------------------------------------------------------
|
// --- media ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
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. */
|
/** The car went away: nothing should keep running for a screen that no longer exists. */
|
||||||
fun shutdown() {
|
fun shutdown() {
|
||||||
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
|
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
|
||||||
|
app.seb3thehacker.gearslip.call.CarCalls.stop()
|
||||||
runCatching { nav.disconnect() }
|
runCatching { nav.disconnect() }
|
||||||
runCatching { media.disconnect() }
|
runCatching { media.disconnect() }
|
||||||
CarAudio.release()
|
CarAudio.release()
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ object CarSettings {
|
|||||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||||
private const val KEY_THEME = "car_app_theme"
|
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. */
|
/** Id of the app to open on connect; [HOME] opens the launcher. */
|
||||||
const val HOME = "home"
|
const val HOME = "home"
|
||||||
@@ -39,6 +44,10 @@ object CarSettings {
|
|||||||
private val autoplayFlow = MutableStateFlow(false)
|
private val autoplayFlow = MutableStateFlow(false)
|
||||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||||
|
private val 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. */
|
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
||||||
val lastNav: StateFlow<String?> = lastNavFlow
|
val lastNav: StateFlow<String?> = lastNavFlow
|
||||||
@@ -73,6 +82,8 @@ object CarSettings {
|
|||||||
nightFlow.value = runCatching {
|
nightFlow.value = runCatching {
|
||||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||||
}.getOrDefault(NightMode.AUTO)
|
}.getOrDefault(NightMode.AUTO)
|
||||||
|
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
||||||
|
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setScale(value: Float) {
|
fun setScale(value: Float) {
|
||||||
@@ -115,4 +126,17 @@ object CarSettings {
|
|||||||
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
||||||
CarEnvironment.refresh()
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.material.icons.Icons
|
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.Notifications
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Home
|
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -44,6 +46,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
import app.seb3thehacker.gearslip.notify.CarNotifications
|
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@@ -129,11 +136,12 @@ fun CarUi() {
|
|||||||
CarApps.find("calibrate")?.content?.invoke()
|
CarApps.find("calibrate")?.content?.invoke()
|
||||||
} else {
|
} else {
|
||||||
Column(Modifier.fillMaxSize().padding(pad)) {
|
Column(Modifier.fillMaxSize().padding(pad)) {
|
||||||
|
BreadcrumbBar(navigator)
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
when (screen) {
|
when (screen) {
|
||||||
CarScreen.Home -> CarHome()
|
CarScreen.Home -> CarHome()
|
||||||
CarScreen.Apps -> CarLauncher()
|
CarScreen.Apps -> CarLauncher()
|
||||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
|
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
|
||||||
CarScreen.Settings -> CarSettingsScreen()
|
CarScreen.Settings -> CarSettingsScreen()
|
||||||
CarScreen.Dashboard -> CarDashboardScreen()
|
CarScreen.Dashboard -> CarDashboardScreen()
|
||||||
CarScreen.Weather -> WeatherScreen()
|
CarScreen.Weather -> WeatherScreen()
|
||||||
@@ -144,6 +152,8 @@ fun CarUi() {
|
|||||||
if (screen !is CarScreen.Notifications) {
|
if (screen !is CarScreen.Notifications) {
|
||||||
NotificationPopup(Modifier.align(Alignment.TopCenter))
|
NotificationPopup(Modifier.align(Alignment.TopCenter))
|
||||||
}
|
}
|
||||||
|
// Above the popup: a call is more urgent than any notification.
|
||||||
|
CallOverlay(Modifier.align(Alignment.TopCenter))
|
||||||
}
|
}
|
||||||
CarNavBar(navigator)
|
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
|
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
||||||
* darkness signal on the right. Icon-only: a driver reads a glyph faster than a label, and it
|
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
||||||
* keeps the bar short enough to leave the app underneath more room.
|
* 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
|
@Composable
|
||||||
private fun CarNavBar(navigator: CarNavigator) {
|
private fun CarNavBar(navigator: CarNavigator) {
|
||||||
val battery by CarEnvironment.battery.collectAsState()
|
val battery by CarEnvironment.battery.collectAsState()
|
||||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
|
||||||
val now by rememberNow()
|
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 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) {
|
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||||
Row(
|
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
|
||||||
Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp),
|
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
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
|
||||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
// these two from three screens deep in a media app shouldn't mean reading it
|
||||||
Spacer(Modifier.width(6.dp))
|
// first.
|
||||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
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) {
|
openMapComponent?.let { component ->
|
||||||
if (showNowPlaying) NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
Spacer(Modifier.width(6.dp))
|
||||||
}
|
NavAppIcon(component, contentDescription = "Map", onClick = navigator::home)
|
||||||
|
}
|
||||||
(weather as? WeatherState.Ready)?.data?.let { w ->
|
pinnedEntries.forEach { entry ->
|
||||||
NavChip(navigator.current == CarScreen.Weather, { navigator.weather() }) {
|
Spacer(Modifier.width(6.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
entry.componentId?.let { id ->
|
||||||
WeatherIcon(w.code, w.isDay, size = 28.dp)
|
NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) }
|
||||||
Text("${w.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
}
|
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
|
||||||
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
|
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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]. */
|
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
|
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||||
@@ -231,14 +339,14 @@ private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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
|
val scheme = MaterialTheme.colorScheme
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(12.dp),
|
.padding(padding),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
|
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
|
@Composable
|
||||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
val media = CarServices.media
|
val media = CarServices.media
|
||||||
@@ -302,9 +414,65 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
|
|||||||
Modifier.size(26.dp),
|
Modifier.size(26.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) {
|
FilledIconButton(
|
||||||
Icon(MediaIcons.Next, "Next", Modifier.size(28.dp))
|
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 browse by media.browse.collectAsStateWithLifecycle()
|
||||||
val rejection by media.rejection.collectAsStateWithLifecycle()
|
val rejection by media.rejection.collectAsStateWithLifecycle()
|
||||||
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
||||||
val navigator = LocalCarNavigator.current
|
var tab by remember { mutableStateOf(Tab.BROWSE) }
|
||||||
var tab by remember {
|
|
||||||
mutableStateOf(if (navigator.lyricsRequested) Tab.LYRICS else Tab.BROWSE).also { navigator.lyricsRequested = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The connection outlives this screen: the home screen's player is the same one.
|
// The connection outlives this screen: the home screen's player is the same one.
|
||||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||||
@@ -145,7 +142,7 @@ private enum class Tab { BROWSE, LYRICS }
|
|||||||
* the current line in the accent colour; unsynced ones are plain scrolling text.
|
* the current line in the accent colour; unsynced ones are plain scrolling text.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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) {
|
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
||||||
value = LyricsState.Loading
|
value = LyricsState.Loading
|
||||||
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
|
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import android.graphics.Rect
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.heightIn
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -21,20 +30,29 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
import androidx.car.app.model.Template
|
import androidx.car.app.model.Template
|
||||||
import androidx.car.app.model.PlaceListMapTemplate
|
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.MapTemplate
|
||||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||||
import androidx.car.app.navigation.model.NavigationTemplate
|
import androidx.car.app.navigation.model.NavigationTemplate
|
||||||
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
|
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
|
||||||
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
|
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
|
||||||
import androidx.car.app.navigation.model.RoutingInfo
|
import androidx.car.app.navigation.model.RoutingInfo
|
||||||
|
import androidx.car.app.navigation.model.Step
|
||||||
import androidx.car.app.navigation.model.TravelEstimate
|
import androidx.car.app.navigation.model.TravelEstimate
|
||||||
|
import app.seb3thehacker.gearslip.host.clockTime
|
||||||
import app.seb3thehacker.gearslip.host.color
|
import app.seb3thehacker.gearslip.host.color
|
||||||
|
import app.seb3thehacker.gearslip.host.image
|
||||||
import app.seb3thehacker.gearslip.host.display
|
import app.seb3thehacker.gearslip.host.display
|
||||||
import app.seb3thehacker.gearslip.host.remainingTime
|
import app.seb3thehacker.gearslip.host.remainingTime
|
||||||
import app.seb3thehacker.gearslip.host.text
|
import app.seb3thehacker.gearslip.host.text
|
||||||
@@ -96,7 +114,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
|||||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||||
)
|
)
|
||||||
if (!pan) template.destinationTravelEstimate?.let {
|
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) {
|
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||||
if (info.isLoading) {
|
if (info.isLoading) {
|
||||||
ChromeSurface(modifier) {
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -120,89 +138,174 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
|||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
val distance = info.currentDistance.display()
|
val distance = info.currentDistance.display()
|
||||||
if (distance.isNotEmpty()) {
|
if (distance.isNotEmpty()) {
|
||||||
Text(
|
Text(distance, style = ChromeType.headline)
|
||||||
distance,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Text(
|
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
step.cue.text(),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val road = step.road.text()
|
val road = step.road.text()
|
||||||
if (road.isNotEmpty()) {
|
if (road.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
road,
|
road,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = ChromeType.body,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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 ->
|
info.nextStep?.let { next ->
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
"then",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
next.cue.text(),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
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
|
@Composable
|
||||||
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
||||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
|
||||||
val time = remainingTime(estimate.remainingTimeSeconds)
|
val time = remainingTime(estimate.remainingTimeSeconds)
|
||||||
val distance = estimate.remainingDistance.display()
|
val distance = estimate.remainingDistance.display()
|
||||||
|
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
||||||
val trip = estimate.tripText.text()
|
val trip = estimate.tripText.text()
|
||||||
if (time.isEmpty() && distance.isEmpty() && trip.isEmpty()) return
|
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||||
|
|
||||||
Surface(
|
val detail = listOfNotNull(
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
arrival.takeIf { it.isNotEmpty() },
|
||||||
contentColor = Color.White,
|
distance.takeIf { it.isNotEmpty() },
|
||||||
modifier = modifier.fillMaxWidth(),
|
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(
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
) {
|
) {
|
||||||
if (time.isNotEmpty()) {
|
Box(
|
||||||
Text(
|
Modifier
|
||||||
time,
|
.size(40.dp)
|
||||||
style = MaterialTheme.typography.titleLarge,
|
.clip(CircleShape)
|
||||||
fontWeight = FontWeight.Bold,
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
color = estimate.remainingTimeColor.color(dark, Color.White),
|
// 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()) {
|
Spacer(Modifier.width(10.dp))
|
||||||
Text(
|
Column {
|
||||||
distance,
|
if (time.isNotEmpty()) {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
Text(
|
||||||
color = estimate.remainingDistanceColor.color(dark, Color.White),
|
time,
|
||||||
)
|
style = ChromeType.headline,
|
||||||
}
|
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||||
if (trip.isNotEmpty()) {
|
)
|
||||||
Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
}
|
||||||
|
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,
|
template.mapController?.mapActionStrip,
|
||||||
fullHeight = template.contentTemplate.needsFullHeight(),
|
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?.startHeaderAction,
|
||||||
template.header?.endHeaderActions.orEmpty(),
|
template.header?.endHeaderActions.orEmpty(),
|
||||||
)
|
)
|
||||||
if (template.pane != null) PaneRows(template.pane)
|
if (template.pane != null) PaneRows(template.pane, autoStart = true)
|
||||||
else ItemListColumn(template.itemList)
|
else ItemListColumn(template.itemList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,13 +428,13 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
|
|||||||
)
|
)
|
||||||
Box(Modifier.weight(1f, fill = false)) {
|
Box(Modifier.weight(1f, fill = false)) {
|
||||||
if (template.isLoading) {
|
if (template.isLoading) {
|
||||||
Text("Finding routes…", Modifier.padding(14.dp))
|
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||||
} else {
|
} else {
|
||||||
ItemListColumn(template.itemList)
|
ItemListColumn(template.itemList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
template.navigateAction?.let {
|
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)) {
|
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||||
Column {
|
Column {
|
||||||
HeaderBar(title, startAction)
|
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)
|
else ItemListColumn(itemList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.car.app.model.GridTemplate
|
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.OnDoneCallback
|
||||||
import androidx.car.app.serialization.Bundleable
|
import androidx.car.app.serialization.Bundleable
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.host.isDrawable
|
||||||
import app.seb3thehacker.gearslip.host.inputChanged
|
import app.seb3thehacker.gearslip.host.inputChanged
|
||||||
import app.seb3thehacker.gearslip.host.inputSubmitted
|
import app.seb3thehacker.gearslip.host.inputSubmitted
|
||||||
import app.seb3thehacker.gearslip.host.submitted
|
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.
|
* the same function either way - the only difference is how much room they get.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier) {
|
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStartPane: Boolean = false) {
|
||||||
when (template) {
|
when (template) {
|
||||||
null -> Unit
|
null -> Unit
|
||||||
is ListTemplate -> ListContent(template, modifier)
|
is ListTemplate -> ListContent(template, modifier)
|
||||||
is GridTemplate -> GridContent(template, modifier)
|
is GridTemplate -> GridContent(template, modifier)
|
||||||
is PaneTemplate -> PaneContent(template, modifier)
|
is PaneTemplate -> PaneContent(template, modifier, autoStartPane)
|
||||||
is MessageTemplate -> MessageContent(template, modifier)
|
is MessageTemplate -> MessageContent(template, modifier)
|
||||||
is LongMessageTemplate -> LongMessageContent(template, modifier)
|
is LongMessageTemplate -> LongMessageContent(template, modifier)
|
||||||
is SearchTemplate -> SearchContent(template, modifier)
|
is SearchTemplate -> SearchContent(template, modifier)
|
||||||
@@ -126,7 +128,7 @@ private fun GridContent(template: GridTemplate, modifier: Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
private fun PaneContent(template: PaneTemplate, modifier: Modifier, autoStart: Boolean = false) {
|
||||||
ContentSurface(modifier) {
|
ContentSurface(modifier) {
|
||||||
HeaderBar(
|
HeaderBar(
|
||||||
headerTitle(template.header, template.title.text()),
|
headerTitle(template.header, template.title.text()),
|
||||||
@@ -134,7 +136,7 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
|||||||
template.header?.endHeaderActions.orEmpty(),
|
template.header?.endHeaderActions.orEmpty(),
|
||||||
template.actionStrip,
|
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
|
@Composable
|
||||||
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
|
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
|
||||||
ContentSurface(modifier) {
|
ContentSurface(modifier) {
|
||||||
HeaderBar(
|
// The header carries only the back arrow, not a title - a message template's own title
|
||||||
headerTitle(template.header, template.title.text()),
|
// is usually the long-form question ("Would you like to download the map...") that
|
||||||
template.header?.startHeaderAction ?: template.headerAction,
|
// reads fine centred in the body but doesn't fit a one-line header without truncating.
|
||||||
template.header?.endHeaderActions.orEmpty(),
|
val backAction = template.header?.startHeaderAction ?: template.headerAction
|
||||||
template.actionStrip,
|
HeaderBar("", backAction, template.header?.endHeaderActions.orEmpty(), template.actionStrip)
|
||||||
)
|
|
||||||
if (template.isLoading) {
|
if (template.isLoading) {
|
||||||
Loading()
|
Loading()
|
||||||
return@ContentSurface
|
return@ContentSurface
|
||||||
}
|
}
|
||||||
Row(
|
// A back arrow already undoes this screen, so an app's own "Cancel" button would just
|
||||||
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
// be a second way to do the one thing the header already offers.
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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 {
|
template.icon?.let {
|
||||||
CarGlyph(it, Modifier.size(32.dp))
|
CarGlyph(it, Modifier.size(40.dp))
|
||||||
Spacer(Modifier.width(12.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(
|
Text(
|
||||||
template.message.text(),
|
template.message.text(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = ChromeType.body,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
@@ -245,7 +265,7 @@ private fun SearchField(query: String, hint: String, modifier: Modifier = Modifi
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = ChromeType.body,
|
||||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
|
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -279,7 +299,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
|||||||
if (instructions.isNotEmpty()) {
|
if (instructions.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
instructions,
|
instructions,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = ChromeType.body,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -298,7 +318,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
|||||||
if (additional.isNotEmpty()) {
|
if (additional.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
additional,
|
additional,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = ChromeType.label,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
@@ -319,7 +339,7 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
|
|||||||
if (error.isNotEmpty()) {
|
if (error.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
error,
|
error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = ChromeType.label,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
@@ -343,8 +363,7 @@ private fun Callout(value: String, modifier: Modifier) {
|
|||||||
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = ChromeType.headline,
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
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))
|
CarGlyph(it, Modifier.size(20.dp))
|
||||||
Spacer(Modifier.width(6.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()) {
|
if (items.isEmpty()) {
|
||||||
val message = section.noItemsMessage.text()
|
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
|
return
|
||||||
}
|
}
|
||||||
items.filterIsInstance<CarRow>().forEach { RowItem(it) }
|
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) {
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.",
|
"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,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
)
|
)
|
||||||
@@ -496,6 +515,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
|
|||||||
@Composable
|
@Composable
|
||||||
private fun Loading() {
|
private fun Loading() {
|
||||||
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
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.material3.LocalContentColor
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -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
|
* 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
|
* 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
|
@Composable
|
||||||
internal fun ChromeSurface(
|
internal fun ChromeSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
shape: Shape = RoundedCornerShape(16.dp),
|
shape: Shape = RoundedCornerShape(16.dp),
|
||||||
alpha: Float = 1f,
|
|
||||||
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
|
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
shadowElevation = shadowElevation,
|
shadowElevation = shadowElevation,
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
|
color = MaterialTheme.colorScheme.surface,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
modifier = modifier,
|
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
|
@Composable
|
||||||
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
|
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
|
||||||
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
|
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
|
||||||
@@ -109,7 +139,7 @@ internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier
|
|||||||
Text(
|
Text(
|
||||||
"${template.javaClass.simpleName} isn't drawn yet",
|
"${template.javaClass.simpleName} isn't drawn yet",
|
||||||
Modifier.padding(16.dp),
|
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) }
|
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = ChromeType.title,
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -285,12 +314,7 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
|
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
|
||||||
Text(
|
Text(title, style = ChromeType.title, maxLines = 1)
|
||||||
title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else if (title.isEmpty()) {
|
} else if (title.isEmpty()) {
|
||||||
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
|
// 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) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
|
||||||
if (hasGlyph) Spacer(Modifier.width(8.dp))
|
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. */
|
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppIconBadge() {
|
private fun AppIconBadge() {
|
||||||
@@ -376,8 +470,7 @@ internal fun PaneTitle(title: String) {
|
|||||||
if (title.isEmpty()) return
|
if (title.isEmpty()) return
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = ChromeType.title,
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -394,7 +487,7 @@ internal fun ItemListColumn(
|
|||||||
}
|
}
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
val message = single?.noItemsMessage.text()
|
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
|
return
|
||||||
}
|
}
|
||||||
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
// 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))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
item.title.text(),
|
item.title.text(),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = ChromeType.label,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
@@ -438,7 +531,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
|||||||
if (subtitle.isNotEmpty()) {
|
if (subtitle.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = ChromeType.label,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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
|
@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.
|
// 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 rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||||
|
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
rows.forEach { RowItem(it, large = true) }
|
rows.forEach { RowItem(it, large = true) }
|
||||||
ActionRow(
|
if (autoStart && actions.size == 1) {
|
||||||
pane?.actions.orEmpty(),
|
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||||
Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
} else {
|
||||||
large = true,
|
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))
|
CarGlyph(it, Modifier.size(28.dp))
|
||||||
Spacer(Modifier.width(12.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)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
row.title.text(),
|
row.title.text(),
|
||||||
style = if (large) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.bodyLarge,
|
style = ChromeType.title,
|
||||||
fontWeight = if (large) FontWeight.SemiBold else null,
|
|
||||||
maxLines = if (large) 2 else 1,
|
maxLines = if (large) 2 else 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
||||||
Text(
|
Text(
|
||||||
line.text(),
|
line.text(),
|
||||||
style = if (large) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
|
style = ChromeType.body,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = if (large) 2 else 1,
|
maxLines = if (large) 2 else 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import androidx.car.app.suggestion.ISuggestionHost
|
|||||||
import androidx.car.app.hardware.ICarHardwareHost
|
import androidx.car.app.hardware.ICarHardwareHost
|
||||||
import androidx.car.app.hardware.ICarHardwareResult
|
import androidx.car.app.hardware.ICarHardwareResult
|
||||||
import androidx.car.app.navigation.INavigationHost
|
import androidx.car.app.navigation.INavigationHost
|
||||||
|
import androidx.car.app.navigation.INavigationManager
|
||||||
import androidx.car.app.serialization.Bundleable
|
import androidx.car.app.serialization.Bundleable
|
||||||
import androidx.car.app.versioning.CarAppApiLevels
|
import androidx.car.app.versioning.CarAppApiLevels
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
@@ -81,6 +82,8 @@ class CarAppConnection(private val context: Context) {
|
|||||||
|
|
||||||
private var carApp: ICarApp? = null
|
private var carApp: ICarApp? = null
|
||||||
private var appManager: IAppManager? = 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. */
|
/** Main thread only, both of these - the handoff is a race between them either way round. */
|
||||||
private var surfaceCallback: ISurfaceCallback? = null
|
private var surfaceCallback: ISurfaceCallback? = null
|
||||||
private var lent: Lent? = null
|
private var lent: Lent? = null
|
||||||
@@ -189,6 +192,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
binding = null
|
binding = null
|
||||||
this.carApp = null
|
this.carApp = null
|
||||||
appManager = null
|
appManager = null
|
||||||
|
navigationManager = null
|
||||||
_panMode.value = false
|
_panMode.value = false
|
||||||
surfaceCallback = null
|
surfaceCallback = null
|
||||||
component = 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}")
|
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
|
||||||
requestTemplate()
|
requestTemplate()
|
||||||
}) { app.getManager(CarContext.APP_SERVICE, it) }
|
}) { 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. */
|
/** 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"
|
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.
|
* Tells the app something was tapped.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package app.seb3thehacker.gearslip.ui
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
import android.hardware.HardwareBuffer
|
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.input.pointer.pointerInteropFilter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
@@ -70,7 +71,9 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
||||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
var viewSize by remember { mutableStateOf(0 to 0) }
|
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 width = frame.width
|
||||||
val height = frame.height
|
val height = frame.height
|
||||||
@@ -149,7 +152,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fullscreen) {
|
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
|
// 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.
|
// whole height, whichever the car's shape allows, and the rest stays black.
|
||||||
frameContent(Modifier)
|
frameContent(Modifier)
|
||||||
@@ -174,7 +177,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
|
|
||||||
Row {
|
Row {
|
||||||
TextButton(onClick = onBack) { Text("Back") }
|
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.
|
* 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
|
* 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
|
* one. This only shows once the phone is already turned sideways (the caller drives it off the
|
||||||
* are undone on the way out, so the rest of the phone UI is unaffected.
|
* 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
|
@Composable
|
||||||
private fun Fullscreen(
|
private fun Fullscreen(
|
||||||
landscape: Boolean,
|
onBack: () -> Unit,
|
||||||
onExit: () -> Unit,
|
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val activity = LocalContext.current.activity()
|
val activity = LocalContext.current.activity()
|
||||||
|
|
||||||
DisposableEffect(activity, landscape) {
|
DisposableEffect(activity) {
|
||||||
if (activity == null) return@DisposableEffect onDispose { }
|
if (activity == null) return@DisposableEffect onDispose { }
|
||||||
|
|
||||||
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
|
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
|
||||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
hide(WindowInsetsCompat.Type.systemBars())
|
hide(WindowInsetsCompat.Type.systemBars())
|
||||||
}
|
}
|
||||||
val previousOrientation = activity.requestedOrientation
|
|
||||||
if (landscape) activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
|
||||||
|
|
||||||
onDispose {
|
onDispose { insets.show(WindowInsetsCompat.Type.systemBars()) }
|
||||||
insets.show(WindowInsetsCompat.Type.systemBars())
|
|
||||||
activity.requestedOrientation = previousOrientation
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black),
|
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
|
// 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.
|
// a button of its own. This one sits in the corner the car UI leaves emptiest.
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onExit,
|
onClick = onBack,
|
||||||
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
|
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. */
|
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||||
@Composable
|
@Composable
|
||||||
fun GearslipApp(onDisconnect: () -> Unit) {
|
fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||||
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
|
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
|
||||||
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
|
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ fun GearslipApp(onDisconnect: () -> Unit) {
|
|||||||
onOpenSettings = { screen = Screen.SETTINGS },
|
onOpenSettings = { screen = Screen.SETTINGS },
|
||||||
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
||||||
onDisconnect = onDisconnect,
|
onDisconnect = onDisconnect,
|
||||||
|
onRequestCallScreening = onRequestCallScreening,
|
||||||
)
|
)
|
||||||
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
||||||
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
|
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ fun HomeScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenCarPreview: () -> Unit,
|
onOpenCarPreview: () -> Unit,
|
||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
|
onRequestCallScreening: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -79,7 +80,7 @@ fun HomeScreen(
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
StatusCard(status)
|
StatusCard(status)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
SetupSteps()
|
SetupSteps(onRequestCallScreening)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
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.
|
* 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
|
* [command] is for a step that cannot be done on the phone; it is shown as something to copy
|
||||||
* copy rather than to read, because a mistyped package name fails silently.
|
* 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("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
|
||||||
Step(
|
Step(
|
||||||
"On GrapheneOS, exempt Gearslip from exploit protections.",
|
"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.",
|
"music is going to the car, then puts it back.",
|
||||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
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."),
|
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. */
|
/** What to do before the first drive, in order. The same width as the status card above it. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SetupSteps() {
|
private fun SetupSteps(onRequestCallScreening: () -> Unit) {
|
||||||
|
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
|
||||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
@@ -268,7 +285,7 @@ private fun SetupSteps() {
|
|||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
SETUP_STEPS.forEachIndexed { index, step ->
|
steps.forEachIndexed { index, step ->
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"${index + 1}",
|
"${index + 1}",
|
||||||
@@ -287,6 +304,11 @@ private fun SetupSteps() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
step.command?.let { CopyableCommand(it) }
|
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"
|
rootProject.name = "Gearslip"
|
||||||
include(":gearslip")
|
include(":gearslip")
|
||||||
|
include(":ksdump")
|
||||||
|
|
||||||
Reference in new issue
Block a user