fuzzyworld

haunt / commit

Add type-specific icons for APs, lights, 'you', and generic points

9fa3d1151298dcbd05fbabce202fafebe8b4c3c9 · Pavel Safronov · 2026-05-13 08:05:40 -0700 · parents d6ee68c1d1c2

Add type-specific icons for APs, lights, 'you', and generic points

Map: routers get WiFi arc stack, Hue lights get bulb shape, 'you' gets
a pulsing 4-point star with crosshair, generic points keep filled circle.
List: colorSwatch is now ImageView with matching tinted vector drawable per type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../java/net/fuzzyworld/haunt/debug/MapView.kt     | 137 ++++++++++++++++-----
 .../net/fuzzyworld/haunt/debug/PointsAdapter.kt    |  13 +-
 app/src/main/res/drawable/ic_ap.xml                |  11 ++
 app/src/main/res/drawable/ic_light.xml             |  11 ++
 app/src/main/res/drawable/ic_point.xml             |  11 ++
 app/src/main/res/drawable/ic_you.xml               |  11 ++
 app/src/main/res/layout/item_debug_point.xml       |  11 +-
 7 files changed, 168 insertions(+), 37 deletions(-)

diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
index 442aaad..aae5d36 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
@@ -1,15 +1,21 @@
 package net.fuzzyworld.haunt.debug
 
+import android.animation.ValueAnimator
 import android.content.Context
 import android.graphics.Canvas
 import android.graphics.Color
 import android.graphics.DashPathEffect
 import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.RectF
 import android.graphics.Typeface
 import android.util.AttributeSet
 import android.view.View
+import android.view.animation.AccelerateDecelerateInterpolator
 import net.fuzzyworld.haunt.hue.HueLightPoint
+import kotlin.math.cos
 import kotlin.math.roundToInt
