Release v0.0.02-pre: car dashboard, nav bar rework, cached launcher

- Nav bar is icon-only and shorter, with a now-playing pill on non-home screens
- Clock opens a calendar and weather dashboard (adds READ_CALENDAR)
- Web page URL is now set from the car's Settings screen
- Launcher app scan and weather are cached
- Home player gains skip buttons and a larger cover
This commit is contained in:
seb committed 2026-09-20 03:00:25 -07:00
1 parent 24cfbee0c4
commit 1b71f96cc0
15 files changed
+544 -143

No files matched your search

+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "app.seb3thehacker.gearslip"
minSdk = 35
targetSdk = 37
versionCode = 1
versionName = "0.1"
versionCode = 2
versionName = "0.0.02-pre"
}
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
+3
View File
@@ -17,6 +17,9 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- The dashboard opened from the car clock: today's agenda and current weather. -->
<uses-permission android:name="android.permission.READ_CALENDAR" />
<!-- Capturing a media app's playback (to send to the car) is gated on this by the platform. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -7,6 +7,7 @@ object AppSettings {
private const val PREFS = "gearslip_settings"
private const val KEY_STARTUP_URL = "startup_url"
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -25,4 +26,12 @@ object AppSettings {
fun setStartupUrl(context: Context, value: String) {
prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply()
}
/** Whether the newer-car compatibility warning has already been shown and dismissed once. */
fun hasSeenCompatWarning(context: Context): Boolean =
prefs(context).getBoolean(KEY_SEEN_COMPAT_WARNING, false)
fun setSeenCompatWarning(context: Context) {
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
}
}
@@ -98,11 +98,19 @@ class GearslipActivity : ComponentActivity(), Projection {
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { }
/** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */
private val calendarPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) calendarPermission.launch(android.Manifest.permission.READ_CALENDAR)
enableEdgeToEdge()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -164,7 +164,7 @@ private fun ChromeBar(
}
/** Corner radius of the home screen's map pane and player. */
val EMBEDDED_RADIUS = 8.dp
val EMBEDDED_RADIUS = 18.dp
/**
* Rounds the corners of a SurfaceView, which Compose cannot clip: the app draws into its own
@@ -0,0 +1,242 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.location.LocationManager
import android.provider.CalendarContract
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.GearslipLog
import java.net.HttpURLConnection
import java.net.URL
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Opened from the nav bar clock: the week's calendar and the current weather, at a glance. Both
* need permissions granted on the phone (READ_CALENDAR, location) - if either was declined
* this just shows an empty state instead of asking again from inside the car UI.
*/
@Composable
fun CarDashboardScreen() {
val context = LocalContext.current.applicationContext
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
WeatherCard(context)
AgendaCard(context)
}
}
@Composable
private fun WeatherCard(context: Context) {
val weather by produceState<Weather?>(Weather.LOADING) {
value = withContext(Dispatchers.IO) { fetchWeather(context) }
}
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
Row(Modifier.fillMaxWidth().padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
val w = weather
when {
w === Weather.LOADING -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
w == null -> Text(
"Weather unavailable - no location fix yet.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> Text(
"${w.tempC.toInt()}°C · ${w.description}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
@Composable
private fun AgendaCard(context: Context) {
val events by produceState<List<AgendaEvent>?>(null) {
value = withContext(Dispatchers.IO) { fetchAgenda(context) }
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Coming up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
val list = events
when {
list == null -> Text("Loading calendar…", style = MaterialTheme.typography.bodyMedium)
list.isEmpty() -> Text(
"Nothing in the next week.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> list.forEach { EventRow(it) }
}
}
}
@Composable
private fun EventRow(event: AgendaEvent) {
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
eventWhen(event),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
event.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */
private fun eventWhen(event: AgendaEvent): String {
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
val today = Calendar.getInstance()
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR)
val time = if (event.allDay) "All day" else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
if (sameDay || (event.allDay && start.before(today))) return time
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
}
private data class Weather(val tempC: Double, val description: String) {
companion object { val LOADING = Weather(Double.NaN, "") }
}
private data class AgendaEvent(val title: String, val startMillis: Long, val allDay: Boolean)
private const val WEATHER_TTL_MS = 60 * 60 * 1000L
private var cachedWeather: Weather? = null
private var cachedWeatherAt = 0L
/** Served from memory for up to an hour: weather barely changes, and each call is a network request. */
private fun fetchWeather(context: Context): Weather? {
val now = System.currentTimeMillis()
cachedWeather?.let { if (now - cachedWeatherAt < WEATHER_TTL_MS) return it }
return loadWeather(context)?.also { cachedWeather = it; cachedWeatherAt = now }
}
/** Null means no fix or no permission; the card shows a plain empty state either way. */
private fun loadWeather(context: Context): Weather? {
if (context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
return null
}
val manager = context.getSystemService(LocationManager::class.java) ?: return null
val location = manager.allProviders
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
?: return null
return runCatching {
// Open-Meteo: no API key, no account - the simplest thing that gives a real reading.
val url = URL(
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}" +
"&longitude=${location.longitude}&current_weather=true",
)
val body = (url.openConnection() as HttpURLConnection).run {
connectTimeout = 5_000
readTimeout = 5_000
inputStream.bufferedReader().use { it.readText() }
}
val current = JSONObject(body).getJSONObject("current_weather")
Weather(current.getDouble("temperature"), weatherDescription(current.getInt("weathercode")))
}.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }.getOrNull()
}
/** Open-Meteo's WMO weather codes, collapsed to what's worth a glance from the driver's seat. */
private fun weatherDescription(code: Int): String = when (code) {
0 -> "Clear"
1, 2 -> "Partly cloudy"
3 -> "Overcast"
45, 48 -> "Fog"
in 51..57 -> "Drizzle"
in 61..67 -> "Rain"
in 71..77 -> "Snow"
in 80..82 -> "Rain showers"
in 85..86 -> "Snow showers"
in 95..99 -> "Thunderstorm"
else -> "-"
}
/**
* Empty list means no permission or nothing scheduled. Looks from the start of today so an
* event already under way still shows, a week ahead so tomorrow's does too, and skips ones
* that have finished.
*/
private fun fetchAgenda(context: Context): List<AgendaEvent> {
if (context.checkSelfPermission(Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) {
return emptyList()
}
val now = System.currentTimeMillis()
val startOfToday = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
}.timeInMillis
val end = startOfToday + 7 * 24 * 60 * 60 * 1000L
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon()
.appendPath(startOfToday.toString())
.appendPath(end.toString())
.build()
val events = mutableListOf<AgendaEvent>()
val projection = arrayOf(
CalendarContract.Instances.TITLE, CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END, CalendarContract.Instances.ALL_DAY,
)
runCatching {
context.contentResolver.query(uri, projection, null, null, "${CalendarContract.Instances.BEGIN} ASC")
?.use { cursor: Cursor ->
while (cursor.moveToNext() && events.size < 10) {
val allDay = cursor.getInt(3) == 1
// All-day events are stored in UTC midnight, so "ended" means the day is over, not the instant.
if (!allDay && cursor.getLong(2) < now) continue
events.add(AgendaEvent(cursor.getString(0) ?: "(untitled)", cursor.getLong(1), allDay))
}
}
}.onFailure { GearslipLog.w("calendar query failed: ${it.message}") }
return events
}
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -65,7 +66,7 @@ fun CarHome() {
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier
.weight(if (showPlayer) 1.8f else 1f)
.weight(if (showPlayer) 2.1f else 1f)
.fillMaxHeight()
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
) {
@@ -117,7 +118,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
}
}
/** Play/pause and a seek bar, and nothing else: the full controls are in the media screen. */
/** Previous, play/pause, next and a seek bar, and nothing else: the full controls are in the media screen. */
@Composable
private fun MiniPlayer(modifier: Modifier) {
val media = CarServices.media
@@ -141,11 +142,11 @@ private fun MiniPlayer(modifier: Modifier) {
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(10.dp),
Modifier.fillMaxSize().padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(Modifier.size(120.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
Box(Modifier.size(140.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Spacer(Modifier.height(10.dp))
@@ -171,12 +172,23 @@ private fun MiniPlayer(modifier: Modifier) {
Spacer(Modifier.height(16.dp))
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(64.dp)) {
Icon(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
Modifier.size(38.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))
}
}
}
}
@@ -35,7 +35,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import android.content.Context
import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaCatalog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -48,6 +51,38 @@ private class Tile(
val onClick: () -> Unit,
)
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
private class Entry(
val label: String,
val icon: ImageBitmap?,
val template: TemplateApp? = null,
val media: MediaApp? = null,
)
/**
* The installed car and media apps with their icons. Scanning the package manager and decoding
* icons is slow, so it happens once per car session; [invalidate] is called when one starts, which
* is the only time new installs are picked up.
*/
private object LauncherCache {
@Volatile var entries: List<Entry>? = null
fun invalidate() { entries = null }
fun load(context: Context): List<Entry> {
fun iconOf(pkg: String) = runCatching {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull()
val nav = CarAppCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), template = it) }
val media = MediaCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), media = it) }
return (nav + media).sortedBy { it.label.lowercase() }.also { entries = it }
}
}
/** Forgets the scanned apps so the next launcher open rescans; call at the start of a car session. */
fun invalidateLauncherApps() = LauncherCache.invalidate()
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
@Composable
fun CarLauncher() {
@@ -55,32 +90,20 @@ fun CarLauncher() {
val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState()
val installed by produceState(initialValue = emptyList<Tile>(), context, frame) {
value = withContext(Dispatchers.IO) {
fun iconOf(pkg: String) = runCatching {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull()
val nav = CarAppCatalog.installed(context).map { app ->
Tile(app.label, iconOf(app.component.packageName)) {
CarServices.connectNav(app, frame)
navigator.home()
}
}
val media = MediaCatalog.installed(context).map { app ->
Tile(app.label, iconOf(app.component.packageName)) {
CarServices.openMedia(app)
navigator.media()
}
}
(nav + media).sortedBy { it.label.lowercase() }
}
// Scanned once per car session (see LauncherCache), not every time the launcher opens.
val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) {
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
}
val tiles = listOf(
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
) + installed + listOf(
) + installed.map { entry ->
Tile(entry.label, entry.icon) {
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
entry.media?.let { CarServices.openMedia(it); navigator.media() }
}
} + listOf(
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
)
@@ -10,6 +10,9 @@ sealed interface CarScreen {
data object Settings : CarScreen
data object Apps : CarScreen
/** Opened from the clock: the day's calendar and the weather, nothing that needs a keyboard. */
data object Dashboard : CarScreen
/** The current media app, full size: browse, lyrics and controls. */
data object Media : CarScreen
@@ -40,6 +43,8 @@ class CarNavigator(startId: String) {
fun settings() { current = CarScreen.Settings }
fun dashboard() { current = CarScreen.Dashboard }
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
fun open(id: String) { current = CarScreen.App(id) }
@@ -26,14 +26,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.ui.CertSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Settings that make sense while sitting in the car. Anything that needs a keyboard (the
* startup URL, certificates) lives on the phone, not here.
* Settings that make sense while sitting in the car. The web page URL is edited here, with the
* car's own on-screen keyboard ([CarKeyboard]); certificate management still needs a real
* keyboard and file picker, so it stays on the phone's Settings screen.
*/
@Composable
fun CarSettingsScreen() {
@@ -102,6 +105,8 @@ fun CarSettingsScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
WebPageSetting()
ScreenFit(vehicle)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -142,6 +147,48 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
}
}
/** The URL (or raw HTML) the Web app shows. Edited with [CarKeyboard] - no phone in hand needed. */
@Composable
private fun WebPageSetting() {
val context = LocalContext.current
var editing by remember { mutableStateOf(false) }
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Web page", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Surface(
onClick = { editing = !editing },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(),
) {
Text(
url.ifEmpty { "Built-in test page - tap to set a URL" },
style = MaterialTheme.typography.bodyLarge,
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
Text(
"Shown by the Web app on the launcher. Applies on the next connection.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (editing) {
CarKeyboard(
url,
onTextChange = {
url = it
AppSettings.setStartupUrl(context, it)
},
onSubmit = { editing = false },
)
}
}
}
@Composable
private fun Info(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -18,11 +19,12 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -40,7 +42,12 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
@@ -74,7 +81,7 @@ fun CarUi() {
val base = LocalDensity.current
val insets by CarEnvironment.insets.collectAsState()
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
remember(appContext) { CarServices.init(appContext) }
remember(appContext) { invalidateLauncherApps(); CarServices.init(appContext) }
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
// The map follows the light outside, whatever the app's own theme is set to.
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
@@ -105,6 +112,7 @@ fun CarUi() {
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
}
@@ -121,67 +129,75 @@ fun CarUi() {
}
}
/** Back, Home and Apps on the left; clock, phone battery and the darkness signal on the right. */
/**
* Home and Apps on the left; now-playing pill in the middle; clock (tap for calendar and weather), phone battery and the
* darkness signal on the right. Icon-only: a driver reads a glyph faster than a label, and it
* keeps the bar short enough to leave the app underneath more room.
*/
@Composable
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState()
val now by rememberNow()
// The home and media screens already show what's playing; everywhere else it lives in the
// middle of this bar so it stays one tap away.
val showNowPlaying = navigator.current != CarScreen.Home && navigator.current != CarScreen.Media
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
Row(
Modifier
.fillMaxWidth()
.height(80.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NavItem(Icons.AutoMirrored.Filled.ArrowBack, "Back", selected = false) { navigator.back() }
Spacer(Modifier.width(8.dp))
NavItem(Icons.Filled.Home, "Home", navigator.current == CarScreen.Home) { navigator.home() }
Spacer(Modifier.width(8.dp))
NavItem(Icons.Filled.Menu, "Apps", navigator.current == CarScreen.Apps) { navigator.apps() }
Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
Spacer(Modifier.weight(1f))
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.headlineSmall,
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.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { navigator.dashboard() }
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
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,
)
}
Spacer(Modifier.width(10.dp))
NavItem(
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
) { navigator.notifications() }
}
if (showNowPlaying) {
NavNowPlaying(navigator, Modifier.align(Alignment.Center))
}
Spacer(Modifier.width(16.dp))
NavItem(
Icons.Filled.Notifications, null, navigator.current is CarScreen.Notifications,
) { navigator.notifications() }
}
}
}
@Composable
private fun NavItem(icon: ImageVector, label: String?, selected: Boolean, onClick: () -> Unit) {
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
val scheme = MaterialTheme.colorScheme
Column(
Box(
Modifier
.clip(RoundedCornerShape(24.dp))
.clip(RoundedCornerShape(18.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
.padding(12.dp),
contentAlignment = Alignment.Center,
) {
Box {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
}
if (label != null) Text(label, style = MaterialTheme.typography.labelLarge)
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
}
}
@@ -193,3 +209,56 @@ internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis(
delay(60_000 - value % 60_000)
}
}
/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
@Composable
private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
val media = CarServices.media
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.hasTrack) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface(
modifier = modifier,
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) {
Row(
Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
Modifier
.clip(RoundedCornerShape(50))
.clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(Modifier.size(40.dp).clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceVariant)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Text(
now.title.ifEmpty { "Nothing playing" }.let { if (it.length > 15) it.take(15) + "…" else it },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(44.dp)) {
Icon(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
Modifier.size(26.dp),
)
}
IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) {
Icon(MediaIcons.Next, "Next", Modifier.size(28.dp))
}
}
}
}
@@ -328,6 +328,8 @@ private fun EntryRow(entry: MediaEntry, media: CarMedia) {
internal object MediaIcons {
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
/** The 3x3 app grid; not in material-icons-core. */
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
private fun icon(name: String, path: String) = ImageVector.Builder(
@@ -1,11 +1,16 @@
package app.seb3thehacker.gearslip.ui
import androidx.activity.compose.BackHandler
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import app.seb3thehacker.gearslip.AppSettings
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW }
@@ -26,4 +31,33 @@ fun GearslipApp(onDisconnect: () -> Unit) {
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
}
CompatWarning()
}
/** Shown once, on first run: the certificate is known to fail on head units newer than ~2020. */
@Composable
private fun CompatWarning() {
val context = LocalContext.current
var visible by rememberSaveable { mutableStateOf(!AppSettings.hasSeenCompatWarning(context)) }
if (visible) {
AlertDialog(
onDismissRequest = {},
title = { Text("Before you plug in") },
text = {
Text(
"Gearslip may not connect on cars or head units newer than about 2020. " +
"Older systems are more likely to work; newer firmware often rejects the " +
"connection outright. See SPIKE_FINDINGS.md in the project for details.",
)
},
confirmButton = {
TextButton(onClick = {
AppSettings.setSeenCompatWarning(context)
visible = false
}) { Text("Got it") }
},
)
}
}
@@ -9,6 +9,7 @@ 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 android.content.Context
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@@ -21,18 +22,13 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.SessionStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun HomeScreen(
@@ -62,10 +58,13 @@ fun HomeScreen(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"v${LocalContext.current.appVersionName()}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
StatusCard(status)
Spacer(Modifier.height(24.dp))
SetupSummary()
Spacer(Modifier.height(16.dp))
}
@@ -141,32 +140,7 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
}
}
/** A read-only recap of what a connection will use, so setup problems are visible before the car. */
@Composable
private fun SetupSummary() {
val context = LocalContext.current
val cert by produceState<CertSummary?>(null) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
val startup = AppSettings.startupUrl(context)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SummaryRow("Certificate", cert?.headline ?: "Checking…")
SummaryRow("Car screen shows", startup.ifEmpty { "Built-in test page" })
}
}
@Composable
private fun SummaryRow(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
value,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
/** The version shown on screen, straight from the manifest - one place to bump per release. */
private fun Context.appVersionName(): String =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
@@ -42,7 +42,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.CertProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -73,7 +72,6 @@ fun SettingsScreen(onBack: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
Spacer(Modifier.height(0.dp))
StartupSection()
CertificateSection()
Spacer(Modifier.height(16.dp))
}
@@ -90,31 +88,6 @@ private fun SectionTitle(text: String) {
)
}
@Composable
private fun StartupSection() {
val context = LocalContext.current
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SectionTitle("Startup URL")
OutlinedTextField(
value = url,
onValueChange = {
url = it
AppSettings.setStartupUrl(context, it)
},
modifier = Modifier.fillMaxWidth(),
label = { Text("URL") },
placeholder = { Text("https://example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
supportingText = {
Text("Shown on the car screen. Leave empty for the built-in test page. Applies on the next connection.")
},
)
}
}
@Composable
private fun CertificateSection() {
val context = LocalContext.current