diff --git a/.gitignore b/.gitignore index 1180a0b..ba9ca43 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ keystore.properties /docs/cert-sweep.sh extraction/ /cloned-repos +docs-internal/ # Internal *.pdf @@ -30,3 +31,6 @@ CLAUDE.md # Other archive/ + +# Side project notes, kept out of the repo +backseat-jury.md diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt index a16dad4..1d253c7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -47,6 +48,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Box import androidx.compose.foundation.background +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.border import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.material.icons.filled.Check @@ -253,7 +256,7 @@ fun CarLauncher() { val id = entry.componentId return Tile( entry.label, entry.icon, entry.builtIn?.glyph, - verified = pkg != null && (KnownApps.works(pkg) || (entry.media != null && KnownApps.playerWorks(pkg))), + verified = pkg != null && !KnownApps.isPartial(pkg) && (KnownApps.works(pkg) || (entry.media != null && KnownApps.playerWorks(pkg))), broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))), partial = pkg != null && KnownApps.isPartial(pkg), onLongClick = id?.let { { navigator.showAppMenu(it) } }, @@ -350,16 +353,28 @@ private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = } } +/** Trial: where a tile says whether its app works. See [AppTileBody]. */ +private enum class BadgeStyle { ON_ICON, OUTLINE } +private val BADGE_STYLE = BadgeStyle.ON_ICON + @OptIn(ExperimentalFoundationApi::class) @Composable private fun AppTile(tile: Tile) { + val status = when { + tile.verified -> Color(0xFF43A047) + tile.partial -> Color(0xFFF9A825) + tile.broken -> Color(0xFFD32F2F) + else -> null + } val shape = MaterialTheme.shapes.extraLarge // A long press opens the app's menu (pin, close) - the same gesture everywhere apps are - // shown, here and on the nav bar. + // shown, here and on the nav bar. GsIconBox marks it in the theme's style. GsIconBox( onClick = tile.onClick, onLongClick = tile.onLongClick, - modifier = Modifier.height(120.dp), + modifier = Modifier.height(120.dp).then( + if (BADGE_STYLE == BadgeStyle.OUTLINE && status != null) Modifier.border(2.5.dp, status, shape) else Modifier, + ), colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), shape = shape, ) { @@ -369,29 +384,44 @@ private fun AppTile(tile: Tile) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - when { - tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp)) - tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp)) + Box { + TileIcon(tile) + if (BADGE_STYLE == BadgeStyle.ON_ICON) { + // Pinned to the icon's lower corner, ringed in the tile's colour so it + // reads as sitting on top of the icon. + val ring = Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp) + .border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape) + when { + tile.verified -> VerifiedBadge(22.dp, ring) + tile.broken -> BrokenBadge(22.dp, ring) + tile.partial -> PartialBadge(22.dp, ring) + } + } } Spacer(Modifier.height(8.dp)) - Text( - tile.label, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - if (tile.verified) { - VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp)) - } - if (tile.broken) { - BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp)) - } - if (tile.partial) { - PartialBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp)) + TileLabel(tile) } } } } + +@Composable +private fun TileIcon(tile: Tile) { + when { + tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp)) + tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp)) + } +} + +@Composable +private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifier) { + Text( + tile.label, + modifier = modifier, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt index a1bd95b..61c97ec 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt @@ -315,6 +315,7 @@ fun GsIconBox( shape: Shape = gsShape(), /** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */ latched: Boolean = false, + /** Also marks the control with the theme's hold marker, so the hold can be found. */ onLongClick: (() -> Unit)? = null, content: @Composable BoxScope.() -> Unit, ) { @@ -327,6 +328,7 @@ fun GsIconBox( .gsSurface(c.container, shape, c.border, pressed, latched), contentAlignment = Alignment.Center, ) { + if (onLongClick != null && enabled) GsHoldMarker(gsLook().holdMarker, c.container, shape, source) CompositionLocalProvider(LocalContentColor provides c.content) { content() } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt new file mode 100644 index 0000000..44cafd3 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt @@ -0,0 +1,180 @@ +package app.seb3thehacker.gearslip.car.theme + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +/** + * How a control says it also does something when held. [GsIconBox] draws it on its own for any + * control given an onLongClick, so callers never place it by hand. + */ +enum class HoldMarkerStyle { + /** The top layer peels back from the top-right corner, further while held. Needs depth. */ + PEEL, + + /** A plain dot in the top-right corner. It doesn't react to a hold. */ + DOT, + + /** Nothing: the hold stays undiscovered. For themes that mark it some other way. */ + NONE, +} + +/** + * The hold marker for one control, drawn under its content. [container] is the control's own + * colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the + * dot whatever the theme says: a circle has no corner to peel. + */ +@Composable +internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) { + if (style == HoldMarkerStyle.NONE) return + val pressed by press.collectIsPressedAsState() + val timeout = LocalViewConfiguration.current.longPressTimeoutMillis.toInt() + val lift = remember { Animatable(0f) } + if (style == HoldMarkerStyle.PEEL) { + LaunchedEffect(pressed) { + if (pressed) lift.animateTo(1f, tween(timeout, easing = LinearEasing)) else lift.animateTo(0f, tween(220)) + } + } + val ink = MaterialTheme.colorScheme.onSurfaceVariant + val accent = MaterialTheme.colorScheme.primaryContainer + val onAccent = MaterialTheme.colorScheme.onPrimaryContainer + Canvas(Modifier.matchParentSize()) { + val side = min(size.width, size.height) + val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f + val round = r >= side * 0.4f + if (style == HoldMarkerStyle.DOT || round) { + drawHoldDot(r, side, ink) + } else { + // Drawn for the bottom-right corner, then flipped up to the top-right. + scale(1f, -1f, pivot = center) { + drawPeel(r, side, container, accent, onAccent, lift.value) + } + } + } +} + +/** Depth of a corner's curve along its diagonal: a fold shallower than this only shaves the curve. */ +private fun curveDepth(r: Float) = r * (2f - 1.41421f) + +private fun DrawScope.drawHoldDot(r: Float, side: Float, ink: Color) { + val dot = (side * 0.06f).coerceIn(5.dp.toPx(), 7.dp.toPx()) + // Tucked into the corner, clear of its curve. + val inset = r * (1f - 0.7071f) + (side * 0.05f).coerceIn(4.dp.toPx(), 6.dp.toPx()) + dot / 2 + drawCircle(ink.copy(alpha = 0.6f), radius = dot / 2, center = Offset(size.width - inset, inset)) +} + +/** The part of a rounded rectangle beyond the fold line x + y = c, as a closed outline. */ +private fun cornerRegion(w: Float, h: Float, r: Float, c: Float): List { + // Walk the bottom-right boundary: down the right edge, round the corner, along the bottom. + val reach = (h + w - c) + 1f + val edge = ArrayList() + edge += Offset(w, h - maxOf(r, reach)) + if (r > 0f) { + for (i in 0..24) { + val a = (i / 24f) * (PI / 2).toFloat() + edge += Offset(w - r + r * cos(a), h - r + r * sin(a)) + } + } else { + edge += Offset(w, h) + } + edge += Offset(w - maxOf(r, reach), h) + // Keep what lies beyond the fold, adding the exact crossing points. + val out = ArrayList() + for (i in 0 until edge.size - 1) { + val p = edge[i] + val q = edge[i + 1] + val ps = p.x + p.y - c + val qs = q.x + q.y - c + if (ps >= 0) out += p + if ((ps >= 0) != (qs >= 0)) { + val t = ps / (ps - qs) + out += Offset(p.x + (q.x - p.x) * t, p.y + (q.y - p.y) * t) + } + } + val last = edge.last() + if (last.x + last.y - c >= 0) out += last + return out +} + +private fun pathOf(points: List): Path = Path().apply { + if (points.isEmpty()) return@apply + moveTo(points[0].x, points[0].y) + for (p in points.drop(1)) lineTo(p.x, p.y) + close() +} + +/** + * The top layer peeling back from the corner, showing a second layer underneath with the same + * rounded corner, so the control's outline never changes. Sized from the control: a launcher + * tile folds about 27dp at rest and 64dp held, a nav bar button far less. + */ +private fun DrawScope.drawPeel(r: Float, side: Float, face: Color, accent: Color, onAccent: Color, lift: Float) { + val w = size.width + val h = size.height + val rest = curveDepth(r) + (side * 0.1f).coerceIn(4.dp.toPx(), 11.dp.toPx()) + val held = rest + min(37.dp.toPx(), side * 0.35f) + val e = rest + (held - rest) * lift + val c = w + h - e + val region = cornerRegion(w, h, r, c) + if (region.size < 3) return + val mid = Offset(w - e / 2, h - e / 2) + // 1. The second layer, turning the accent colour as the hold completes. + drawPath(pathOf(region), lerp(lerp(face, Color.Black, 0.26f), accent, lift)) + // The lifted edge of the top layer shades the second layer just beside it. + drawPath( + pathOf(region), + Brush.linearGradient( + listOf(Color.Black.copy(alpha = 0.35f), Color.Transparent), + start = mid, + end = mid + Offset(e * 0.25f, e * 0.25f), + ), + ) + // 2. "More" waits on the second layer, readable once the peel is well back. + if (lift > 0.35f && side > 80.dp.toPx()) { + val a = ((lift - 0.35f) / 0.65f).coerceIn(0f, 1f) + val centre = Offset(w - e * 0.3f, h - e * 0.3f) + val gap = 5.5.dp.toPx() + for (k in -1..1) { + drawCircle(onAccent.copy(alpha = a), radius = 2.2.dp.toPx(), center = centre + Offset(k * gap * 0.71f, -k * gap * 0.71f)) + } + } + // 3. The flap: the same corner folded back over the face, its rounded tip and all. + val flap = region.map { Offset(c - it.y, c - it.x) } + translate(-1.5.dp.toPx(), -1.5.dp.toPx()) { + drawPath(pathOf(flap), Color.Black.copy(alpha = 0.30f)) + } + drawPath( + pathOf(flap), + Brush.linearGradient( + listOf(lerp(face, Color.White, 0.30f), lerp(face, Color.White, 0.08f)), + start = Offset(c - h, c - w), + end = mid, + ), + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt index ba351ce..e16aeab 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt @@ -59,6 +59,11 @@ data class ControlLook( val pressedDim: Float = 0.22f, /** Scale while pressed, 1 for none. */ val pressedScale: Float = 0.95f, + /** + * How a control with a press-and-hold action says so. The peel needs a raised face to lift + * from; a flat look takes the dot. + */ + val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL, /** Replaces this look when the UI is light, for effects that only work on one background. */ val light: ControlLook? = null, ) @@ -67,7 +72,7 @@ object GsThemes { val Keycap = GsTheme( id = "keycap", name = "Keycap", - control = ControlLook(lip = 4.dp, pressedScale = 1f), + control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL), ) val Tactile = GsTheme( @@ -83,6 +88,7 @@ object GsThemes { insetAlpha = 0.30f, pressedDim = 0.12f, pressedScale = 1f, + holdMarker = HoldMarkerStyle.PEEL, // A wide soft shadow bands once the car's video stream compresses it on a light // background, so light mode gets a tight one and leans on the bevel instead. light = ControlLook( @@ -95,6 +101,7 @@ object GsThemes { insetAlpha = 0.30f, pressedDim = 0.12f, pressedScale = 1f, + holdMarker = HoldMarkerStyle.PEEL, ), ), ) @@ -102,7 +109,7 @@ object GsThemes { val Flat = GsTheme( id = "flat", name = "Flat", - control = ControlLook(), + control = ControlLook(holdMarker = HoldMarkerStyle.DOT), ) val default = Tactile diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt index 005124a..79caa60 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt @@ -65,10 +65,15 @@ object KnownApps { fun isBroken(packageName: String): Boolean = packageName in broken - /** Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to call working. */ + /** + * Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to + * call working. Wins over [works] and [playerWorks] for the badge. + */ private val partial = setOf( "app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet "com.mapquest.android.ace", // MapQuest: map, search and routing run, but not cleanly + "com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first + "deezer.android.app", // Deezer: the same - it has to be opened on the phone first ) fun isPartial(packageName: String): Boolean = packageName in partial