|
| 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 | +} |
0 commit comments