mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
1 parent
24cfbee0c4
commit
1b71f96cc0
15 files changed
+544
-143
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}¤t_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
|
||||
|
||||
Reference in new issue
Block a user