mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge pull request #836 from permissionlesstech/codex/restore-globe-picker-ux
Restore globe picker UX and teleport selection
This commit is contained in:
commit
13f15ca1ef
@ -15,9 +15,6 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SegmentedButton
|
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@ -37,8 +34,6 @@ import com.bitchat.android.geohash.Geohash
|
|||||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||||
import com.bitchat.android.geohash.LocationChannelManager
|
import com.bitchat.android.geohash.LocationChannelManager
|
||||||
import com.bitchat.android.ui.globe.GlobeColors
|
import com.bitchat.android.ui.globe.GlobeColors
|
||||||
import com.bitchat.android.ui.globe.GlobeRenderQuality
|
|
||||||
import com.bitchat.android.ui.globe.GlobeRenderQualityPreference
|
|
||||||
import com.bitchat.android.ui.globe.GlobeState
|
import com.bitchat.android.ui.globe.GlobeState
|
||||||
import com.bitchat.android.ui.globe.GlobeView
|
import com.bitchat.android.ui.globe.GlobeView
|
||||||
import com.bitchat.android.ui.globe.LandData
|
import com.bitchat.android.ui.globe.LandData
|
||||||
@ -95,18 +90,13 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
|||||||
BitchatTheme {
|
BitchatTheme {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val initialRenderQuality = remember(context) {
|
|
||||||
GlobeRenderQualityPreference.load(context)
|
|
||||||
}
|
|
||||||
var renderQuality by remember { mutableStateOf(initialRenderQuality) }
|
|
||||||
|
|
||||||
val globeState = remember {
|
val globeState = remember {
|
||||||
GlobeState(
|
GlobeState(
|
||||||
targetLat = targetLat,
|
targetLat = targetLat,
|
||||||
targetLon = targetLon,
|
targetLon = targetLon,
|
||||||
initialPrecision = initialPrecision,
|
initialPrecision = initialPrecision,
|
||||||
startZoomedOut = true,
|
startZoomedOut = true
|
||||||
initialRenderQuality = initialRenderQuality
|
|
||||||
).apply {
|
).apply {
|
||||||
introTarget = Triple(targetLat, targetLon, initialPrecision)
|
introTarget = Triple(targetLat, targetLon, initialPrecision)
|
||||||
}
|
}
|
||||||
@ -193,62 +183,15 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
|||||||
tonalElevation = 3.dp,
|
tonalElevation = 3.dp,
|
||||||
shadowElevation = 6.dp
|
shadowElevation = 6.dp
|
||||||
) {
|
) {
|
||||||
Column(
|
Text(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
text = stringResource(R.string.pan_zoom_instruction),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontFamily = BitchatFontFamily,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||||
) {
|
)
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.pan_zoom_instruction),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = BitchatFontFamily,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
val qualities = GlobeRenderQuality.entries
|
|
||||||
SingleChoiceSegmentedButtonRow(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
qualities.forEachIndexed { index, quality ->
|
|
||||||
val selected = renderQuality == quality
|
|
||||||
SegmentedButton(
|
|
||||||
selected = selected,
|
|
||||||
onClick = {
|
|
||||||
globeState.setRenderQuality(quality)
|
|
||||||
renderQuality = quality
|
|
||||||
GlobeRenderQualityPreference.save(context, quality)
|
|
||||||
},
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(
|
|
||||||
index = index,
|
|
||||||
count = qualities.size
|
|
||||||
),
|
|
||||||
icon = {},
|
|
||||||
label = {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (selected) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterStart)
|
|
||||||
.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(quality.labelResource),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontFamily = BitchatFontFamily
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating bottom controls
|
// Floating bottom controls
|
||||||
@ -347,13 +290,6 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val GlobeRenderQuality.labelResource: Int
|
|
||||||
get() = when (this) {
|
|
||||||
GlobeRenderQuality.FAST -> R.string.globe_render_quality_fast
|
|
||||||
GlobeRenderQuality.MEDIUM -> R.string.globe_render_quality_medium
|
|
||||||
GlobeRenderQuality.HIGH -> R.string.globe_render_quality_high
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun levelForLength(length: Int): GeohashChannelLevel {
|
private fun levelForLength(length: Int): GeohashChannelLevel {
|
||||||
return when (length) {
|
return when (length) {
|
||||||
in 0..2 -> GeohashChannelLevel.REGION
|
in 0..2 -> GeohashChannelLevel.REGION
|
||||||
|
|||||||
@ -137,6 +137,18 @@ fun LocationChannelsSheet(
|
|||||||
var customGeohash by remember { mutableStateOf("") }
|
var customGeohash by remember { mutableStateOf("") }
|
||||||
var customError by remember { mutableStateOf<String?>(null) }
|
var customError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val teleportToGeohash: (String) -> Unit = { value ->
|
||||||
|
val channel = channelForManualGeohash(value)
|
||||||
|
if (channel != null) {
|
||||||
|
customError = null
|
||||||
|
locationManager.selectManual(channel)
|
||||||
|
onDismiss()
|
||||||
|
} else {
|
||||||
|
customGeohash = value.trim().lowercase().replace("#", "")
|
||||||
|
customError = context.getString(R.string.invalid_geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
@ -158,8 +170,7 @@ fun LocationChannelsSheet(
|
|||||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||||
val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
|
val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
|
||||||
if (!gh.isNullOrBlank()) {
|
if (!gh.isNullOrBlank()) {
|
||||||
customGeohash = gh
|
teleportToGeohash(gh)
|
||||||
customError = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -503,15 +514,7 @@ fun LocationChannelsSheet(
|
|||||||
mapPickerLauncher.launch(intent)
|
mapPickerLauncher.launch(intent)
|
||||||
},
|
},
|
||||||
onTeleport = {
|
onTeleport = {
|
||||||
val normalized = customGeohash.trim().lowercase().replace("#", "")
|
teleportToGeohash(customGeohash)
|
||||||
if (validateGeohash(normalized)) {
|
|
||||||
val level = levelForLength(normalized.length)
|
|
||||||
val channel = GeohashChannel(level = level, geohash = normalized)
|
|
||||||
locationManager.selectManual(channel)
|
|
||||||
onDismiss()
|
|
||||||
} else {
|
|
||||||
customError = context.getString(R.string.invalid_geohash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1173,6 +1176,15 @@ private fun validateGeohash(geohash: String): Boolean {
|
|||||||
return geohash.all { it in allowed }
|
return geohash.all { it in allowed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun channelForManualGeohash(value: String): GeohashChannel? {
|
||||||
|
val normalized = value.trim().lowercase().replace("#", "")
|
||||||
|
if (!validateGeohash(normalized)) return null
|
||||||
|
return GeohashChannel(
|
||||||
|
level = levelForLength(normalized.length),
|
||||||
|
geohash = normalized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun levelForLength(length: Int): GeohashChannelLevel {
|
private fun levelForLength(length: Int): GeohashChannelLevel {
|
||||||
return when (length) {
|
return when (length) {
|
||||||
in 0..2 -> GeohashChannelLevel.REGION
|
in 0..2 -> GeohashChannelLevel.REGION
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
package com.bitchat.android.ui.globe
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
|
|
||||||
enum class GlobeRenderQuality {
|
|
||||||
FAST,
|
|
||||||
MEDIUM,
|
|
||||||
HIGH;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromStoredValue(value: String?): GlobeRenderQuality =
|
|
||||||
entries.firstOrNull { it.name == value } ?: MEDIUM
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object GlobeRenderQualityPreference {
|
|
||||||
private const val PREFERENCES_NAME = "bitchat_settings"
|
|
||||||
private const val KEY_RENDER_QUALITY = "geohash_globe_render_quality"
|
|
||||||
|
|
||||||
fun load(context: Context): GlobeRenderQuality {
|
|
||||||
val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
|
||||||
return GlobeRenderQuality.fromStoredValue(
|
|
||||||
preferences.getString(KEY_RENDER_QUALITY, GlobeRenderQuality.MEDIUM.name)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun save(context: Context, quality: GlobeRenderQuality) {
|
|
||||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
|
||||||
.edit()
|
|
||||||
.putString(KEY_RENDER_QUALITY, quality.name)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -28,8 +28,7 @@ class GlobeState(
|
|||||||
targetLat: Double,
|
targetLat: Double,
|
||||||
targetLon: Double,
|
targetLon: Double,
|
||||||
initialPrecision: Int,
|
initialPrecision: Int,
|
||||||
startZoomedOut: Boolean,
|
startZoomedOut: Boolean
|
||||||
initialRenderQuality: GlobeRenderQuality = GlobeRenderQuality.MEDIUM
|
|
||||||
) {
|
) {
|
||||||
var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat())
|
var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat())
|
||||||
private set
|
private set
|
||||||
@ -48,8 +47,6 @@ class GlobeState(
|
|||||||
|
|
||||||
internal var baseRadiusPx by mutableFloatStateOf(0f)
|
internal var baseRadiusPx by mutableFloatStateOf(0f)
|
||||||
internal var screenMinPx by mutableFloatStateOf(0f)
|
internal var screenMinPx by mutableFloatStateOf(0f)
|
||||||
internal var renderQuality = initialRenderQuality
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var scope: CoroutineScope? = null
|
private var scope: CoroutineScope? = null
|
||||||
private var animJob: Job? = null
|
private var animJob: Job? = null
|
||||||
@ -71,14 +68,6 @@ class GlobeState(
|
|||||||
this.scope = scope
|
this.scope = scope
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rendering quality only affects moving frames. This is deliberately not snapshot state:
|
|
||||||
* changing it must not invalidate the expensive stationary globe beneath the selector.
|
|
||||||
*/
|
|
||||||
fun setRenderQuality(quality: GlobeRenderQuality) {
|
|
||||||
renderQuality = quality
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setViewport(baseRadiusPx: Float, screenMinPx: Float) {
|
fun setViewport(baseRadiusPx: Float, screenMinPx: Float) {
|
||||||
if (baseRadiusPx <= 0f || screenMinPx <= 0f) return
|
if (baseRadiusPx <= 0f || screenMinPx <= 0f) return
|
||||||
this.baseRadiusPx = baseRadiusPx
|
this.baseRadiusPx = baseRadiusPx
|
||||||
|
|||||||
@ -18,8 +18,11 @@ import androidx.compose.foundation.gestures.calculateZoom
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
@ -39,10 +42,7 @@ import androidx.compose.ui.platform.LocalView
|
|||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.geohash.Geohash
|
import com.bitchat.android.geohash.Geohash
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@ -76,12 +76,15 @@ internal data class GlobeFrameDetail(
|
|||||||
val showNeighborCells: Boolean
|
val showNeighborCells: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun globeFrameDetail(
|
internal enum class GlobeMotionDetail {
|
||||||
quality: GlobeRenderQuality,
|
FULL,
|
||||||
isMoving: Boolean
|
BALANCED,
|
||||||
): GlobeFrameDetail {
|
FAST
|
||||||
if (!isMoving || quality == GlobeRenderQuality.HIGH) {
|
}
|
||||||
return GlobeFrameDetail(
|
|
||||||
|
internal fun globeFrameDetail(motionDetail: GlobeMotionDetail): GlobeFrameDetail =
|
||||||
|
when (motionDetail) {
|
||||||
|
GlobeMotionDetail.FULL -> GlobeFrameDetail(
|
||||||
graticuleStepDegrees = 4.0,
|
graticuleStepDegrees = 4.0,
|
||||||
landPointStride = 1,
|
landPointStride = 1,
|
||||||
showBorders = true,
|
showBorders = true,
|
||||||
@ -90,18 +93,7 @@ internal fun globeFrameDetail(
|
|||||||
showGeohashGrid = true,
|
showGeohashGrid = true,
|
||||||
showNeighborCells = true
|
showNeighborCells = true
|
||||||
)
|
)
|
||||||
}
|
GlobeMotionDetail.BALANCED -> GlobeFrameDetail(
|
||||||
return when (quality) {
|
|
||||||
GlobeRenderQuality.FAST -> GlobeFrameDetail(
|
|
||||||
graticuleStepDegrees = 10.0,
|
|
||||||
landPointStride = 2,
|
|
||||||
showBorders = false,
|
|
||||||
cityMaxRank = -1,
|
|
||||||
showCityLabels = false,
|
|
||||||
showGeohashGrid = false,
|
|
||||||
showNeighborCells = false
|
|
||||||
)
|
|
||||||
GlobeRenderQuality.MEDIUM -> GlobeFrameDetail(
|
|
||||||
graticuleStepDegrees = 8.0,
|
graticuleStepDegrees = 8.0,
|
||||||
landPointStride = 2,
|
landPointStride = 2,
|
||||||
showBorders = true,
|
showBorders = true,
|
||||||
@ -110,7 +102,44 @@ internal fun globeFrameDetail(
|
|||||||
showGeohashGrid = true,
|
showGeohashGrid = true,
|
||||||
showNeighborCells = false
|
showNeighborCells = false
|
||||||
)
|
)
|
||||||
GlobeRenderQuality.HIGH -> error("Handled above")
|
GlobeMotionDetail.FAST -> GlobeFrameDetail(
|
||||||
|
graticuleStepDegrees = 10.0,
|
||||||
|
landPointStride = 2,
|
||||||
|
showBorders = false,
|
||||||
|
cityMaxRank = -1,
|
||||||
|
showCityLabels = false,
|
||||||
|
showGeohashGrid = false,
|
||||||
|
showNeighborCells = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjusts moving-frame detail from measured frame cadence. Hysteresis keeps the renderer
|
||||||
|
* from oscillating between levels when timings sit near a boundary.
|
||||||
|
*/
|
||||||
|
internal fun nextGlobeMotionDetail(
|
||||||
|
current: GlobeMotionDetail,
|
||||||
|
averageFrameMillis: Float
|
||||||
|
): GlobeMotionDetail {
|
||||||
|
if (!averageFrameMillis.isFinite()) return GlobeMotionDetail.FAST
|
||||||
|
return when (current) {
|
||||||
|
GlobeMotionDetail.FULL -> when {
|
||||||
|
averageFrameMillis > SEVERELY_SLOW_FRAME_MILLIS -> GlobeMotionDetail.FAST
|
||||||
|
averageFrameMillis > SLOW_FRAME_MILLIS -> GlobeMotionDetail.BALANCED
|
||||||
|
else -> GlobeMotionDetail.FULL
|
||||||
|
}
|
||||||
|
GlobeMotionDetail.BALANCED -> when {
|
||||||
|
averageFrameMillis > VERY_SLOW_FRAME_MILLIS -> GlobeMotionDetail.FAST
|
||||||
|
averageFrameMillis < SMOOTH_FRAME_MILLIS -> GlobeMotionDetail.FULL
|
||||||
|
else -> GlobeMotionDetail.BALANCED
|
||||||
|
}
|
||||||
|
GlobeMotionDetail.FAST -> {
|
||||||
|
if (averageFrameMillis < RECOVERING_FRAME_MILLIS) {
|
||||||
|
GlobeMotionDetail.BALANCED
|
||||||
|
} else {
|
||||||
|
GlobeMotionDetail.FAST
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,19 +198,48 @@ fun GlobeView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(state) {
|
LaunchedEffect(state) {
|
||||||
snapshotFlow { state.selectedGeohash to state.isInMotion }
|
snapshotFlow { state.selectedGeohash }
|
||||||
.distinctUntilChanged()
|
|
||||||
.drop(1)
|
.drop(1)
|
||||||
.collectLatest { (geohash, inMotion) ->
|
.collect {
|
||||||
if (geohash.isNotEmpty() && !inMotion) {
|
@Suppress("DEPRECATION")
|
||||||
delay(SETTLED_HAPTIC_DELAY_MS)
|
view.performHapticFeedback(
|
||||||
if (!state.isInMotion && state.selectedGeohash == geohash) {
|
HapticFeedbackConstants.KEYBOARD_TAP,
|
||||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
|
||||||
}
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var motionDetail by remember(state) { mutableStateOf(GlobeMotionDetail.BALANCED) }
|
||||||
|
LaunchedEffect(state, state.isInMotion) {
|
||||||
|
if (!state.isInMotion) {
|
||||||
|
motionDetail = GlobeMotionDetail.FULL
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balanced is a safe first frame; measured cadence then moves detail up or down.
|
||||||
|
motionDetail = GlobeMotionDetail.BALANCED
|
||||||
|
var previousFrameNanos = withFrameNanos { it }
|
||||||
|
var sampledFrameMillis = 0f
|
||||||
|
var sampledFrameCount = 0
|
||||||
|
while (state.isInMotion) {
|
||||||
|
val frameNanos = withFrameNanos { it }
|
||||||
|
val frameMillis = ((frameNanos - previousFrameNanos) / 1_000_000f)
|
||||||
|
.coerceIn(1f, MAX_SAMPLED_FRAME_MILLIS)
|
||||||
|
previousFrameNanos = frameNanos
|
||||||
|
sampledFrameMillis += frameMillis
|
||||||
|
sampledFrameCount++
|
||||||
|
|
||||||
|
if (sampledFrameCount >= FRAME_SAMPLE_COUNT) {
|
||||||
|
motionDetail = nextGlobeMotionDetail(
|
||||||
|
current = motionDetail,
|
||||||
|
averageFrameMillis = sampledFrameMillis / sampledFrameCount
|
||||||
|
)
|
||||||
|
sampledFrameMillis = 0f
|
||||||
|
sampledFrameCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val labelTextSize = with(density) { 12.5.sp.toPx() }
|
val labelTextSize = with(density) { 12.5.sp.toPx() }
|
||||||
val labelTextSizeSmall = with(density) { 10.sp.toPx() }
|
val labelTextSizeSmall = with(density) { 10.sp.toPx() }
|
||||||
|
|
||||||
@ -325,7 +383,9 @@ fun GlobeView(
|
|||||||
|
|
||||||
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
|
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
|
||||||
|
|
||||||
val frameDetail = globeFrameDetail(state.renderQuality, state.isInMotion)
|
val frameDetail = globeFrameDetail(
|
||||||
|
if (state.isInMotion) motionDetail else GlobeMotionDetail.FULL
|
||||||
|
)
|
||||||
|
|
||||||
// Graticule
|
// Graticule
|
||||||
drawGraticule(
|
drawGraticule(
|
||||||
@ -433,7 +493,13 @@ fun GlobeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SETTLED_HAPTIC_DELAY_MS = 80L
|
private const val FRAME_SAMPLE_COUNT = 8
|
||||||
|
private const val MAX_SAMPLED_FRAME_MILLIS = 50f
|
||||||
|
private const val SMOOTH_FRAME_MILLIS = 18.5f
|
||||||
|
private const val SLOW_FRAME_MILLIS = 22f
|
||||||
|
private const val RECOVERING_FRAME_MILLIS = 22f
|
||||||
|
private const val VERY_SLOW_FRAME_MILLIS = 29f
|
||||||
|
private const val SEVERELY_SLOW_FRAME_MILLIS = 34f
|
||||||
|
|
||||||
private fun DrawScope.drawGraticule(
|
private fun DrawScope.drawGraticule(
|
||||||
cx: Float,
|
cx: Float,
|
||||||
|
|||||||
@ -540,9 +540,6 @@
|
|||||||
<string name="nobody_around">Nobody around…</string>
|
<string name="nobody_around">Nobody around…</string>
|
||||||
<string name="you_suffix"> (you)</string>
|
<string name="you_suffix"> (you)</string>
|
||||||
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
|
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
|
||||||
<string name="globe_render_quality_fast">Fast</string>
|
|
||||||
<string name="globe_render_quality_medium">Medium</string>
|
|
||||||
<string name="globe_render_quality_high">High</string>
|
|
||||||
<string name="select">Select</string>
|
<string name="select">Select</string>
|
||||||
<string name="type_a_message_placeholder">Type a message…</string>
|
<string name="type_a_message_placeholder">Type a message…</string>
|
||||||
<string name="mention_suggestion_at">@%1$s</string>
|
<string name="mention_suggestion_at">@%1$s</string>
|
||||||
|
|||||||
@ -42,6 +42,22 @@ class LocationChannelsSheetTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `globe result resolves to a manual teleport channel`() {
|
||||||
|
assertEquals(
|
||||||
|
GeohashChannel(
|
||||||
|
level = GeohashChannelLevel.CITY,
|
||||||
|
geohash = "u33dc"
|
||||||
|
),
|
||||||
|
channelForManualGeohash(" #U33DC ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid globe result cannot create a teleport channel`() {
|
||||||
|
assertNull(channelForManualGeohash("not-a-geohash"))
|
||||||
|
}
|
||||||
|
|
||||||
private fun channel(geohash: String) = GeohashChannel(
|
private fun channel(geohash: String) = GeohashChannel(
|
||||||
level = GeohashChannelLevel.CITY,
|
level = GeohashChannelLevel.CITY,
|
||||||
geohash = geohash
|
geohash = geohash
|
||||||
|
|||||||
@ -0,0 +1,65 @@
|
|||||||
|
package com.bitchat.android.ui.globe
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class GlobeInteractionPolicyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullDetail_preservesAllGlobeFeatures() {
|
||||||
|
val detail = globeFrameDetail(GlobeMotionDetail.FULL)
|
||||||
|
|
||||||
|
assertEquals(1, detail.landPointStride)
|
||||||
|
assertTrue(detail.showBorders)
|
||||||
|
assertNull(detail.cityMaxRank)
|
||||||
|
assertTrue(detail.showCityLabels)
|
||||||
|
assertTrue(detail.showGeohashGrid)
|
||||||
|
assertTrue(detail.showNeighborCells)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun balancedDetail_preservesOrientationAndSelection() {
|
||||||
|
val detail = globeFrameDetail(GlobeMotionDetail.BALANCED)
|
||||||
|
|
||||||
|
assertEquals(2, detail.landPointStride)
|
||||||
|
assertTrue(detail.showBorders)
|
||||||
|
assertEquals(1, detail.cityMaxRank)
|
||||||
|
assertFalse(detail.showCityLabels)
|
||||||
|
assertTrue(detail.showGeohashGrid)
|
||||||
|
assertFalse(detail.showNeighborCells)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fastDetail_usesMinimumMovingFrameWork() {
|
||||||
|
val detail = globeFrameDetail(GlobeMotionDetail.FAST)
|
||||||
|
|
||||||
|
assertEquals(2, detail.landPointStride)
|
||||||
|
assertFalse(detail.showBorders)
|
||||||
|
assertEquals(-1, detail.cityMaxRank)
|
||||||
|
assertFalse(detail.showCityLabels)
|
||||||
|
assertFalse(detail.showGeohashGrid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun adaptiveDetail_degradesOnSlowFramesAndRecoversWithHysteresis() {
|
||||||
|
assertEquals(
|
||||||
|
GlobeMotionDetail.BALANCED,
|
||||||
|
nextGlobeMotionDetail(GlobeMotionDetail.FULL, averageFrameMillis = 24f)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
GlobeMotionDetail.FAST,
|
||||||
|
nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 32f)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
GlobeMotionDetail.BALANCED,
|
||||||
|
nextGlobeMotionDetail(GlobeMotionDetail.FAST, averageFrameMillis = 17f)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
GlobeMotionDetail.FULL,
|
||||||
|
nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 17f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,78 +0,0 @@
|
|||||||
package com.bitchat.android.ui.globe
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class GlobeRenderQualityTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun invalidStoredValue_defaultsToMedium() {
|
|
||||||
assertEquals(GlobeRenderQuality.MEDIUM, GlobeRenderQuality.fromStoredValue(null))
|
|
||||||
assertEquals(GlobeRenderQuality.MEDIUM, GlobeRenderQuality.fromStoredValue("UNKNOWN"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun globeState_usesAndUpdatesRenderQualityWithoutReplacingState() {
|
|
||||||
val state = GlobeState(
|
|
||||||
targetLat = 0.0,
|
|
||||||
targetLon = 0.0,
|
|
||||||
initialPrecision = 2,
|
|
||||||
startZoomedOut = false,
|
|
||||||
initialRenderQuality = GlobeRenderQuality.FAST
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(GlobeRenderQuality.FAST, state.renderQuality)
|
|
||||||
state.setRenderQuality(GlobeRenderQuality.HIGH)
|
|
||||||
assertEquals(GlobeRenderQuality.HIGH, state.renderQuality)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun stationaryFrame_alwaysUsesFullDetail() {
|
|
||||||
GlobeRenderQuality.entries.forEach { quality ->
|
|
||||||
val detail = globeFrameDetail(quality, isMoving = false)
|
|
||||||
|
|
||||||
assertEquals(1, detail.landPointStride)
|
|
||||||
assertTrue(detail.showBorders)
|
|
||||||
assertNull(detail.cityMaxRank)
|
|
||||||
assertTrue(detail.showCityLabels)
|
|
||||||
assertTrue(detail.showGeohashGrid)
|
|
||||||
assertTrue(detail.showNeighborCells)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun movingFastFrame_usesMinimumDetail() {
|
|
||||||
val detail = globeFrameDetail(GlobeRenderQuality.FAST, isMoving = true)
|
|
||||||
|
|
||||||
assertEquals(2, detail.landPointStride)
|
|
||||||
assertFalse(detail.showBorders)
|
|
||||||
assertEquals(-1, detail.cityMaxRank)
|
|
||||||
assertFalse(detail.showGeohashGrid)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun movingMediumFrame_preservesOrientationAndSelection() {
|
|
||||||
val detail = globeFrameDetail(GlobeRenderQuality.MEDIUM, isMoving = true)
|
|
||||||
|
|
||||||
assertEquals(2, detail.landPointStride)
|
|
||||||
assertTrue(detail.showBorders)
|
|
||||||
assertEquals(1, detail.cityMaxRank)
|
|
||||||
assertFalse(detail.showCityLabels)
|
|
||||||
assertTrue(detail.showGeohashGrid)
|
|
||||||
assertFalse(detail.showNeighborCells)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun movingHighFrame_usesFullDetail() {
|
|
||||||
val detail = globeFrameDetail(GlobeRenderQuality.HIGH, isMoving = true)
|
|
||||||
|
|
||||||
assertEquals(1, detail.landPointStride)
|
|
||||||
assertTrue(detail.showBorders)
|
|
||||||
assertNull(detail.cityMaxRank)
|
|
||||||
assertTrue(detail.showCityLabels)
|
|
||||||
assertTrue(detail.showNeighborCells)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user