Skip to content

Commit 211fc36

Browse files
committed
fix(ui): mirror the weekly chart and the log strip in right-to-left layouts
Canvas and drawBehind drawing doesn't follow the layout direction. On a phone set to Arabic or Hebrew the bars ran against their day labels, and the strip on grouped log rows stayed on the side its padding had left. The app has no Arabic strings of its own, but AndroidX's resources are enough to turn the layout right to left. The labels also spread evenly across the row while the bars sit in equal slots, which pulled the outer labels off their bars in every language. They share the bars' slots now.
1 parent 9752158 commit 211fc36

3 files changed

Lines changed: 168 additions & 5 deletions

File tree

‎app/src/main/java/com/sysadmindoc/callshield/ui/screens/main/BlockedLogScreen.kt‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import androidx.compose.ui.semantics.heading
3939
import androidx.compose.ui.semantics.role
4040
import androidx.compose.ui.semantics.semantics
4141
import androidx.compose.ui.text.font.FontWeight
42+
import androidx.compose.ui.unit.LayoutDirection
4243
import androidx.compose.ui.unit.dp
4344
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4445
import androidx.paging.LoadState
@@ -859,10 +860,13 @@ fun GroupedCallItem(
859860
Modifier
860861
.fillMaxWidth()
861862
.drawBehind {
863+
// Drawing doesn't mirror on its own, and the padding below does.
864+
val stripWidth = 3.dp.toPx()
865+
val stripStart = if (layoutDirection == LayoutDirection.Rtl) size.width - stripWidth else 0f
862866
drawRect(
863867
color = accentColor.copy(alpha = 0.5f),
864-
topLeft = Offset(0f, 0f),
865-
size = Size(3.dp.toPx(), size.height),
868+
topLeft = Offset(stripStart, 0f),
869+
size = Size(stripWidth, size.height),
866870
)
867871
}.padding(start = 14.dp, end = 12.dp, top = 12.dp, bottom = 12.dp),
868872
verticalAlignment = Alignment.CenterVertically,

‎app/src/main/java/com/sysadmindoc/callshield/ui/screens/stats/StatsScreen.kt‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource
2828
import androidx.compose.ui.semantics.heading
2929
import androidx.compose.ui.semantics.semantics
3030
import androidx.compose.ui.text.font.FontWeight
31+
import androidx.compose.ui.unit.LayoutDirection
3132
import androidx.compose.ui.unit.dp
3233
import androidx.compose.ui.unit.sp
3334
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -796,20 +797,22 @@ fun WeeklyBarChart(
796797
dailyCounts.forEachIndexed { index, (_, count) ->
797798
val barHeight = (count.toFloat() / maxCount) * (size.height - 30.dp.toPx())
798799
val x = index * (barWidth + gap) + gap / 2
800+
// The day labels below mirror with the layout, and drawing doesn't on its own.
801+
val barStart = if (layoutDirection == LayoutDirection.Rtl) size.width - x - barWidth else x
799802

800803
// Bar with rounded top
801804
drawRoundRect(
802805
color = barColor,
803-
topLeft = Offset(x, size.height - 30.dp.toPx() - barHeight),
806+
topLeft = Offset(barStart, size.height - 30.dp.toPx() - barHeight),
804807
size = Size(barWidth, barHeight),
805808
cornerRadius = CornerRadius(4.dp.toPx()),
806809
)
807810
}
808811
}
809812
// Day labels below
810-
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
813+
Row(modifier = Modifier.fillMaxWidth()) {
811814
dailyCounts.forEach { (day, count) ->
812-
Column(horizontalAlignment = Alignment.CenterHorizontally) {
815+
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
813816
Text(day, style = MaterialTheme.typography.labelSmall, color = CatSubtext)
814817
Text(count.toString(), style = MaterialTheme.typography.labelSmall, color = CatOverlay)
815818
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package com.sysadmindoc.callshield.ui
2+
3+
import android.graphics.Bitmap
4+
import android.graphics.Canvas
5+
import android.view.View
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.foundation.layout.Column
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.runtime.Composable
10+
import androidx.compose.runtime.CompositionLocalProvider
11+
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.graphics.PixelMap
13+
import androidx.compose.ui.graphics.asImageBitmap
14+
import androidx.compose.ui.graphics.toPixelMap
15+
import androidx.compose.ui.platform.LocalLayoutDirection
16+
import androidx.compose.ui.platform.LocalView
17+
import androidx.compose.ui.test.junit4.v2.createComposeRule
18+
import androidx.compose.ui.test.onNodeWithText
19+
import androidx.compose.ui.test.onRoot
20+
import androidx.compose.ui.unit.LayoutDirection
21+
import com.sysadmindoc.callshield.data.model.BlockedCall
22+
import com.sysadmindoc.callshield.ui.screens.main.GroupedCallItem
23+
import com.sysadmindoc.callshield.ui.screens.stats.RegionTileMap
24+
import com.sysadmindoc.callshield.ui.screens.stats.WeeklyBarChart
25+
import org.junit.Assert.assertEquals
26+
import org.junit.Assert.assertTrue
27+
import org.junit.Rule
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
import org.robolectric.RobolectricTestRunner
31+
import org.robolectric.annotation.Config
32+
import org.robolectric.annotation.GraphicsMode
33+
34+
/** Canvas drawing doesn't mirror with the layout, so these read the drawn pixels on both sides. */
35+
@RunWith(RobolectricTestRunner::class)
36+
@Config(sdk = [35], qualifiers = "w420dp-h900dp-xhdpi")
37+
@GraphicsMode(GraphicsMode.Mode.NATIVE)
38+
class RightToLeftDrawingTest {
39+
@get:Rule
40+
val composeRule = createComposeRule()
41+
42+
/** Draws straight to a bitmap, since captureToImage waits for a frame Robolectric never delivers. */
43+
private fun render(
44+
direction: LayoutDirection,
45+
content: @Composable () -> Unit,
46+
): PixelMap {
47+
lateinit var root: View
48+
composeRule.setContent {
49+
root = LocalView.current
50+
CompositionLocalProvider(LocalLayoutDirection provides direction, content = content)
51+
}
52+
return composeRule.runOnIdle {
53+
val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888)
54+
root.draw(Canvas(bitmap))
55+
bitmap.asImageBitmap().toPixelMap()
56+
}
57+
}
58+
59+
/** The mean column of the matching pixels, as a share of the width. */
60+
private fun PixelMap.centerOf(matches: (red: Float, green: Float, blue: Float) -> Boolean): Float {
61+
var columns = 0L
62+
var count = 0L
63+
for (y in 0 until height) {
64+
for (x in 0 until width) {
65+
val pixel = this[x, y]
66+
if (matches(pixel.red, pixel.green, pixel.blue)) {
67+
columns += x
68+
count++
69+
}
70+
}
71+
}
72+
assertTrue("nothing was drawn in the color this test looks for", count > 0)
73+
return columns.toFloat() / count / width
74+
}
75+
76+
/** The column with the most warm pixels, as a share of the width. A log row's strip runs its full height. */
77+
private fun PixelMap.tallestWarmColumn(): Float {
78+
val warmPixels = IntArray(width)
79+
for (y in 0 until height) {
80+
for (x in 0 until width) {
81+
val pixel = this[x, y]
82+
if (pixel.red - pixel.blue > 0.06f) warmPixels[x]++
83+
}
84+
}
85+
val tallest = warmPixels.indices.maxBy { warmPixels[it] }
86+
assertTrue("nothing warm was drawn", warmPixels[tallest] > 0)
87+
return tallest.toFloat() / width
88+
}
89+
90+
private fun renderChart(direction: LayoutDirection): Float =
91+
render(direction) {
92+
Column(Modifier.fillMaxWidth()) {
93+
WeeklyBarChart(listOf("Sun" to 10, "Mon" to 0, "Tue" to 0, "Wed" to 0))
94+
}
95+
}.centerOf { red, green, blue -> green - red > 0.2f && green - blue > 0.1f }
96+
97+
@Test
98+
fun `the first day's bar sits on the left in English`() {
99+
val center = renderChart(LayoutDirection.Ltr)
100+
101+
assertTrue("center at $center", center < 0.25f)
102+
}
103+
104+
@Test
105+
fun `the first day's bar sits on the right in Arabic, over its label`() {
106+
val center = renderChart(LayoutDirection.Rtl)
107+
108+
assertTrue("center at $center", center > 0.75f)
109+
}
110+
111+
@Test
112+
fun `a day's label sits centered under its bar`() {
113+
val bar = renderChart(LayoutDirection.Ltr)
114+
115+
val label = composeRule.onNodeWithText("Sun").fetchSemanticsNode().boundsInRoot.center.x
116+
val width = composeRule.onRoot().fetchSemanticsNode().size.width
117+
118+
assertEquals(bar, label / width, 0.01f)
119+
}
120+
121+
@Test
122+
fun `California stays on the west side of the map in Arabic`() {
123+
val center =
124+
render(LayoutDirection.Rtl) {
125+
Box(Modifier.fillMaxWidth()) { RegionTileMap(mapOf("CA" to 10)) }
126+
}.centerOf { red, _, blue -> red - blue > 0.2f }
127+
128+
assertTrue("center at $center", center < 0.25f)
129+
}
130+
131+
private fun renderRow(direction: LayoutDirection): Float =
132+
render(direction) {
133+
Box(Modifier.fillMaxWidth()) {
134+
GroupedCallItem(
135+
call = BlockedCall(number = "+14155550123", type = "robocall", matchReason = "database"),
136+
count = 1,
137+
onTap = {},
138+
onBlock = {},
139+
)
140+
}
141+
}.tallestWarmColumn()
142+
143+
@Test
144+
fun `a log row's accent strip is on the left in English`() {
145+
val column = renderRow(LayoutDirection.Ltr)
146+
147+
assertTrue("strip at $column", column < 0.05f)
148+
}
149+
150+
@Test
151+
fun `a log row's accent strip moves to the right in Arabic, where its padding went`() {
152+
val column = renderRow(LayoutDirection.Rtl)
153+
154+
assertTrue("strip at $column", column > 0.95f)
155+
}
156+
}

0 commit comments

Comments
 (0)