Skip to content

Commit 9752158

Browse files
committed
feat(stats): show spam by state and province on a tile map
Every state and province is one equal tile, so Rhode Island reads as easily as Texas, shaded in four steps of one hue from the area codes already in the log. It's worked out on the phone and sends nothing anywhere. West stays on the left in every language, since a map doesn't mirror. Numbers outside North America don't appear on it. The card stays hidden until there's a tile to shade.
1 parent 598d3db commit 9752158

8 files changed

Lines changed: 349 additions & 0 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ class MainViewModel
128128
const val DASHBOARD_RECENT_LOG_LIMIT = 50
129129
const val STATS_TOP_LIMIT = 10
130130
const val STATS_AREA_CODE_LIMIT = 15
131+
132+
// More than North America has, counting a +1 number and a bare one apart.
133+
const val STATS_MAP_AREA_CODE_LIMIT = 2_000
131134
const val EXCHANGE_SIGHTING_LIMIT = 2_000
132135
}
133136

@@ -203,6 +206,12 @@ class MainViewModel
203206
.observeLogAreaCodeCounts(STATS_AREA_CODE_LIMIT)
204207
.screenState(emptyList())
205208

209+
/** Every area code in the log, for the statistics map. The list under it shows only the busiest. */
210+
val logMapAreaCodeCounts: StateFlow<List<LogAggregate>> =
211+
repo
212+
.observeLogAreaCodeCounts(STATS_MAP_AREA_CODE_LIMIT)
213+
.screenState(emptyList())
214+
206215
/** Blocked North American numbers of the last 14 days, for the dashboard's exchange suggestion (slowCampaignExchanges). */
207216
val recentNanpSightings: StateFlow<List<NumberSighting>> =
208217
repo
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package com.sysadmindoc.callshield.ui.screens.stats
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Arrangement
5+
import androidx.compose.foundation.layout.Box
6+
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.Row
8+
import androidx.compose.foundation.layout.RowScope
9+
import androidx.compose.foundation.layout.Spacer
10+
import androidx.compose.foundation.layout.aspectRatio
11+
import androidx.compose.foundation.layout.fillMaxWidth
12+
import androidx.compose.foundation.layout.height
13+
import androidx.compose.foundation.layout.padding
14+
import androidx.compose.foundation.layout.size
15+
import androidx.compose.foundation.layout.widthIn
16+
import androidx.compose.foundation.shape.RoundedCornerShape
17+
import androidx.compose.material3.MaterialTheme
18+
import androidx.compose.material3.Text
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.CompositionLocalProvider
21+
import androidx.compose.ui.Alignment
22+
import androidx.compose.ui.Modifier
23+
import androidx.compose.ui.draw.clip
24+
import androidx.compose.ui.graphics.Color
25+
import androidx.compose.ui.graphics.compositeOver
26+
import androidx.compose.ui.graphics.luminance
27+
import androidx.compose.ui.platform.LocalLayoutDirection
28+
import androidx.compose.ui.res.stringResource
29+
import androidx.compose.ui.semantics.clearAndSetSemantics
30+
import androidx.compose.ui.semantics.contentDescription
31+
import androidx.compose.ui.text.font.FontWeight
32+
import androidx.compose.ui.unit.LayoutDirection
33+
import androidx.compose.ui.unit.dp
34+
import androidx.compose.ui.unit.sp
35+
import com.sysadmindoc.callshield.R
36+
import com.sysadmindoc.callshield.data.areacodes.AreaCodeLookup
37+
import com.sysadmindoc.callshield.ui.theme.Black
38+
import com.sysadmindoc.callshield.ui.theme.CatOverlay
39+
import com.sysadmindoc.callshield.ui.theme.CatPeach
40+
import com.sysadmindoc.callshield.ui.theme.CatSubtext
41+
import com.sysadmindoc.callshield.ui.theme.CatText
42+
import com.sysadmindoc.callshield.ui.theme.Surface
43+
import kotlin.math.ceil
44+
import kotlin.math.sqrt
45+
46+
private const val EMPTY_CELL = ".."
47+
private const val SHADE_STEPS = 4
48+
private const val TOP_REGION_COUNT = 3
49+
50+
/** Every state in its usual spot on a tile grid map, so no map data ships and nothing leaves the phone. */
51+
private val US_ROWS =
52+
parseTileRows(
53+
"AK .. .. .. .. .. .. .. .. .. ME",
54+
".. .. .. .. .. .. .. .. .. VT NH",
55+
"WA ID MT ND MN IL WI MI NY RI MA",
56+
"OR NV WY SD IA IN OH PA NJ CT ..",
57+
"CA UT CO NE MO KY WV VA MD DE ..",
58+
".. AZ NM KS AR TN NC SC DC .. ..",
59+
".. .. .. OK LA MS AL GA .. .. ..",
60+
"HI .. .. TX .. .. .. .. FL .. PR",
61+
)
62+
63+
/** AreaCodeLookup files the shared codes under NT (867) and NS (902), so those tiles stand for their neighbors too. */
64+
private val CANADA_ROWS = parseTileRows("NT BC AB SK MB ON QC NB NS NL ..")
65+
66+
private val CANADA_REGIONS = CANADA_ROWS.flatten().filterNotNull().toSet()
67+
private val MAPPED_REGIONS = US_ROWS.flatten().filterNotNull().toSet() + CANADA_REGIONS
68+
69+
private fun parseTileRows(vararg rows: String): List<List<String?>> =
70+
rows.map { row -> row.split(" ").map { cell -> cell.takeUnless { it == EMPTY_CELL } } }
71+
72+
/**
73+
* The log's area code counts added up by state or province. A code with no
74+
* tile (the Caribbean, toll-free) stays out, and the area code list still
75+
* shows it.
76+
*/
77+
internal fun countsByRegion(areaCodeCounts: List<Pair<String, Int>>): Map<String, Int> =
78+
areaCodeCounts
79+
.mapNotNull { (areaCode, count) ->
80+
AreaCodeLookup.getRegionCode("+1${areaCode}0000000")?.takeIf { it in MAPPED_REGIONS }?.let { it to count }
81+
}.groupBy(keySelector = { it.first }, valueTransform = { it.second })
82+
.mapValues { (_, counts) -> counts.sum() }
83+
84+
/** The map's rows, with Canada's on top only when the log holds a Canadian number. */
85+
internal fun regionTileRows(counts: Map<String, Int>): List<List<String?>> =
86+
if (counts.keys.any { it in CANADA_REGIONS }) CANADA_ROWS + US_ROWS else US_ROWS
87+
88+
/** How dark a tile is, 0 for no calls. The square root keeps one busy state from washing out the rest. */
89+
internal fun shadeStep(
90+
count: Int,
91+
maxCount: Int,
92+
): Int =
93+
if (count <= 0 || maxCount <= 0) {
94+
0
95+
} else {
96+
ceil(sqrt(count.toDouble() / maxCount) * SHADE_STEPS).toInt().coerceIn(1, SHADE_STEPS)
97+
}
98+
99+
/** The busiest places, "TX 12, CA 5, FL 3", most first and then by name. */
100+
internal fun topRegions(counts: Map<String, Int>): String =
101+
counts.entries
102+
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
103+
.take(TOP_REGION_COUNT)
104+
.joinToString(", ") { (region, count) -> "$region $count" }
105+
106+
/** The label color that reads better on [background], [light] or [dark]. */
107+
internal fun inkOn(
108+
background: Color,
109+
light: Color,
110+
dark: Color,
111+
): Color = if (contrast(background, light) >= contrast(background, dark)) light else dark
112+
113+
private fun contrast(
114+
first: Color,
115+
second: Color,
116+
): Float {
117+
val high = maxOf(first.luminance(), second.luminance())
118+
val low = minOf(first.luminance(), second.luminance())
119+
return (high + 0.05f) / (low + 0.05f)
120+
}
121+
122+
@Composable
123+
private fun shadeColor(step: Int): Color =
124+
when (step) {
125+
0 -> CatOverlay.copy(alpha = 0.1f)
126+
1 -> CatPeach.copy(alpha = 0.3f)
127+
2 -> CatPeach.copy(alpha = 0.5f)
128+
3 -> CatPeach.copy(alpha = 0.75f)
129+
else -> CatPeach
130+
}
131+
132+
/** The state and province map. TalkBack reads it as one line, the busiest places, and the area code list has the rest. */
133+
@Composable
134+
internal fun RegionTileMap(
135+
counts: Map<String, Int>,
136+
modifier: Modifier = Modifier,
137+
) {
138+
val maxCount = counts.values.maxOrNull() ?: 0
139+
val summary = stringResource(R.string.stats_region_map_summary, topRegions(counts))
140+
Column(modifier = modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = summary }) {
141+
// A map keeps its west on the left in every language.
142+
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
143+
Column(
144+
modifier = Modifier.widthIn(max = 420.dp).align(Alignment.CenterHorizontally),
145+
verticalArrangement = Arrangement.spacedBy(2.dp),
146+
) {
147+
regionTileRows(counts).forEach { row ->
148+
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
149+
row.forEach { region ->
150+
if (region == null) {
151+
Spacer(Modifier.weight(1f))
152+
} else {
153+
RegionTile(region, shadeStep(counts[region] ?: 0, maxCount))
154+
}
155+
}
156+
}
157+
}
158+
}
159+
}
160+
Spacer(Modifier.height(10.dp))
161+
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
162+
Text(summary, style = MaterialTheme.typography.labelSmall, color = CatSubtext, modifier = Modifier.weight(1f))
163+
Spacer(Modifier.size(8.dp))
164+
ShadeKey()
165+
}
166+
}
167+
}
168+
169+
@Composable
170+
private fun RowScope.RegionTile(
171+
region: String,
172+
step: Int,
173+
) {
174+
val fill = shadeColor(step)
175+
Box(
176+
modifier =
177+
Modifier
178+
.weight(1f)
179+
.aspectRatio(1f)
180+
.clip(RoundedCornerShape(4.dp))
181+
.background(fill),
182+
contentAlignment = Alignment.Center,
183+
) {
184+
Text(
185+
text = region,
186+
fontSize = 9.sp,
187+
maxLines = 1,
188+
softWrap = false,
189+
fontWeight = if (step > 0) FontWeight.Bold else FontWeight.Normal,
190+
color = if (step > 0) inkOn(fill.compositeOver(Surface), CatText, Black) else CatSubtext,
191+
)
192+
}
193+
}
194+
195+
@Composable
196+
private fun ShadeKey() {
197+
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
198+
Text(stringResource(R.string.stats_region_map_fewer), style = MaterialTheme.typography.labelSmall, color = CatSubtext)
199+
Spacer(Modifier.size(2.dp))
200+
for (step in 1..SHADE_STEPS) {
201+
Box(Modifier.size(10.dp).clip(RoundedCornerShape(2.dp)).background(shadeColor(step)))
202+
}
203+
Spacer(Modifier.size(2.dp))
204+
Text(stringResource(R.string.stats_region_map_more), style = MaterialTheme.typography.labelSmall, color = CatSubtext)
205+
}
206+
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ fun StatsScreen(viewModel: MainViewModel) {
6868
val hourAggregates by viewModel.logHourCounts.collectAsStateWithLifecycle()
6969
val topNumberAggregates by viewModel.logTopNumbers.collectAsStateWithLifecycle()
7070
val areaCodeAggregates by viewModel.logAreaCodeCounts.collectAsStateWithLifecycle()
71+
val mapAreaCodeAggregates by viewModel.logMapAreaCodeCounts.collectAsStateWithLifecycle()
7172
val numberFormatter = remember { NumberFormat.getIntegerInstance() }
7273

7374
// Room emits compact projections instead of materializing the unbounded
@@ -103,6 +104,11 @@ fun StatsScreen(viewModel: MainViewModel) {
103104
countsByAreaCode(areaCodeAggregates, homeRegion).map { (block, count) -> block.areaCode to count }
104105
}
105106

107+
val regionCounts: Map<String, Int> =
108+
remember(mapAreaCodeAggregates, homeRegion) {
109+
countsByRegion(countsByAreaCode(mapAreaCodeAggregates, homeRegion).map { (block, count) -> block.areaCode to count })
110+
}
111+
106112
val dayBucket = rememberDayBucket()
107113
val todayStart = remember(dayBucket) { currentDayStart() }
108114
val dayQueryStart = dayOffset(todayStart, -14)
@@ -397,6 +403,17 @@ fun StatsScreen(viewModel: MainViewModel) {
397403
}
398404
}
399405

