From 8602db88de40e82b2d960bf40112bebc207221ee Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Thu, 30 Jul 2026 14:39:29 +0200 Subject: [PATCH] Add geohash globe render quality selector --- .../android/ui/GeohashPickerActivity.kt | 60 ++++++++++-- .../android/ui/globe/GlobeRenderQuality.kt | 33 +++++++ .../com/bitchat/android/ui/globe/GlobeView.kt | 92 +++++++++++++++---- app/src/main/res/values/strings.xml | 3 + .../ui/globe/GlobeRenderQualityTest.kt | 63 +++++++++++++ 5 files changed, 228 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/globe/GlobeRenderQuality.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index ce450ded..3d1d2736 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -15,6 +15,9 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon 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.Text import androidx.compose.runtime.* @@ -34,6 +37,8 @@ import com.bitchat.android.geohash.Geohash import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager 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.GlobeView import com.bitchat.android.ui.globe.LandData @@ -152,6 +157,9 @@ class GeohashPickerActivity : OrientationAwareActivity() { val labelTypeface = remember { ResourcesCompat.getFont(context, R.font.geist_mono_medium) } val labelTypefaceBold = remember { ResourcesCompat.getFont(context, R.font.geist_mono_semibold) } + var renderQuality by remember { + mutableStateOf(GlobeRenderQualityPreference.load(context)) + } Box( Modifier @@ -165,6 +173,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { land = rings, borders = borders, cities = cities, + renderQuality = renderQuality, labelTypeface = labelTypeface, labelTypefaceBold = labelTypefaceBold, modifier = Modifier.fillMaxSize() @@ -183,15 +192,45 @@ class GeohashPickerActivity : OrientationAwareActivity() { tonalElevation = 3.dp, shadowElevation = 6.dp ) { - Text( - text = stringResource(R.string.pan_zoom_instruction), - fontSize = 12.sp, - textAlign = TextAlign.Center, - fontFamily = BitchatFontFamily, - color = MaterialTheme.colorScheme.onSurface, + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .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 -> + SegmentedButton( + selected = renderQuality == quality, + onClick = { + renderQuality = quality + GlobeRenderQualityPreference.save(context, quality) + }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = qualities.size + ), + label = { + Text( + text = stringResource(quality.labelResource), + fontSize = 11.sp, + fontFamily = BitchatFontFamily + ) + } + ) + } + } + } } // Floating bottom controls @@ -290,6 +329,13 @@ 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 { return when (length) { in 0..2 -> GeohashChannelLevel.REGION diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeRenderQuality.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeRenderQuality.kt new file mode 100644 index 00000000..7a2ba578 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeRenderQuality.kt @@ -0,0 +1,33 @@ +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() + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt index 619973be..e11e163a 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt @@ -64,6 +64,54 @@ data class GlobeColors( private class Star(val x: Float, val y: Float, val radius: Float, val alpha: Float) +internal data class GlobeFrameDetail( + val graticuleStepDegrees: Double, + val landPointStride: Int, + val showBorders: Boolean, + val cityMaxRank: Int?, + val showCityLabels: Boolean, + val showGeohashGrid: Boolean, + val showNeighborCells: Boolean +) + +internal fun globeFrameDetail( + quality: GlobeRenderQuality, + isMoving: Boolean +): GlobeFrameDetail { + if (!isMoving || quality == GlobeRenderQuality.HIGH) { + return GlobeFrameDetail( + graticuleStepDegrees = 4.0, + landPointStride = 1, + showBorders = true, + cityMaxRank = null, + showCityLabels = true, + showGeohashGrid = true, + showNeighborCells = true + ) + } + 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, + landPointStride = 2, + showBorders = true, + cityMaxRank = 1, + showCityLabels = false, + showGeohashGrid = true, + showNeighborCells = false + ) + GlobeRenderQuality.HIGH -> error("Handled above") + } +} + @Composable fun GlobeView( state: GlobeState, @@ -71,6 +119,7 @@ fun GlobeView( land: List, borders: List, cities: List, + renderQuality: GlobeRenderQuality, labelTypeface: Typeface?, labelTypefaceBold: Typeface?, modifier: Modifier = Modifier @@ -275,12 +324,12 @@ fun GlobeView( val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f) - val lowDetail = state.isInMotion + val frameDetail = globeFrameDetail(renderQuality, state.isInMotion) // Graticule drawGraticule( cx, cy, r, cLat, cLon, colors.graticule, clip, - step = if (lowDetail) 10.0 else 4.0 + step = frameDetail.graticuleStepDegrees ) // Landmasses @@ -294,12 +343,12 @@ fun GlobeView( r = r, colors = colors, clip = clip, - pointStride = if (lowDetail && ring.size >= 64) 2 else 1 + pointStride = if (ring.size >= 64) frameDetail.landPointStride else 1 ) } // Country borders are restored when interaction settles. - if (!lowDetail) { + if (frameDetail.showBorders) { for (line in borders) { drawBorderLine(line, borderScratch, preparedProjector, cx, cy, r, colors, clip) } @@ -331,20 +380,27 @@ fun GlobeView( ) // Cities are detail-only; omitting them while moving keeps touch latency predictable. - if (!lowDetail) { + val cityMaxRank = frameDetail.cityMaxRank + if (cityMaxRank == null || cityMaxRank >= 0) { drawCities( cities, state, preparedProjector, cx, cy, r, colors, - labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density + labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density, + maxRankOverride = cityMaxRank, + showLabels = frameDetail.showCityLabels ) } // Detailed cells and labels settle into place after the gesture ends. - if (!lowDetail && state.selectedGeohash.isNotEmpty()) { - drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip) + if (frameDetail.showGeohashGrid && state.selectedGeohash.isNotEmpty()) { + drawGeohashGrid( + state, cx, cy, r, cLat, cLon, colors, clip, + includeNeighbors = frameDetail.showNeighborCells + ) drawGeohashLabels( state, cx, cy, r, cLat, cLon, colors, labelPaint, haloPaint, labelTypeface, labelTypefaceBold, - labelTextSize, labelTextSizeSmall + labelTextSize, labelTextSizeSmall, + includeNeighbors = frameDetail.showNeighborCells ) } @@ -697,12 +753,14 @@ private fun DrawScope.drawCities( haloPaint: Paint, typeface: Typeface?, textSize: Float, - density: Float + density: Float, + maxRankOverride: Int?, + showLabels: Boolean ) { if (cities.isEmpty()) return val projection = FloatArray(3) val zoom = state.zoom - val maxRank = when { + val maxRank = maxRankOverride ?: when { zoom < 2f -> 1 zoom < 8f -> 3 zoom < 40f -> 4 @@ -725,7 +783,7 @@ private fun DrawScope.drawCities( else colors.label.copy(alpha = alpha * 0.85f) drawCircle(dotColor, radius = dotRadius, center = Offset(sx, sy)) - if (zoom >= 6f || (important && zoom >= 2.5f)) { + if (showLabels && (zoom >= 6f || (important && zoom >= 2.5f))) { labelPaint.textSize = textSize labelPaint.typeface = typeface labelPaint.textAlign = Paint.Align.LEFT @@ -789,11 +847,12 @@ private fun DrawScope.drawGeohashGrid( cx: Float, cy: Float, r: Float, cLat: Double, cLon: Double, colors: GlobeColors, - clip: ClipRect + clip: ClipRect, + includeNeighbors: Boolean ) { val selected = state.selectedGeohash val cells = linkedSetOf(selected) - cells.addAll(Geohash.neighborsSamePrecision(selected)) + if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected)) for (cell in cells) { val isSelected = cell == selected @@ -856,11 +915,12 @@ private fun DrawScope.drawGeohashLabels( labelTypeface: Typeface?, labelTypefaceBold: Typeface?, selectedSize: Float, - neighborSize: Float + neighborSize: Float, + includeNeighbors: Boolean ) { val selected = state.selectedGeohash val cells = linkedSetOf(selected) - cells.addAll(Geohash.neighborsSamePrecision(selected)) + if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected)) val canvas = drawContext.canvas.nativeCanvas for (cell in cells) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88c495c1..52388349 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -540,6 +540,9 @@ Nobody around… (you) Drag to spin · Pinch to zoom · Tap to focus + Fast + Medium + High Select Type a message… @%1$s diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt new file mode 100644 index 00000000..3712970f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt @@ -0,0 +1,63 @@ +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 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) + } +}