mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
car: split the nav bar, template rows and media browsing into their own files
This commit is contained in:
1 parent
ca33b5d01c
commit
795963f178
6 files changed
+1290
-1028
No files matched your search
@@ -0,0 +1,664 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
|
import app.seb3thehacker.gearslip.call.CallUiState
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.LocalIndication
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.navigation.model.NavigationTemplate
|
||||||
|
import androidx.car.app.navigation.model.RoutingInfo
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
|
import app.seb3thehacker.gearslip.host.display
|
||||||
|
import app.seb3thehacker.gearslip.host.text
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
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.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||||
|
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
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
// The nav bar along the bottom of the car screen: home, apps, the assistant, running apps,
|
||||||
|
// the next turn or a call, the player, and the clock. Split out of CarUi.kt.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
||||||
|
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
||||||
|
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
||||||
|
* back at all, since a car touchscreen has no hardware button for it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BreadcrumbBar(navigator: CarNavigator) {
|
||||||
|
val trail = navigator.trail
|
||||||
|
if (trail.size <= 1) return
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 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
|
||||||
|
internal fun CarNavBar(navigator: CarNavigator) {
|
||||||
|
val battery by CarEnvironment.battery.collectAsState()
|
||||||
|
val now by rememberNow()
|
||||||
|
val weather by Weather.state.collectAsState()
|
||||||
|
val weatherData = (weather as? WeatherState.Ready)?.data
|
||||||
|
|
||||||
|
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||||
|
val entries = rememberAllEntries()
|
||||||
|
val pinnedEntries = remember(entries, pinnedIds) {
|
||||||
|
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||||
|
}
|
||||||
|
val frame by CarEnvironment.frame.collectAsState()
|
||||||
|
val running = rememberRunningApps()
|
||||||
|
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
|
||||||
|
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// Home and Apps are two different places now, not a toggle on one button - the
|
||||||
|
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
||||||
|
// these two from three screens deep in a media app shouldn't mean reading it
|
||||||
|
// first.
|
||||||
|
NavHomeButton(navigator, entries, running)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
NavAssistantIcon()
|
||||||
|
|
||||||
|
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
||||||
|
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
||||||
|
// The map app lives on the Home button, so it never gets a second icon here.
|
||||||
|
val runningEntries = running.keys
|
||||||
|
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
|
||||||
|
.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||||
|
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
|
||||||
|
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
|
||||||
|
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||||
|
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
|
||||||
|
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||||
|
|
||||||
|
// Next turn first, off the map only; the player shares the space when it fits,
|
||||||
|
// and gives way to the turn when it doesn't.
|
||||||
|
// A call takes the player's place until it ends.
|
||||||
|
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
|
||||||
|
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
|
||||||
|
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
||||||
|
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
|
||||||
|
if (call != null) {
|
||||||
|
NavCallPill(call, Modifier.weight(1f, fill = false))
|
||||||
|
} else if (turn == null || roomForBoth) {
|
||||||
|
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// The clock is also the notifications button: the dashboard lists them under the
|
||||||
|
// calendar and weather, and a dot here marks anything unread.
|
||||||
|
val unread by CarNotifications.unread.collectAsState()
|
||||||
|
NavChip(
|
||||||
|
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
|
||||||
|
{ navigator.dashboard() },
|
||||||
|
) {
|
||||||
|
if (unread > 0) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = 8.dp, y = (-2).dp)
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
|
||||||
|
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
|
||||||
|
val selected = navigator.current == CarScreen.Home
|
||||||
|
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
|
||||||
|
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
|
||||||
|
GsIconBox(
|
||||||
|
{ navigator.home() },
|
||||||
|
colors = navColors(selected),
|
||||||
|
latched = selected,
|
||||||
|
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
|
||||||
|
) {
|
||||||
|
val icon = navEntry?.icon
|
||||||
|
if (icon != null) {
|
||||||
|
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
|
||||||
|
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
|
||||||
|
@Composable
|
||||||
|
private fun NavDivider() {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.width(1.dp)
|
||||||
|
.height(28.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
|
||||||
|
* already open; a long press opens its menu (pin, unpin, close).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavEntryIcon(
|
||||||
|
entry: Entry,
|
||||||
|
running: Map<String, RunningSlot>,
|
||||||
|
navigator: CarNavigator,
|
||||||
|
frame: CarEnvironment.Frame,
|
||||||
|
) {
|
||||||
|
val id = entry.componentId ?: return
|
||||||
|
val slot = running[id]
|
||||||
|
Box(Modifier.padding(horizontal = 3.dp)) {
|
||||||
|
NavAppIcon(
|
||||||
|
entry,
|
||||||
|
open = slot != null,
|
||||||
|
showing = slot != null && slot.isShowing(navigator.current),
|
||||||
|
onLongClick = { navigator.showAppMenu(id) },
|
||||||
|
) { openApp(entry, slot, navigator, frame) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||||
|
@Composable
|
||||||
|
private fun NavAppIcon(
|
||||||
|
entry: Entry,
|
||||||
|
open: Boolean,
|
||||||
|
showing: Boolean,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
GsIconBox(
|
||||||
|
onClick,
|
||||||
|
Modifier.size(44.dp),
|
||||||
|
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
|
||||||
|
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
|
||||||
|
latched = showing,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
) {
|
||||||
|
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||||
|
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
|
||||||
|
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
||||||
|
if (open) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 2.dp)
|
||||||
|
.size(width = 12.dp, height = 3.dp)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(scheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||||
|
@Composable
|
||||||
|
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
|
||||||
|
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||||
|
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
|
||||||
|
@Composable
|
||||||
|
private fun navColors(selected: Boolean): GsColors {
|
||||||
|
val s = MaterialTheme.colorScheme
|
||||||
|
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
||||||
|
else GsColors(s.surfaceContainerHighest, s.onSurface)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
|
||||||
|
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
|
||||||
|
* two buttons beside it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavAssistantIcon() {
|
||||||
|
val state by CarAssistant.state.collectAsState()
|
||||||
|
val active = state.phase != CarAssistant.Phase.IDLE
|
||||||
|
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
|
||||||
|
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
|
||||||
|
private val NAV_ITEM_PADDING = 8.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
|
||||||
|
while (true) {
|
||||||
|
value = System.currentTimeMillis()
|
||||||
|
delay(60_000 - value % 60_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long the pill shows a new song's title before settling to just the controls. */
|
||||||
|
private const val TITLE_SHOWN_MS = 3_000L
|
||||||
|
|
||||||
|
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
||||||
|
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
||||||
|
|
||||||
|
/** The next manoeuvre, as the running nav app last described it. */
|
||||||
|
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
||||||
|
* while it's running, whether or not the map is the screen being shown.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberNextTurn(): NextTurn? {
|
||||||
|
val status by CarServices.nav.status.collectAsState()
|
||||||
|
val template by CarServices.nav.template.collectAsState()
|
||||||
|
// Read so a change of units redraws the distance straight away, not at the next update.
|
||||||
|
CarSettings.units.collectAsState().value
|
||||||
|
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
||||||
|
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
||||||
|
if (routing.isLoading) return null
|
||||||
|
val step = routing.currentStep ?: return null
|
||||||
|
return NextTurn(
|
||||||
|
icon = step.maneuver?.icon,
|
||||||
|
distance = routing.currentDistance.display(),
|
||||||
|
road = step.road.text().ifEmpty { step.cue.text() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
||||||
|
@Composable
|
||||||
|
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||||
|
// Same finish as every other button on the bar, in the accent colour; the text runs a step
|
||||||
|
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
|
||||||
|
GsIconBox(
|
||||||
|
onClick,
|
||||||
|
Modifier.widthIn(max = 340.dp).height(44.dp),
|
||||||
|
colors = gsColors(GsTone.Primary),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(NAV_ITEM_PADDING),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
CarGlyph(turn.icon, Modifier.size(28.dp))
|
||||||
|
if (turn.distance.isNotEmpty()) {
|
||||||
|
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||||
|
}
|
||||||
|
if (turn.road.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
turn.road,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
|
||||||
|
@Composable
|
||||||
|
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
val muted by CarCalls.muted.collectAsState()
|
||||||
|
val elapsed by produceState(0L, call.since) {
|
||||||
|
while (true) {
|
||||||
|
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
|
||||||
|
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.height(44.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = scheme.secondaryContainer,
|
||||||
|
contentColor = scheme.onSecondaryContainer,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Text(
|
||||||
|
call.caller?.label ?: "On a call",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"%d:%02d".format(elapsed / 60, elapsed % 60),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(2.dp))
|
||||||
|
GsIconButton(
|
||||||
|
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
|
||||||
|
if (muted) "Unmute" else "Mute",
|
||||||
|
CarCalls::toggleMute,
|
||||||
|
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.CallEnd, "End call", { CarCalls.end(context) },
|
||||||
|
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
|
||||||
|
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||||
|
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||||
|
.ifEmpty { "Nothing playing" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
||||||
|
* play/pause and skip work right here. The dock button moves the player into the column beside
|
||||||
|
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
||||||
|
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val media = CarServices.media
|
||||||
|
val phase by media.phase.collectAsState()
|
||||||
|
val now by media.now.collectAsState()
|
||||||
|
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
||||||
|
// Docked beside the map, the player is already on screen - the pill steps aside for it.
|
||||||
|
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
|
||||||
|
// The media screen is the full player already; the pill would only repeat its controls.
|
||||||
|
if (navigator.current == CarScreen.Media) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||||
|
val position by produceState(now.currentPosition(), now) {
|
||||||
|
while (true) {
|
||||||
|
value = now.currentPosition()
|
||||||
|
if (!now.playing) break
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
|
||||||
|
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
||||||
|
val keepTitle by CarSettings.playerTitle.collectAsState()
|
||||||
|
var announcing by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(now.title, now.artist) {
|
||||||
|
announcing = true
|
||||||
|
delay(TITLE_SHOWN_MS)
|
||||||
|
announcing = false
|
||||||
|
}
|
||||||
|
val showTitle = keepTitle || announcing
|
||||||
|
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.animateContentSize()
|
||||||
|
// With the title up, all the room the bar has, so as much of it shows as can.
|
||||||
|
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
||||||
|
.height(44.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
|
||||||
|
contentColor = onPill,
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
// Art (and the title while it shows) opens the full media screen.
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.then(if (showTitle) Modifier.weight(1f) else Modifier)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.clickable { navigator.media() },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
|
||||||
|
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||||
|
}
|
||||||
|
if (showTitle) {
|
||||||
|
Text(
|
||||||
|
nowPlayingLabel(now.title, now.artist),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!showTitle) {
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.Previous, "Previous", media::previous,
|
||||||
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GsIconButton(
|
||||||
|
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||||
|
if (now.playing) "Pause" else "Play",
|
||||||
|
media::togglePlay,
|
||||||
|
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
if (!showTitle) {
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.Next, "Next", media::next,
|
||||||
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
|
||||||
|
size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (now.durationMs > 0) {
|
||||||
|
// matchParentSize, so the line follows the pill's width rather than widening it.
|
||||||
|
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
|
||||||
|
.height(2.dp)
|
||||||
|
.background(onPill.copy(alpha = 0.7f)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 rather than a vector asset.
|
||||||
|
*/
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -358,7 +358,7 @@ fun CarUi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val NAV_BAR_HEIGHT = 56.dp
|
internal val NAV_BAR_HEIGHT = 56.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
|
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
|
||||||
@@ -480,568 +480,6 @@ private fun DebugBuildBadge(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
|
||||||
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
|
||||||
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
|
||||||
* back at all, since a car touchscreen has no hardware button for it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun BreadcrumbBar(navigator: CarNavigator) {
|
|
||||||
val trail = navigator.trail
|
|
||||||
if (trail.size <= 1) return
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
|
|
||||||
Spacer(Modifier.width(2.dp))
|
|
||||||
trail.forEachIndexed { index, crumb ->
|
|
||||||
val isLast = index == trail.lastIndex
|
|
||||||
Text(
|
|
||||||
crumb.label,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = if (isLast) Modifier else Modifier
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
|
||||||
.clickable { navigator.jumpTo(index) }
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
|
||||||
)
|
|
||||||
if (!isLast) {
|
|
||||||
Text(
|
|
||||||
"›",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
|
|
||||||
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
|
|
||||||
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
|
|
||||||
* more room.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun CarNavBar(navigator: CarNavigator) {
|
|
||||||
val battery by CarEnvironment.battery.collectAsState()
|
|
||||||
val now by rememberNow()
|
|
||||||
val weather by Weather.state.collectAsState()
|
|
||||||
val weatherData = (weather as? WeatherState.Ready)?.data
|
|
||||||
|
|
||||||
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
|
||||||
val entries = rememberAllEntries()
|
|
||||||
val pinnedEntries = remember(entries, pinnedIds) {
|
|
||||||
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
|
||||||
}
|
|
||||||
val frame by CarEnvironment.frame.collectAsState()
|
|
||||||
val running = rememberRunningApps()
|
|
||||||
|
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
|
||||||
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
|
|
||||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
// Home and Apps are two different places now, not a toggle on one button - the
|
|
||||||
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
|
||||||
// these two from three screens deep in a media app shouldn't mean reading it
|
|
||||||
// first.
|
|
||||||
NavHomeButton(navigator, entries, running)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
NavAssistantIcon()
|
|
||||||
|
|
||||||
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
|
||||||
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
|
||||||
// The map app lives on the Home button, so it never gets a second icon here.
|
|
||||||
val runningEntries = running.keys
|
|
||||||
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
|
|
||||||
.mapNotNull { id -> entries.find { it.componentId == id } }
|
|
||||||
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
|
|
||||||
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
|
|
||||||
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
|
||||||
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
|
|
||||||
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
|
||||||
|
|
||||||
// Next turn first, off the map only; the player shares the space when it fits,
|
|
||||||
// and gives way to the turn when it doesn't.
|
|
||||||
// A call takes the player's place until it ends.
|
|
||||||
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
|
|
||||||
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
|
|
||||||
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
|
||||||
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
|
|
||||||
if (call != null) {
|
|
||||||
NavCallPill(call, Modifier.weight(1f, fill = false))
|
|
||||||
} else if (turn == null || roomForBoth) {
|
|
||||||
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
// The clock is also the notifications button: the dashboard lists them under the
|
|
||||||
// calendar and weather, and a dot here marks anything unread.
|
|
||||||
val unread by CarNotifications.unread.collectAsState()
|
|
||||||
NavChip(
|
|
||||||
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
|
|
||||||
{ navigator.dashboard() },
|
|
||||||
) {
|
|
||||||
if (unread > 0) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.offset(x = 8.dp, y = (-2).dp)
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.primary),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
|
|
||||||
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
|
|
||||||
val selected = navigator.current == CarScreen.Home
|
|
||||||
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
|
|
||||||
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
|
|
||||||
GsIconBox(
|
|
||||||
{ navigator.home() },
|
|
||||||
colors = navColors(selected),
|
|
||||||
latched = selected,
|
|
||||||
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
|
|
||||||
) {
|
|
||||||
val icon = navEntry?.icon
|
|
||||||
if (icon != null) {
|
|
||||||
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
|
|
||||||
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
|
|
||||||
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
|
|
||||||
@Composable
|
|
||||||
private fun NavDivider() {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.padding(horizontal = 10.dp)
|
|
||||||
.width(1.dp)
|
|
||||||
.height(28.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
|
|
||||||
* already open; a long press opens its menu (pin, unpin, close).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun NavEntryIcon(
|
|
||||||
entry: Entry,
|
|
||||||
running: Map<String, RunningSlot>,
|
|
||||||
navigator: CarNavigator,
|
|
||||||
frame: CarEnvironment.Frame,
|
|
||||||
) {
|
|
||||||
val id = entry.componentId ?: return
|
|
||||||
val slot = running[id]
|
|
||||||
Box(Modifier.padding(horizontal = 3.dp)) {
|
|
||||||
NavAppIcon(
|
|
||||||
entry,
|
|
||||||
open = slot != null,
|
|
||||||
showing = slot != null && slot.isShowing(navigator.current),
|
|
||||||
onLongClick = { navigator.showAppMenu(id) },
|
|
||||||
) { openApp(entry, slot, navigator, frame) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
|
||||||
@Composable
|
|
||||||
private fun NavAppIcon(
|
|
||||||
entry: Entry,
|
|
||||||
open: Boolean,
|
|
||||||
showing: Boolean,
|
|
||||||
onLongClick: () -> Unit,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
GsIconBox(
|
|
||||||
onClick,
|
|
||||||
Modifier.size(44.dp),
|
|
||||||
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
|
|
||||||
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
|
|
||||||
latched = showing,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
) {
|
|
||||||
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
|
||||||
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
|
|
||||||
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
|
||||||
if (open) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = 2.dp)
|
|
||||||
.size(width = 12.dp, height = 3.dp)
|
|
||||||
.clip(RoundedCornerShape(50))
|
|
||||||
.background(scheme.primary),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
|
||||||
@Composable
|
|
||||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
|
|
||||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
|
||||||
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
|
|
||||||
@Composable
|
|
||||||
private fun navColors(selected: Boolean): GsColors {
|
|
||||||
val s = MaterialTheme.colorScheme
|
|
||||||
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
|
||||||
else GsColors(s.surfaceContainerHighest, s.onSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
|
|
||||||
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
|
|
||||||
* two buttons beside it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun NavAssistantIcon() {
|
|
||||||
val state by CarAssistant.state.collectAsState()
|
|
||||||
val active = state.phase != CarAssistant.Phase.IDLE
|
|
||||||
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
|
|
||||||
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
|
|
||||||
private val NAV_ITEM_PADDING = 8.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
|
||||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
|
||||||
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
|
|
||||||
@Composable
|
|
||||||
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
|
|
||||||
while (true) {
|
|
||||||
value = System.currentTimeMillis()
|
|
||||||
delay(60_000 - value % 60_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How long the pill shows a new song's title before settling to just the controls. */
|
|
||||||
private const val TITLE_SHOWN_MS = 3_000L
|
|
||||||
|
|
||||||
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
|
||||||
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
|
||||||
|
|
||||||
/** The next manoeuvre, as the running nav app last described it. */
|
|
||||||
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
|
||||||
* while it's running, whether or not the map is the screen being shown.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun rememberNextTurn(): NextTurn? {
|
|
||||||
val status by CarServices.nav.status.collectAsState()
|
|
||||||
val template by CarServices.nav.template.collectAsState()
|
|
||||||
// Read so a change of units redraws the distance straight away, not at the next update.
|
|
||||||
CarSettings.units.collectAsState().value
|
|
||||||
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
|
||||||
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
|
||||||
if (routing.isLoading) return null
|
|
||||||
val step = routing.currentStep ?: return null
|
|
||||||
return NextTurn(
|
|
||||||
icon = step.maneuver?.icon,
|
|
||||||
distance = routing.currentDistance.display(),
|
|
||||||
road = step.road.text().ifEmpty { step.cue.text() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
|
||||||
@Composable
|
|
||||||
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
|
||||||
// Same finish as every other button on the bar, in the accent colour; the text runs a step
|
|
||||||
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
|
|
||||||
GsIconBox(
|
|
||||||
onClick,
|
|
||||||
Modifier.widthIn(max = 340.dp).height(44.dp),
|
|
||||||
colors = gsColors(GsTone.Primary),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier.padding(NAV_ITEM_PADDING),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
CarGlyph(turn.icon, Modifier.size(28.dp))
|
|
||||||
if (turn.distance.isNotEmpty()) {
|
|
||||||
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
|
|
||||||
}
|
|
||||||
if (turn.road.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
turn.road,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
|
|
||||||
@Composable
|
|
||||||
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
|
|
||||||
val context = LocalContext.current.applicationContext
|
|
||||||
val muted by CarCalls.muted.collectAsState()
|
|
||||||
val elapsed by produceState(0L, call.since) {
|
|
||||||
while (true) {
|
|
||||||
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
|
|
||||||
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
Surface(
|
|
||||||
modifier = modifier.height(44.dp),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
color = scheme.secondaryContainer,
|
|
||||||
contentColor = scheme.onSecondaryContainer,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
|
||||||
Text(
|
|
||||||
call.caller?.label ?: "On a call",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"%d:%02d".format(elapsed / 60, elapsed % 60),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(2.dp))
|
|
||||||
GsIconButton(
|
|
||||||
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
|
|
||||||
if (muted) "Unmute" else "Mute",
|
|
||||||
CarCalls::toggleMute,
|
|
||||||
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
GsIconButton(
|
|
||||||
MediaIcons.CallEnd, "End call", { CarCalls.end(context) },
|
|
||||||
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
|
|
||||||
private fun nowPlayingLabel(title: String, artist: String): String =
|
|
||||||
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
|
||||||
.ifEmpty { "Nothing playing" }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
|
||||||
* play/pause and skip work right here. The dock button moves the player into the column beside
|
|
||||||
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
|
||||||
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
|
||||||
val media = CarServices.media
|
|
||||||
val phase by media.phase.collectAsState()
|
|
||||||
val now by media.now.collectAsState()
|
|
||||||
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
|
||||||
// Docked beside the map, the player is already on screen - the pill steps aside for it.
|
|
||||||
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
|
|
||||||
// The media screen is the full player already; the pill would only repeat its controls.
|
|
||||||
if (navigator.current == CarScreen.Media) return
|
|
||||||
val context = LocalContext.current
|
|
||||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
|
||||||
val position by produceState(now.currentPosition(), now) {
|
|
||||||
while (true) {
|
|
||||||
value = now.currentPosition()
|
|
||||||
if (!now.playing) break
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
|
|
||||||
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
|
||||||
val keepTitle by CarSettings.playerTitle.collectAsState()
|
|
||||||
var announcing by remember { mutableStateOf(true) }
|
|
||||||
LaunchedEffect(now.title, now.artist) {
|
|
||||||
announcing = true
|
|
||||||
delay(TITLE_SHOWN_MS)
|
|
||||||
announcing = false
|
|
||||||
}
|
|
||||||
val showTitle = keepTitle || announcing
|
|
||||||
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
|
||||||
Surface(
|
|
||||||
modifier = modifier
|
|
||||||
.animateContentSize()
|
|
||||||
// With the title up, all the room the bar has, so as much of it shows as can.
|
|
||||||
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
|
||||||
.height(44.dp),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
|
|
||||||
contentColor = onPill,
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
// Art (and the title while it shows) opens the full media screen.
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.then(if (showTitle) Modifier.weight(1f) else Modifier)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.clickable { navigator.media() },
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
|
|
||||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
|
||||||
}
|
|
||||||
if (showTitle) {
|
|
||||||
Text(
|
|
||||||
nowPlayingLabel(now.title, now.artist),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!showTitle) {
|
|
||||||
GsIconButton(
|
|
||||||
MediaIcons.Previous, "Previous", media::previous,
|
|
||||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GsIconButton(
|
|
||||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
|
||||||
if (now.playing) "Pause" else "Play",
|
|
||||||
media::togglePlay,
|
|
||||||
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
if (!showTitle) {
|
|
||||||
GsIconButton(
|
|
||||||
MediaIcons.Next, "Next", media::next,
|
|
||||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GsIconButton(
|
|
||||||
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
|
|
||||||
size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (now.durationMs > 0) {
|
|
||||||
// matchParentSize, so the line follows the pill's width rather than widening it.
|
|
||||||
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
|
|
||||||
.height(2.dp)
|
|
||||||
.background(onPill.copy(alpha = 0.7f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 rather than a vector asset.
|
|
||||||
*/
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stands in for the phone's vibration motor on the car screen: does nothing. */
|
/** Stands in for the phone's vibration motor on the car screen: does nothing. */
|
||||||
private object NoHaptics : HapticFeedback {
|
private object NoHaptics : HapticFeedback {
|
||||||
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
|
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
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.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.PathFillType
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import app.seb3thehacker.gearslip.media.CarMedia
|
||||||
|
import app.seb3thehacker.gearslip.media.MediaApp
|
||||||
|
import app.seb3thehacker.gearslip.media.MediaArt
|
||||||
|
import app.seb3thehacker.gearslip.media.LyricsResult
|
||||||
|
import app.seb3thehacker.gearslip.media.Lyrics
|
||||||
|
import app.seb3thehacker.gearslip.media.MediaEntry
|
||||||
|
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||||
|
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
|
||||||
|
// The media screen's browse list, up-next queue, rows and icons. Split out of MediaScreen.kt.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
||||||
|
Column(modifier) {
|
||||||
|
// At the library root there's no trail and nowhere to go up to, so there's nothing this
|
||||||
|
// row could say that the app's name in the top-left corner hasn't already - showing it
|
||||||
|
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
||||||
|
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (media.canGoUp) {
|
||||||
|
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
|
||||||
|
}
|
||||||
|
browse.trail.lastOrNull()?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
browse.loading -> Notice("Loading…", null)
|
||||||
|
browse.unavailable -> Notice(
|
||||||
|
"$appLabel keeps its library to itself.",
|
||||||
|
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
|
||||||
|
)
|
||||||
|
browse.failed -> Notice("Could not load this list.", null)
|
||||||
|
browse.entries.isEmpty() -> Notice("Nothing here.", null)
|
||||||
|
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- up next -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whatever the app has queued up, starting right after the track that's playing now - not the
|
||||||
|
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
|
||||||
|
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||||
|
val upNext = remember(queue, now.activeQueueItemId) {
|
||||||
|
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
|
||||||
|
if (current >= 0) queue.drop(current + 1) else queue
|
||||||
|
}
|
||||||
|
Column(modifier) {
|
||||||
|
if (upNext.isEmpty()) {
|
||||||
|
Notice(
|
||||||
|
"Nothing queued.",
|
||||||
|
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
||||||
|
"not every app tells Gearslip what comes next.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val icon by produceState(track.iconBitmap, track.iconUri) {
|
||||||
|
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = { media.playQueueItem(track) },
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RowArt(icon, MediaIcons.MusicNote)
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (track.subtitle.isNotEmpty()) {
|
||||||
|
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||||
|
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (entry.subtitle.isNotEmpty()) {
|
||||||
|
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.browsable && entry.playable) {
|
||||||
|
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
|
||||||
|
}
|
||||||
|
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row's artwork, or a folder or note in its place when the app sends none. */
|
||||||
|
@Composable
|
||||||
|
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
|
else Icon(fallback, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(30.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Material's transport icons, from their published path data (they live in the extended set). */
|
||||||
|
internal object MediaIcons {
|
||||||
|
val MusicNote = icon("MusicNote", "M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z")
|
||||||
|
val Folder = icon("Folder", "M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z")
|
||||||
|
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")
|
||||||
|
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
|
||||||
|
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
|
||||||
|
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
|
||||||
|
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
|
||||||
|
/** A screen with a filled right-hand panel: "put the player beside the map". */
|
||||||
|
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
||||||
|
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
|
||||||
|
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
|
||||||
|
|
||||||
|
val CallEnd = icon("CallEnd", "M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z")
|
||||||
|
val Mic = icon("Mic", "M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z")
|
||||||
|
val MicOff = icon("MicOff", "M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z")
|
||||||
|
|
||||||
|
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
|
||||||
|
name, 24.dp, 24.dp, 24f, 24f,
|
||||||
|
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
|
||||||
|
}
|
||||||
@@ -444,7 +444,7 @@ private sealed interface LyricsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
|
internal fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
@@ -568,157 +568,3 @@ private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier)
|
|||||||
|
|
||||||
// --- browsing --------------------------------------------------------------------------------
|
// --- browsing --------------------------------------------------------------------------------
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
|
||||||
Column(modifier) {
|
|
||||||
// At the library root there's no trail and nowhere to go up to, so there's nothing this
|
|
||||||
// row could say that the app's name in the top-left corner hasn't already - showing it
|
|
||||||
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
|
||||||
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (media.canGoUp) {
|
|
||||||
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
|
|
||||||
}
|
|
||||||
browse.trail.lastOrNull()?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
browse.loading -> Notice("Loading…", null)
|
|
||||||
browse.unavailable -> Notice(
|
|
||||||
"$appLabel keeps its library to itself.",
|
|
||||||
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
|
|
||||||
)
|
|
||||||
browse.failed -> Notice("Could not load this list.", null)
|
|
||||||
browse.entries.isEmpty() -> Notice("Nothing here.", null)
|
|
||||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- up next -----------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whatever the app has queued up, starting right after the track that's playing now - not the
|
|
||||||
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
|
|
||||||
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
|
||||||
val upNext = remember(queue, now.activeQueueItemId) {
|
|
||||||
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
|
|
||||||
if (current >= 0) queue.drop(current + 1) else queue
|
|
||||||
}
|
|
||||||
Column(modifier) {
|
|
||||||
if (upNext.isEmpty()) {
|
|
||||||
Notice(
|
|
||||||
"Nothing queued.",
|
|
||||||
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
|
||||||
"not every app tells Gearslip what comes next.",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val icon by produceState(track.iconBitmap, track.iconUri) {
|
|
||||||
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
onClick = { media.playQueueItem(track) },
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
RowArt(icon, MediaIcons.MusicNote)
|
|
||||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
|
||||||
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
if (track.subtitle.isNotEmpty()) {
|
|
||||||
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
|
||||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
|
|
||||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
|
||||||
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
if (entry.subtitle.isNotEmpty()) {
|
|
||||||
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entry.browsable && entry.playable) {
|
|
||||||
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
|
|
||||||
}
|
|
||||||
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A row's artwork, or a folder or note in its place when the app sends none. */
|
|
||||||
@Composable
|
|
||||||
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
|
|
||||||
Box(
|
|
||||||
Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
|
||||||
else Icon(fallback, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(30.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Material's transport icons, from their published path data (they live in the extended set). */
|
|
||||||
internal object MediaIcons {
|
|
||||||
val MusicNote = icon("MusicNote", "M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z")
|
|
||||||
val Folder = icon("Folder", "M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z")
|
|
||||||
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")
|
|
||||||
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
|
|
||||||
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
|
|
||||||
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
|
|
||||||
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
|
|
||||||
/** A screen with a filled right-hand panel: "put the player beside the map". */
|
|
||||||
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
|
||||||
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
|
|
||||||
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
|
|
||||||
|
|
||||||
val CallEnd = icon("CallEnd", "M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z")
|
|
||||||
val Mic = icon("Mic", "M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z")
|
|
||||||
val MicOff = icon("MicOff", "M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z")
|
|
||||||
|
|
||||||
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
|
|
||||||
name, 24.dp, 24.dp, 24f, 24f,
|
|
||||||
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
|
|
||||||
}
|
|
||||||
@@ -293,7 +293,7 @@ internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, lar
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A row with nothing to show: no words, no picture, nothing to tap. */
|
/** A row with nothing to show: no words, no picture, nothing to tap. */
|
||||||
private fun CarRow.isBlank(): Boolean =
|
internal fun CarRow.isBlank(): Boolean =
|
||||||
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
|
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
|
||||||
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
|
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
|
||||||
|
|
||||||
@@ -514,315 +514,6 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
|
|||||||
|
|
||||||
// --- lists, grids and panes ---------------------------------------------------------------------
|
// --- lists, grids and panes ---------------------------------------------------------------------
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun PaneTitle(title: String) {
|
|
||||||
if (title.isEmpty()) return
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = ChromeType.title,
|
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ItemListColumn(
|
|
||||||
single: ItemList?,
|
|
||||||
sectioned: List<ItemList>? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val rows = buildList {
|
|
||||||
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
|
|
||||||
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
|
|
||||||
}
|
|
||||||
if (rows.isEmpty()) {
|
|
||||||
val message = single?.noItemsMessage.text()
|
|
||||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
|
||||||
val onSelected = single?.onSelectedDelegate
|
|
||||||
val chosen = single?.selectedIndex ?: -1
|
|
||||||
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
|
|
||||||
itemsIndexed(rows) { index, row ->
|
|
||||||
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
|
|
||||||
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
|
|
||||||
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
|
|
||||||
@Composable
|
|
||||||
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
|
||||||
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
|
|
||||||
if (items.isEmpty()) return
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Adaptive(84.dp),
|
|
||||||
modifier = modifier,
|
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
items(items) { item -> GridTile(item, iconSize = 36.dp) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
|
|
||||||
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
|
|
||||||
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
|
|
||||||
* instead of wrapping into a grid and wants its full-size cover art.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
|
|
||||||
Column(
|
|
||||||
modifier
|
|
||||||
.clickable { item.onClickDelegate.click("grid item") }
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
CarGlyph(item.image, Modifier.size(iconSize))
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
item.title.text(),
|
|
||||||
style = ChromeType.label,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val subtitle = item.text.text()
|
|
||||||
if (subtitle.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style = ChromeType.label,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
|
||||||
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
|
||||||
* driver glancing at the car screen has already decided. It never applies to more than one
|
|
||||||
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
|
||||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
|
||||||
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
|
||||||
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
|
||||||
val card = PlaceCard.of(rows)
|
|
||||||
Column(modifier) {
|
|
||||||
card.heading?.let { PlaceHeading(it) }
|
|
||||||
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
|
||||||
if (autoStart && actions.size == 1) {
|
|
||||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
|
||||||
} else {
|
|
||||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
|
||||||
}
|
|
||||||
if (card.heading == null && card.buttons.isEmpty()) {
|
|
||||||
card.details.forEach { RowItem(it, large = true) }
|
|
||||||
} else {
|
|
||||||
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
|
|
||||||
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
|
|
||||||
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
|
|
||||||
* heading and no buttons, and draws exactly as a plain list of rows.
|
|
||||||
*/
|
|
||||||
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
|
|
||||||
companion object {
|
|
||||||
fun of(rows: List<CarRow>): PlaceCard {
|
|
||||||
// A short tappable row with an icon and nothing under it is a button in all but name:
|
|
||||||
// MAPS.ME's "Let's go" and "Save".
|
|
||||||
val buttons = rows.filter {
|
|
||||||
it.onClickDelegate != null && it.toggle == null && it.image != null &&
|
|
||||||
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
|
|
||||||
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
|
|
||||||
// A first row that can't be tapped and has no icon is the place's name and kind.
|
|
||||||
val heading = rows.firstOrNull()?.takeIf {
|
|
||||||
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
|
|
||||||
}
|
|
||||||
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PlaceHeading(row: CarRow) {
|
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
|
|
||||||
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
||||||
row.texts.orEmpty().take(2).forEach {
|
|
||||||
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
|
|
||||||
@Composable
|
|
||||||
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
rows.forEachIndexed { i, row ->
|
|
||||||
GsIconBox(
|
|
||||||
onClick = { row.onClickDelegate.click("row") },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
|
|
||||||
enabled = row.isEnabled,
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
CarGlyph(row.image, Modifier.size(24.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One fact about a place, tighter than a list row: a small label over its value, or just the
|
|
||||||
* value when the row has no label. A phone number gets a Call button that asks first.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun DetailRow(row: CarRow, placeName: String) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
|
|
||||||
val label = row.title.text().takeIf { texts.isNotEmpty() }
|
|
||||||
val values = texts.ifEmpty { listOf(row.title.text()) }
|
|
||||||
val number = values.firstOrNull { it.isPhoneNumber() }
|
|
||||||
val canCall = number != null &&
|
|
||||||
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
|
|
||||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
row.image?.let {
|
|
||||||
CarGlyph(it, Modifier.size(24.dp))
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
label?.let {
|
|
||||||
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
|
||||||
}
|
|
||||||
values.take(3).forEach {
|
|
||||||
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (canCall) {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
GsIconBox(
|
|
||||||
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
|
|
||||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
) {
|
|
||||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text("Call", style = ChromeType.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ActionRow(row.actions.orEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
|
|
||||||
private fun String.isPhoneNumber(): Boolean {
|
|
||||||
val t = trim()
|
|
||||||
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
|
|
||||||
return t.count { it.isDigit() } in 7..15
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
|
||||||
val toggle = row.toggle
|
|
||||||
// Shown at once and corrected when the app answers with its own template, so the switch
|
|
||||||
// never lags a tap; keyed on what the app last said so its answer wins.
|
|
||||||
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
|
|
||||||
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.let {
|
|
||||||
if (!clickable) it else it.clickable {
|
|
||||||
when {
|
|
||||||
toggle != null -> {
|
|
||||||
checked = !checked
|
|
||||||
toggle.onCheckedChangeDelegate.checkedChanged(checked)
|
|
||||||
}
|
|
||||||
selection != null -> selection.onPick()
|
|
||||||
else -> row.onClickDelegate.click("row")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
if (selection != null) {
|
|
||||||
RadioButton(selected = selection.selected, onClick = null)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
row.image?.let {
|
|
||||||
CarGlyph(it, Modifier.size(28.dp))
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
// large only changes how much room a row gets (more lines, a bigger icon above) - the
|
|
||||||
// type itself is the same scale as every other row, so a pane's one row and a plain
|
|
||||||
// list's tenth row read as the same kind of text.
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
row.title.text(),
|
|
||||||
style = ChromeType.title,
|
|
||||||
maxLines = if (large) 2 else 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
|
||||||
Text(
|
|
||||||
line.text(),
|
|
||||||
style = ChromeType.body,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = if (large) 2 else 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ActionRow(row.actions.orEmpty())
|
|
||||||
if (toggle != null) {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
|
|
||||||
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this colour stands out against the background that [content] was chosen for: at least
|
|
||||||
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
|
|
||||||
*/
|
|
||||||
private fun Color.readableOn(content: Color): Boolean {
|
|
||||||
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
|
|
||||||
val a = luminance() + 0.05f
|
|
||||||
val b = background.luminance() + 0.05f
|
|
||||||
return maxOf(a, b) / minOf(a, b) >= 3f
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Color.luminanceIsDark(): Boolean =
|
internal fun Color.luminanceIsDark(): Boolean =
|
||||||
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
|
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import app.seb3thehacker.gearslip.host.selected
|
||||||
|
import app.seb3thehacker.gearslip.host.checkedChanged
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
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.Shape
|
||||||
|
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.car.app.model.Action
|
||||||
|
import androidx.car.app.model.ActionStrip
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.GridItem
|
||||||
|
import androidx.car.app.model.Header
|
||||||
|
import androidx.car.app.model.ItemList
|
||||||
|
import androidx.car.app.model.Pane
|
||||||
|
import androidx.car.app.model.Row as CarRow
|
||||||
|
import androidx.car.app.model.Template
|
||||||
|
import app.seb3thehacker.gearslip.host.click
|
||||||
|
import app.seb3thehacker.gearslip.host.color
|
||||||
|
import app.seb3thehacker.gearslip.host.image
|
||||||
|
import app.seb3thehacker.gearslip.host.isDrawable
|
||||||
|
import app.seb3thehacker.gearslip.host.standardType
|
||||||
|
import app.seb3thehacker.gearslip.host.text
|
||||||
|
|
||||||
|
// Lists, grids, panes and rows of templated apps. Split out of TemplateParts.kt.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PaneTitle(title: String) {
|
||||||
|
if (title.isEmpty()) return
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = ChromeType.title,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ItemListColumn(
|
||||||
|
single: ItemList?,
|
||||||
|
sectioned: List<ItemList>? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val rows = buildList {
|
||||||
|
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
|
||||||
|
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
|
||||||
|
}
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
val message = single?.noItemsMessage.text()
|
||||||
|
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
||||||
|
val onSelected = single?.onSelectedDelegate
|
||||||
|
val chosen = single?.selectedIndex ?: -1
|
||||||
|
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||||
|
itemsIndexed(rows) { index, row ->
|
||||||
|
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
|
||||||
|
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
|
||||||
|
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
|
||||||
|
@Composable
|
||||||
|
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||||
|
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
|
||||||
|
if (items.isEmpty()) return
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(84.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
items(items) { item -> GridTile(item, iconSize = 36.dp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
|
||||||
|
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
|
||||||
|
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
|
||||||
|
* instead of wrapping into a grid and wants its full-size cover art.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clickable { item.onClickDelegate.click("grid item") }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CarGlyph(item.image, Modifier.size(iconSize))
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
item.title.text(),
|
||||||
|
style = ChromeType.label,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
val subtitle = item.text.text()
|
||||||
|
if (subtitle.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = ChromeType.label,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
||||||
|
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
||||||
|
* driver glancing at the car screen has already decided. It never applies to more than one
|
||||||
|
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
||||||
|
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||||
|
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||||
|
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||||
|
val card = PlaceCard.of(rows)
|
||||||
|
Column(modifier) {
|
||||||
|
card.heading?.let { PlaceHeading(it) }
|
||||||
|
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
||||||
|
if (autoStart && actions.size == 1) {
|
||||||
|
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||||
|
} else {
|
||||||
|
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||||
|
}
|
||||||
|
if (card.heading == null && card.buttons.isEmpty()) {
|
||||||
|
card.details.forEach { RowItem(it, large = true) }
|
||||||
|
} else {
|
||||||
|
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
|
||||||
|
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
|
||||||
|
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
|
||||||
|
* heading and no buttons, and draws exactly as a plain list of rows.
|
||||||
|
*/
|
||||||
|
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
|
||||||
|
companion object {
|
||||||
|
fun of(rows: List<CarRow>): PlaceCard {
|
||||||
|
// A short tappable row with an icon and nothing under it is a button in all but name:
|
||||||
|
// MAPS.ME's "Let's go" and "Save".
|
||||||
|
val buttons = rows.filter {
|
||||||
|
it.onClickDelegate != null && it.toggle == null && it.image != null &&
|
||||||
|
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
|
||||||
|
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
|
||||||
|
// A first row that can't be tapped and has no icon is the place's name and kind.
|
||||||
|
val heading = rows.firstOrNull()?.takeIf {
|
||||||
|
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
|
||||||
|
}
|
||||||
|
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlaceHeading(row: CarRow) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
|
||||||
|
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
row.texts.orEmpty().take(2).forEach {
|
||||||
|
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
|
||||||
|
@Composable
|
||||||
|
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
rows.forEachIndexed { i, row ->
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { row.onClickDelegate.click("row") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
|
||||||
|
enabled = row.isEnabled,
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CarGlyph(row.image, Modifier.size(24.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One fact about a place, tighter than a list row: a small label over its value, or just the
|
||||||
|
* value when the row has no label. A phone number gets a Call button that asks first.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DetailRow(row: CarRow, placeName: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
|
||||||
|
val label = row.title.text().takeIf { texts.isNotEmpty() }
|
||||||
|
val values = texts.ifEmpty { listOf(row.title.text()) }
|
||||||
|
val number = values.firstOrNull { it.isPhoneNumber() }
|
||||||
|
val canCall = number != null &&
|
||||||
|
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
|
||||||
|
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
row.image?.let {
|
||||||
|
CarGlyph(it, Modifier.size(24.dp))
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
label?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||||
|
}
|
||||||
|
values.take(3).forEach {
|
||||||
|
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canCall) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Call", style = ChromeType.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ActionRow(row.actions.orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
|
||||||
|
private fun String.isPhoneNumber(): Boolean {
|
||||||
|
val t = trim()
|
||||||
|
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
|
||||||
|
return t.count { it.isDigit() } in 7..15
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
||||||
|
val toggle = row.toggle
|
||||||
|
// Shown at once and corrected when the app answers with its own template, so the switch
|
||||||
|
// never lags a tap; keyed on what the app last said so its answer wins.
|
||||||
|
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
|
||||||
|
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.let {
|
||||||
|
if (!clickable) it else it.clickable {
|
||||||
|
when {
|
||||||
|
toggle != null -> {
|
||||||
|
checked = !checked
|
||||||
|
toggle.onCheckedChangeDelegate.checkedChanged(checked)
|
||||||
|
}
|
||||||
|
selection != null -> selection.onPick()
|
||||||
|
else -> row.onClickDelegate.click("row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (selection != null) {
|
||||||
|
RadioButton(selected = selection.selected, onClick = null)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
row.image?.let {
|
||||||
|
CarGlyph(it, Modifier.size(28.dp))
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
// large only changes how much room a row gets (more lines, a bigger icon above) - the
|
||||||
|
// type itself is the same scale as every other row, so a pane's one row and a plain
|
||||||
|
// list's tenth row read as the same kind of text.
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
row.title.text(),
|
||||||
|
style = ChromeType.title,
|
||||||
|
maxLines = if (large) 2 else 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
||||||
|
Text(
|
||||||
|
line.text(),
|
||||||
|
style = ChromeType.body,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = if (large) 2 else 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ActionRow(row.actions.orEmpty())
|
||||||
|
if (toggle != null) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
|
||||||
|
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this colour stands out against the background that [content] was chosen for: at least
|
||||||
|
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
|
||||||
|
*/
|
||||||
|
internal fun Color.readableOn(content: Color): Boolean {
|
||||||
|
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
|
||||||
|
val a = luminance() + 0.05f
|
||||||
|
val b = background.luminance() + 0.05f
|
||||||
|
return maxOf(a, b) / minOf(a, b) >= 3f
|
||||||
|
}
|
||||||
Reference in new issue
Block a user