mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
hold marker in the theme engine, status badges on app icons
Any control with a press-and-hold action now shows it: a corner peel on Tactile and Keycap that opens while held, a dot on Flat. Working/partial/broken badges moved onto the app icon. Spotify and Deezer are marked partial since they need opening on the phone.
This commit is contained in:
1 parent
024850bf48
commit
d3592457cf
6 files changed
+254
-26
No files matched your search
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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<Offset> {
|
||||
// Walk the bottom-right boundary: down the right edge, round the corner, along the bottom.
|
||||
val reach = (h + w - c) + 1f
|
||||
val edge = ArrayList<Offset>()
|
||||
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<Offset>()
|
||||
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<Offset>): 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user