diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt index b16475d..108e292 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -5,6 +5,11 @@ import android.view.SurfaceHolder import android.view.SurfaceView import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.util.VelocityTracker +import kotlin.math.hypot import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -125,6 +130,30 @@ private fun SurfaceHost( if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y) if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom) } + } + .pointerInput(connection) { + // detectTransformGestures has no velocity, so a fling is measured here alongside + // it: one finger lifting while still moving lets the map glide on, as on a car. + // Velocity is the finger's own, which is GestureDetector's onFling convention. + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val tracker = VelocityTracker() + tracker.addPosition(down.uptimeMillis, down.position) + var pinched = false + while (true) { + val event = awaitPointerEvent(PointerEventPass.Final) + if (event.changes.size > 1) pinched = true + val change = event.changes.firstOrNull { it.id == down.id } ?: break + tracker.addPosition(change.uptimeMillis, change.position) + if (!change.pressed) { + val v = tracker.calculateVelocity() + if (!pinched && hypot(v.x, v.y) >= FLING_MIN_PX_S) { + connection.surfaceFling(v.x, v.y) + } + break + } + } + } }, factory = { context -> SurfaceView(context).apply { @@ -188,3 +217,6 @@ private fun ChromeBar( /** Height of [ChromeBar]; the template's own chrome starts below it. */ private val CHROME_BAR = 44.dp + +/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */ +private const val FLING_MIN_PX_S = 400f diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt index 1c20583..1e10665 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt @@ -2,6 +2,8 @@ package app.seb3thehacker.gearslip.car import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -246,7 +248,15 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { template.headerAction?.let { ActionButton(it) } - SearchField(query, template.searchHint.orEmpty(), Modifier.weight(1f)) + SearchField( + query, + template.searchHint.orEmpty(), + Modifier.weight(1f), + onClear = { + query = "" + delegate.textChanged("") + }, + ) ActionRow(template.actionStrip?.actions.orEmpty()) } @@ -266,22 +276,59 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) { } } +/** + * The field the on-screen keyboard types into. It is the same height and shape as the round + * buttons beside it, so the header reads as one row of controls rather than a thin strip squeezed + * between two big buttons, and it carries a magnifier so it reads as search at a glance. + * [onClear] adds a clear button once something is typed; [searchIcon] is off for sign-in fields. + */ @Composable -private fun SearchField(query: String, hint: String, modifier: Modifier = Modifier) { +private fun SearchField( + query: String, + hint: String, + modifier: Modifier = Modifier, + searchIcon: Boolean = true, + onClear: (() -> Unit)? = null, +) { + val muted = MaterialTheme.colorScheme.onSurfaceVariant Surface( color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = RoundedCornerShape(10.dp), - modifier = modifier.fillMaxWidth(), + shape = androidx.compose.foundation.shape.CircleShape, + modifier = modifier.fillMaxWidth().height(ICON_BUTTON_SIZE), ) { - Text( - query.ifEmpty { hint.ifEmpty { "Search" } }, - style = ChromeType.body, - color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - ) + Row( + Modifier.padding(start = if (searchIcon) 16.dp else 22.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (searchIcon) { + androidx.compose.material3.Icon( + androidx.compose.material.icons.Icons.Filled.Search, + contentDescription = null, + tint = muted, + modifier = Modifier.size(26.dp), + ) + Spacer(Modifier.width(12.dp)) + } + Text( + query.ifEmpty { hint.ifEmpty { "Search" } }, + style = MaterialTheme.typography.titleMedium, + color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (onClear != null && query.isNotEmpty()) { + GsIconButton( + androidx.compose.material.icons.Icons.Filled.Close, + "Clear", + onClear, + colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted), + size = 42.dp, + iconSize = 22.dp, + shape = androidx.compose.foundation.shape.CircleShape, + ) + } + } } } @@ -344,7 +391,7 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) { val masked = method.inputType == InputSignInMethod.INPUT_TYPE_PASSWORD Column(modifier) { - SearchField(if (masked) "•".repeat(value.length) else value, method.hint.text()) + SearchField(if (masked) "•".repeat(value.length) else value, method.hint.text(), searchIcon = false) val error = method.errorMessage.text() if (error.isNotEmpty()) { Text( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index c621949..0d07349 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -680,5 +680,5 @@ private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean { return seen > 0 } -private val ICON_BUTTON_SIZE = 54.dp +internal val ICON_BUTTON_SIZE = 54.dp private val ICON_BUTTON_GLYPH = 30.dp diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt index c67492e..5db0845 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt @@ -52,7 +52,7 @@ private val iconCache = android.util.LruCache(200) */ fun CarIcon?.image(context: Context): ImageBitmap? { val icon = this?.icon ?: return null - val key = icon.describe() + val key = icon.cacheKey(context) iconCache.get(key)?.let { return it } val drawable: Drawable = runCatching { icon.loadDrawable(context) } .onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") } @@ -77,6 +77,18 @@ fun CarIcon?.image(context: Context): ImageBitmap? { return bitmap } +/** + * [describe], plus when the app was last installed for a resource icon. Resource ids are only + * numbers inside one build of the app: an update that adds a drawable renumbers the ones after it, + * so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's + * new Saved button came up as its search glyph). + */ +private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String { + if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe() + val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L) + return "${describe()}@$installed" +} + /** Enough of an [androidx.core.graphics.drawable.IconCompat] to tell log lines apart without dumping the whole object. */ private fun androidx.core.graphics.drawable.IconCompat.describe(): String = when (type) { androidx.core.graphics.drawable.IconCompat.TYPE_URI,