+import kotlin.math.sin
 
 class MapView @JvmOverloads constructor(
     context: Context,
@@ -31,6 +37,18 @@ class MapView @JvmOverloads constructor(
     var youColor: Int = Color.HSVToColor(floatArrayOf(210f, 0.75f, 0.90f))
         set(value) { field = value; invalidate() }
 
+    private var youPulse = 1f
+    private val pulseAnimator = ValueAnimator.ofFloat(0.85f, 1.15f).apply {
+        duration = 1100
+        repeatMode = ValueAnimator.REVERSE
+        repeatCount = ValueAnimator.INFINITE
+        interpolator = AccelerateDecelerateInterpolator()
+        addUpdateListener { youPulse = it.animatedValue as Float; invalidate() }
+    }
+
+    override fun onAttachedToWindow() { super.onAttachedToWindow(); pulseAnimator.start() }
+    override fun onDetachedFromWindow() { super.onDetachedFromWindow(); pulseAnimator.cancel() }
+
     private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
     private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
     private val gridPaint = Paint().apply {
@@ -51,6 +69,8 @@ class MapView @JvmOverloads constructor(
         strokeWidth = 3f
         pathEffect = DashPathEffect(floatArrayOf(8f, 6f), 0f)
     }
+    private val arcRect = RectF()
+    private val starPath = Path()
 
     private val allBssids: List<String>
         get() = (savedPoints.flatMap { it.readings.keys } + currentReadings.keys)
@@ -63,6 +83,82 @@ class MapView @JvmOverloads constructor(
         return Pair(x, y)
     }
 
+    // --- Per-type icon draw functions ---
+
+    private fun drawApIcon(canvas: Canvas, cx: Float, cy: Float, color: Int, radius: Float) {
+        strokePaint.color = color
+        strokePaint.strokeWidth = radius * 0.18f
+        // 3 concentric upward arcs
+        for (r in listOf(0.40f, 0.65f, 0.90f)) {
+            val ar = radius * r
+            arcRect.set(cx - ar, cy - ar, cx + ar, cy + ar)
+            canvas.drawArc(arcRect, 210f, 120f, false, strokePaint)
+        }
+        // dot at the focal point
+        fillPaint.color = color
+        canvas.drawCircle(cx, cy + radius * 0.55f, radius * 0.15f, fillPaint)
+    }
+
+    private fun drawLightIcon(canvas: Canvas, cx: Float, cy: Float, color: Int, radius: Float) {
+        // Outer circle (bulb)
+        fillPaint.color = color
+        canvas.drawCircle(cx, cy - radius * 0.1f, radius * 0.72f, fillPaint)
+        strokePaint.color = Color.WHITE
+        strokePaint.strokeWidth = radius * 0.12f
+        canvas.drawCircle(cx, cy - radius * 0.1f, radius * 0.72f, strokePaint)
+        // Base
+        fillPaint.color = color
+        val baseHalf = radius * 0.28f
+        val baseTop = cy + radius * 0.55f
+        val baseBot = cy + radius * 0.90f
+        canvas.drawRect(cx - baseHalf, baseTop, cx + baseHalf, baseBot, fillPaint)
+        // Center highlight dot
+        fillPaint.color = Color.argb(100, 255, 255, 255)
+        canvas.drawCircle(cx - radius * 0.18f, cy - radius * 0.28f, radius * 0.18f, fillPaint)
+    }
+
+    private fun drawPointIcon(canvas: Canvas, cx: Float, cy: Float, color: Int, radius: Float) {
+        fillPaint.color = color
+        canvas.drawCircle(cx, cy, radius, fillPaint)
+        strokePaint.color = Color.WHITE
+        strokePaint.strokeWidth = radius * 0.12f
+        canvas.drawCircle(cx, cy, radius, strokePaint)
+    }
+
+    private fun drawYouIcon(canvas: Canvas, cx: Float, cy: Float, color: Int, radius: Float) {
+        val r = radius * youPulse
+        // 4-point star
+        starPath.reset()
+        val outer = r
+        val inner = r * 0.42f
+        for (i in 0 until 8) {
+            val angle = Math.toRadians((i * 45.0) - 90.0)
+            val dist = if (i % 2 == 0) outer else inner
+            val px = cx + (dist * cos(angle)).toFloat()
+            val py = cy + (dist * sin(angle)).toFloat()
+            if (i == 0) starPath.moveTo(px, py) else starPath.lineTo(px, py)
+        }
+        starPath.close()
+
+        fillPaint.color = color
+        canvas.drawPath(starPath, fillPaint)
+
+        strokePaint.color = Color.argb(180, 0, 0, 0)
+        strokePaint.strokeWidth = r * 0.12f
+        canvas.drawPath(starPath, strokePaint)
+
+        // Crosshair
+        strokePaint.color = Color.argb(160, 0, 0, 0)
+        strokePaint.strokeWidth = 2f
+        val cross = r * 1.7f
+        canvas.drawLine(cx - cross, cy, cx + cross, cy, strokePaint)
+        canvas.drawLine(cx, cy - cross, cx, cy + cross, strokePaint)
+
+        // Dashed ring
+        ringPaint.color = Color.argb(140, 0, 0, 0)
+        canvas.drawCircle(cx, cy, r * 1.7f, ringPaint)
+    }
+
     override fun onDraw(canvas: Canvas) {
         super.onDraw(canvas)
         canvas.drawColor(Color.parseColor("#F2F2F2"))
@@ -115,54 +211,35 @@ class MapView @JvmOverloads constructor(
             canvas.drawCircle(sx(x), sy(y), 7f, fillPaint)
         }
 
-        // Light points (same style as saved points for now — icons come next commit)
+        // Light points
         lightCoords.forEach { (point, xy) ->
             val cx = sx(xy.first)
             val cy = sy(xy.second)
-            fillPaint.color = point.color
-            canvas.drawCircle(cx, cy, 26f, fillPaint)
-            strokePaint.color = Color.WHITE
-            strokePaint.strokeWidth = 3f
-            canvas.drawCircle(cx, cy, 26f, strokePaint)
+            drawLightIcon(canvas, cx, cy, point.color, 26f)
+            labelPaint.color = Color.BLACK
             labelPaint.textAlign = Paint.Align.LEFT
             canvas.drawText(point.name, cx + 34f, cy + 11f, labelPaint)
         }
 
-        // Saved points
+        // Saved points — routers get AP icon, others get generic pin
         labelPaint.color = Color.BLACK
         savedCoords.forEach { (point, xy) ->
             val cx = sx(xy.first)
             val cy = sy(xy.second)
-
-            fillPaint.color = point.color
-            canvas.drawCircle(cx, cy, 26f, fillPaint)
-            strokePaint.color = Color.WHITE
-            strokePaint.strokeWidth = 3f
-            canvas.drawCircle(cx, cy, 26f, strokePaint)
-
+            if (point.id.startsWith("router:")) {
+                drawApIcon(canvas, cx, cy, point.color, 26f)
+            } else {
+                drawPointIcon(canvas, cx, cy, point.color, 26f)
+            }
             labelPaint.textAlign = Paint.Align.LEFT
             canvas.drawText(point.name, cx + 34f, cy + 11f, labelPaint)
         }
 
-        // Current position — filled with youColor, crosshair + dashed ring on top
+        // Current position — pulsing star
         if (currentCoords != null) {
             val cx = sx(currentCoords.first)
             val cy = sy(currentCoords.second)
-
-            fillPaint.color = youColor
-            canvas.drawCircle(cx, cy, 22f, fillPaint)
-
-            strokePaint.color = Color.argb(180, 0, 0, 0)
-            strokePaint.strokeWidth = 4f
-            canvas.drawCircle(cx, cy, 22f, strokePaint)
-
-            strokePaint.strokeWidth = 2f
-            canvas.drawLine(cx - 38f, cy, cx + 38f, cy, strokePaint)
-            canvas.drawLine(cx, cy - 38f, cx, cy + 38f, strokePaint)
-
-            ringPaint.color = Color.argb(140, 0, 0, 0)
-            canvas.drawCircle(cx, cy, 38f, ringPaint)
-
+            drawYouIcon(canvas, cx, cy, youColor, 22f)
             labelPaint.textAlign = Paint.Align.LEFT
             labelPaint.color = Color.parseColor("#444444")
             canvas.drawText("you", cx + 46f, cy + 11f, labelPaint)
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
index 3559413..b311478 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
@@ -1,5 +1,6 @@
 package net.fuzzyworld.haunt.debug
 
+import android.content.res.ColorStateList
 import android.content.Context
 import android.graphics.Color
 import android.graphics.drawable.GradientDrawable
@@ -8,7 +9,9 @@ import android.view.View
 import android.view.ViewGroup
 import android.widget.GridLayout
 import androidx.appcompat.app.AlertDialog
+import androidx.core.widget.ImageViewCompat
 import androidx.recyclerview.widget.RecyclerView
+import net.fuzzyworld.haunt.R
 import net.fuzzyworld.haunt.databinding.ItemDebugPointBinding
 import net.fuzzyworld.haunt.hue.HueLightPoint
 
@@ -114,7 +117,8 @@ class PointsAdapter(
 
     inner class YouViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) {
         fun bind() {
-            b.colorSwatch.setBackgroundColor(youColor)
+            b.colorSwatch.setImageResource(R.drawable.ic_you)
+            ImageViewCompat.setImageTintList(b.colorSwatch, ColorStateList.valueOf(youColor))
             b.colorSwatch.setOnClickListener {
                 showColorPicker(b.root.context, youColor) { color -> onRecolorYou(color) }
             }
@@ -134,7 +138,8 @@ class PointsAdapter(
 
     inner class LightViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) {
         fun bind(point: HueLightPoint) {
-            b.colorSwatch.setBackgroundColor(point.color)
+            b.colorSwatch.setImageResource(R.drawable.ic_light)
+            ImageViewCompat.setImageTintList(b.colorSwatch, ColorStateList.valueOf(point.color))
             b.colorSwatch.setOnClickListener {
                 showColorPicker(b.root.context, point.color) { color -> onRecolorLight(point.id, color) }
             }
@@ -158,7 +163,9 @@ class PointsAdapter(
 
     inner class PointViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) {
         fun bind(point: DebugPoint) {
-            b.colorSwatch.setBackgroundColor(point.color)
+            val iconRes = if (point.id.startsWith("router:")) R.drawable.ic_ap else R.drawable.ic_point
+            b.colorSwatch.setImageResource(iconRes)
+            ImageViewCompat.setImageTintList(b.colorSwatch, ColorStateList.valueOf(point.color))
             b.colorSwatch.setOnClickListener {
                 showColorPicker(b.root.context, point.color) { color -> onRecolor(point.id, color) }
             }
diff --git a/app/src/main/res/drawable/ic_ap.xml b/app/src/main/res/drawable/ic_ap.xml
new file mode 100644
index 0000000..20c4a01
--- /dev/null
+++ b/app/src/main/res/drawable/ic_ap.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- WiFi / Access Point icon -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M1,9L3,11C7.97,6.03 16.03,6.03 21,11L23,9C16.93,2.93 7.08,2.93 1,9M9,17L12,20L15,17C13.35,15.34 10.66,15.34 9,17M5,13L7,15C9.76,12.24 14.24,12.24 17,15L19,13C15.14,9.14 8.87,9.14 5,13Z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_light.xml b/app/src/main/res/drawable/ic_light.xml
new file mode 100644
index 0000000..bc3fa2d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_light.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Light bulb icon -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M12,2A7,7 0,0 0,5 9C5,11.38 6.19,13.47 8,14.74V17A1,1 0,0 0,9 18H15A1,1 0,0 0,16 17V14.74C17.81,13.47 19,11.38 19,9A7,7 0,0 0,12 2M9,21A1,1 0,0 0,10 22H14A1,1 0,0 0,15 21V20H9V21Z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_point.xml b/app/src/main/res/drawable/ic_point.xml
new file mode 100644
index 0000000..69037ca
--- /dev/null
+++ b/app/src/main/res/drawable/ic_point.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generic location pin / dot -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M12,2C8.13,2 5,5.13 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9C19,5.13 15.87,2 12,2ZM12,11.5C10.62,11.5 9.5,10.38 9.5,9C9.5,7.62 10.62,6.5 12,6.5C13.38,6.5 14.5,7.62 14.5,9C14.5,10.38 13.38,11.5 12,11.5Z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_you.xml b/app/src/main/res/drawable/ic_you.xml
new file mode 100644
index 0000000..112a399
--- /dev/null
+++ b/app/src/main/res/drawable/ic_you.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 4-point star icon (for "you" position) -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M12,1L14.83,9.17L23,12L14.83,14.83L12,23L9.17,14.83L1,12L9.17,9.17Z" />
+</vector>
diff --git a/app/src/main/res/layout/item_debug_point.xml b/app/src/main/res/layout/item_debug_point.xml
index 46c61e4..9c17973 100644
--- a/app/src/main/res/layout/item_debug_point.xml
+++ b/app/src/main/res/layout/item_debug_point.xml
@@ -7,11 +7,14 @@
     android:paddingHorizontal="12dp"
     android:paddingVertical="4dp">
 
-    <View
+    <ImageView
         android:id="@+id/colorSwatch"
-        android:layout_width="20dp"
-        android:layout_height="20dp"
-        android:layout_marginEnd="10dp" />
+        android:layout_width="28dp"
+        android:layout_height="28dp"
+        android:layout_marginEnd="10dp"
+        android:padding="2dp"
+        android:scaleType="fitCenter"
+        android:contentDescription="Point type icon" />
 
     <LinearLayout
         android:layout_width="0dp"