maps UI tweaks

This commit is contained in:
Seb3thehacker committed 2026-10-01 20:17:44 -07:00
1 parent 3994c9bd2e
commit a2d9ed7008
4 files changed
+107 -16

No files matched your search

@@ -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
@@ -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(
@@ -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
@@ -52,7 +52,7 @@ private val iconCache = android.util.LruCache<String, ImageBitmap>(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,