406+
// State and province map, worked out on this phone from the log's area codes
407+
if (regionCounts.isNotEmpty()) {
408+
PremiumCard(accentColor = CatPeach) {
409+
Column(modifier = Modifier.padding(16.dp)) {
410+
SectionHeader(stringResource(R.string.stats_spam_by_region), CatPeach)
411+
Spacer(Modifier.height(8.dp))
412+
RegionTileMap(regionCounts)
413+
}
414+
}
415+
}
416+
400417
// Area code heatmap
401418
if (areaCodeCounts.isNotEmpty()) {
402419
PremiumCard(accentColor = CatPeach) {

‎app/src/main/res/values-es/strings.xml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,10 @@
862862
<string name="stats_by_detection_method">Por método de detección</string>
863863
<string name="stats_top_offenders">Principales infractores</string>
864864
<string name="stats_spam_by_area_code">Spam por código de área</string>
865+
<string name="stats_spam_by_region">Spam por estado o provincia</string>
866+
<string name="stats_region_map_summary">Más spam: %1$s</string>
867+
<string name="stats_region_map_fewer">Menos</string>
868+
<string name="stats_region_map_more">Más</string>
865869
<string name="stats_spam_by_hour">Spam por hora</string>
866870
<string name="stats_spam_concentrate">Cuándo se concentran las llamadas de spam</string>
867871
<string name="stats_time_12a">12am</string>

‎app/src/main/res/values-pt-rBR/strings.xml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,10 @@
862862
<string name="stats_by_detection_method">Por método de detecção</string>
863863
<string name="stats_top_offenders">Maiores incômodos</string>
864864
<string name="stats_spam_by_area_code">Spam por DDD</string>
865+
<string name="stats_spam_by_region">Spam por estado ou província</string>
866+
<string name="stats_region_map_summary">Mais spam: %1$s</string>
867+
<string name="stats_region_map_fewer">Menos</string>
868+
<string name="stats_region_map_more">Mais</string>
865869
<string name="stats_spam_by_hour">Spam por hora</string>
866870
<string name="stats_spam_concentrate">Quando as chamadas de spam se concentram</string>
867871
<string name="stats_time_12a">0h</string>

‎app/src/main/res/values-zh-rCN/strings.xml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,10 @@
690690
<string name="stats_by_detection_method">按检测方式</string>
691691
<string name="stats_top_offenders">主要违规者</string>
692692
<string name="stats_spam_by_area_code">按区号的垃圾</string>
693+
<string name="stats_spam_by_region">按州或省的垃圾</string>
694+
<string name="stats_region_map_summary">最多垃圾:%1$s</string>
695+
<string name="stats_region_map_fewer">较少</string>
696+
<string name="stats_region_map_more">较多</string>
693697
<string name="stats_spam_by_hour">按小时的垃圾</string>
694698
<string name="stats_spam_concentrate">垃圾电话集中时段</string>
695699
<string name="stats_time_12a">0时</string>

‎app/src/main/res/values/strings.xml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,10 @@
895895
<string name="stats_by_detection_method">By detection method</string>
896896
<string name="stats_top_offenders">Top offenders</string>
897897
<string name="stats_spam_by_area_code">Spam by area code</string>
898+
<string name="stats_spam_by_region">Spam by state or province</string>
899+
<string name="stats_region_map_summary">Most spam: %1$s</string>
900+
<string name="stats_region_map_fewer">Fewer</string>
901+
<string name="stats_region_map_more">More</string>
898902
<string name="stats_spam_by_hour">Spam by hour</string>
899903
<string name="stats_spam_concentrate">When spam calls concentrate</string>
900904
<string name="stats_time_12a">12a</string>

0 commit comments

Comments
 (0)