diff --git a/README.md b/README.md
index 5e38c80..b8d3493 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,12 @@ Key goal: appliance-style operation — once a preferred device is set, a foregr
### Dashboard
- **Real-time charts** with zoom, pan, and configurable time windows (30s to 1h)
+- **Live Map View** - GPS-tracked radiation readings on interactive map
+ - Color-coded markers (green → yellow → red gradient)
+ - Switch between dose rate and count rate display
+ - Dynamic legend showing min/max values
+ - Reset button to clear readings
+ - Persistent storage of readings with GPS coordinates
- **Delta cards** showing change rates with statistical sparklines
- **Spike detection** with configurable markers and percentage annotations
- **Statistical analysis** with z-score highlighting and trend indicators
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index aa01c9a..81f8bde 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,8 +11,8 @@ android {
applicationId = "com.radiacode.ble"
minSdk = 26
targetSdk = 34
- versionCode = 29
- versionName = "0.41"
+ versionCode = 30
+ versionName = "0.42"
}
buildTypes {
@@ -39,4 +39,6 @@ dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
+ implementation("com.google.android.gms:play-services-maps:18.2.0")
+ implementation("com.google.android.gms:play-services-location:21.1.0")
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f55cb1f..67322ba 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,7 +6,8 @@
-
+
+
+
+
+
get() {
- val perms = ArrayList(3)
+ val perms = ArrayList(5)
+ // Location permissions (always needed for map)
+ perms += Manifest.permission.ACCESS_FINE_LOCATION
+ perms += Manifest.permission.ACCESS_COARSE_LOCATION
+
if (Build.VERSION.SDK_INT >= 31) {
perms += Manifest.permission.BLUETOOTH_SCAN
perms += Manifest.permission.BLUETOOTH_CONNECT
- } else {
- perms += Manifest.permission.ACCESS_FINE_LOCATION
}
if (Build.VERSION.SDK_INT >= 33) {
perms += Manifest.permission.POST_NOTIFICATIONS
@@ -348,6 +356,7 @@ class MainActivity : AppCompatActivity() {
setupLogsPanel()
setupMetricCards()
setupCharts()
+ setupMapCard(savedInstanceState)
setupIsotopePanel()
setupToolbarDeviceSelector()
@@ -399,6 +408,9 @@ class MainActivity : AppCompatActivity() {
cpsChartGoRealtime = findViewById(R.id.cpsChartGoRealtime)
cpsStats = findViewById(R.id.cpsStats)
+ // Live Map
+ mapCard = findViewById(R.id.mapCard)
+
// Isotope detection panel
isotopePanel = findViewById(R.id.isotopePanel)
isotopeChartTitle = findViewById(R.id.isotopeChartTitle)
@@ -777,6 +789,36 @@ class MainActivity : AppCompatActivity() {
})
}
+ private fun setupMapCard(savedInstanceState: Bundle?) {
+ // Initialize map
+ mapCard.onCreate(savedInstanceState)
+
+ // Setup reset callback
+ mapCard.onResetClickListener = {
+ val selectedDeviceId = Prefs.getSelectedDeviceId(this)
+ if (selectedDeviceId != null) {
+ Prefs.clearMapReadings(this, selectedDeviceId)
+ }
+ }
+
+ // Load existing map readings for selected device
+ val selectedDeviceId = Prefs.getSelectedDeviceId(this)
+ if (selectedDeviceId != null) {
+ val mapReadings = Prefs.getMapReadings(this, selectedDeviceId)
+ for (reading in mapReadings) {
+ mapCard.addReading(
+ com.radiacode.ble.ui.MapCardView.MapReading(
+ latitude = reading.latitude,
+ longitude = reading.longitude,
+ doseRate = reading.uSvPerHour,
+ countRate = reading.cps,
+ timestampMs = reading.timestampMs
+ )
+ )
+ }
+ }
+ }
+
private fun setupIsotopePanel() {
// Initialize the isotope detector with enabled isotopes
val enabledIsotopes = Prefs.getEnabledIsotopes(this)
@@ -1326,6 +1368,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
+ mapCard.onResume()
registerReadingReceiver()
reloadChartHistoryForSelectedDevice()
startUiLoop()
@@ -1375,15 +1418,37 @@ class MainActivity : AppCompatActivity() {
override fun onPause() {
super.onPause()
+ mapCard.onPause()
unregisterReadingReceiver()
stopUiLoop()
}
override fun onDestroy() {
super.onDestroy()
+ mapCard.onDestroy()
stopUiLoop()
}
+ override fun onStart() {
+ super.onStart()
+ mapCard.onStart()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ mapCard.onStop()
+ }
+
+ override fun onLowMemory() {
+ super.onLowMemory()
+ mapCard.onLowMemory()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ mapCard.onSaveInstanceState(outState)
+ }
+
private fun hasAllPermissions(): Boolean {
return requiredPermissions.all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
@@ -1629,6 +1694,30 @@ class MainActivity : AppCompatActivity() {
doseHistory.add(last.timestampMs, last.uSvPerHour)
cpsHistory.add(last.timestampMs, last.cps)
sampleCount++
+
+ // Add reading to map with GPS location
+ if (selectedDeviceId != null) {
+ mapCard.addReadingAtCurrentLocation(last.uSvPerHour, last.cps, last.timestampMs)
+
+ // Store map reading with GPS to Prefs (for persistence)
+ if (ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.ACCESS_FINE_LOCATION)
+ == PackageManager.PERMISSION_GRANTED
+ ) {
+ val fusedLocationClient = LocationServices.getFusedLocationProviderClient(this@MainActivity)
+ fusedLocationClient.lastLocation.addOnSuccessListener { location ->
+ location?.let {
+ val mapReading = Prefs.MapReading(
+ latitude = it.latitude,
+ longitude = it.longitude,
+ uSvPerHour = last.uSvPerHour,
+ cps = last.cps,
+ timestampMs = last.timestampMs
+ )
+ Prefs.addMapReading(this@MainActivity, selectedDeviceId, mapReading)
+ }
+ }
+ }
+ }
}
if (paused && (pausedSnapshotDose == null || pausedSnapshotCps == null)) {
diff --git a/app/src/main/java/com/radiacode/ble/Prefs.kt b/app/src/main/java/com/radiacode/ble/Prefs.kt
index e1cc7c5..41de249 100644
--- a/app/src/main/java/com/radiacode/ble/Prefs.kt
+++ b/app/src/main/java/com/radiacode/ble/Prefs.kt
@@ -1291,4 +1291,77 @@ object Prefs {
.putBoolean(KEY_ISOTOPE_HIDE_BACKGROUND, hide)
.apply()
}
+
+ // ===== Map Readings with GPS =====
+
+ private const val KEY_MAP_READINGS_PREFIX = "map_readings_"
+ private const val MAX_MAP_READINGS = 1000
+
+ /**
+ * Reading with GPS coordinates for map display.
+ */
+ data class MapReading(
+ val latitude: Double,
+ val longitude: Double,
+ val uSvPerHour: Float,
+ val cps: Float,
+ val timestampMs: Long
+ )
+
+ /**
+ * Add a map reading with GPS coordinates for a specific device.
+ */
+ fun addMapReading(context: Context, deviceId: String, reading: MapReading) {
+ val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
+ val key = KEY_MAP_READINGS_PREFIX + deviceId
+ val existing = prefs.getString(key, "") ?: ""
+
+ val newEntry = "${reading.timestampMs},${reading.latitude},${reading.longitude},${reading.uSvPerHour},${reading.cps}"
+
+ val entries = existing.split(";").filter { it.isNotBlank() }.toMutableList()
+ entries.add(newEntry)
+ while (entries.size > MAX_MAP_READINGS) {
+ entries.removeAt(0)
+ }
+
+ prefs.edit()
+ .putString(key, entries.joinToString(";"))
+ .apply()
+ }
+
+ /**
+ * Get map readings for a specific device.
+ */
+ fun getMapReadings(context: Context, deviceId: String): List {
+ val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
+ val raw = prefs.getString(KEY_MAP_READINGS_PREFIX + deviceId, "") ?: ""
+ if (raw.isBlank()) return emptyList()
+
+ return raw.split(";")
+ .filter { it.isNotBlank() }
+ .mapNotNull { entry ->
+ val parts = entry.split(",")
+ if (parts.size == 5) {
+ try {
+ MapReading(
+ timestampMs = parts[0].toLong(),
+ latitude = parts[1].toDouble(),
+ longitude = parts[2].toDouble(),
+ uSvPerHour = parts[3].toFloat(),
+ cps = parts[4].toFloat()
+ )
+ } catch (_: Exception) { null }
+ } else null
+ }
+ }
+
+ /**
+ * Clear map readings for a specific device.
+ */
+ fun clearMapReadings(context: Context, deviceId: String) {
+ context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
+ .edit()
+ .remove(KEY_MAP_READINGS_PREFIX + deviceId)
+ .apply()
+ }
}
diff --git a/app/src/main/java/com/radiacode/ble/ui/MapCardView.kt b/app/src/main/java/com/radiacode/ble/ui/MapCardView.kt
new file mode 100644
index 0000000..ba6efae
--- /dev/null
+++ b/app/src/main/java/com/radiacode/ble/ui/MapCardView.kt
@@ -0,0 +1,335 @@
+package com.radiacode.ble.ui
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.graphics.*
+import android.location.Location
+import android.util.AttributeSet
+import android.view.View
+import android.widget.*
+import androidx.core.content.ContextCompat
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.maps.CameraUpdateFactory
+import com.google.android.gms.maps.GoogleMap
+import com.google.android.gms.maps.MapView
+import com.google.android.gms.maps.model.*
+import com.radiacode.ble.R
+import java.util.Locale
+
+/**
+ * Live map card view that shows radiation readings on a map.
+ * Features:
+ * - Google Maps integration with user location
+ * - Metric selector (dose rate / count rate)
+ * - Color-coded dots based on readings
+ * - Dynamic legend showing min/max values
+ * - Reset button to clear readings
+ */
+class MapCardView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+ // Data class for map readings
+ data class MapReading(
+ val latitude: Double,
+ val longitude: Double,
+ val doseRate: Float, // μSv/h
+ val countRate: Float, // cps
+ val timestampMs: Long
+ )
+
+ enum class MetricType {
+ DOSE_RATE,
+ COUNT_RATE
+ }
+
+ // Views
+ private val containerLayout: LinearLayout
+ private val headerLayout: LinearLayout
+ private val titleText: TextView
+ private val metricSelector: Spinner
+ private val resetButton: ImageButton
+ private val contentLayout: LinearLayout
+ private val mapView: MapView
+ private val legendLayout: LinearLayout
+ private val legendBar: View
+ private val legendMinLabel: TextView
+ private val legendMaxLabel: TextView
+
+ // Map and location
+ private var googleMap: GoogleMap? = null
+ private val fusedLocationClient: FusedLocationProviderClient
+ private var currentLocation: Location? = null
+ private val DEFAULT_ZOOM = 16f
+
+ // Data
+ private val readings = mutableListOf()
+ private val markers = mutableListOf()
+ private var currentMetric = MetricType.DOSE_RATE
+ private var minValue = 0f
+ private var maxValue = 0f
+ private var hasInitialLocation = false
+
+ // Callbacks
+ var onResetClickListener: (() -> Unit)? = null
+
+ init {
+ // Inflate the card layout
+ inflate(context, R.layout.view_map_card, this)
+
+ // Bind views
+ containerLayout = findViewById(R.id.mapCardContainer)
+ headerLayout = findViewById(R.id.mapCardHeader)
+ titleText = findViewById(R.id.mapCardTitle)
+ metricSelector = findViewById(R.id.mapMetricSelector)
+ resetButton = findViewById(R.id.mapResetButton)
+ contentLayout = findViewById(R.id.mapCardContent)
+ mapView = findViewById(R.id.mapView)
+ legendLayout = findViewById(R.id.mapLegendLayout)
+ legendBar = findViewById(R.id.mapLegendBar)
+ legendMinLabel = findViewById(R.id.mapLegendMin)
+ legendMaxLabel = findViewById(R.id.mapLegendMax)
+
+ // Setup location client
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
+
+ // Setup metric selector
+ setupMetricSelector()
+
+ // Setup reset button
+ resetButton.setOnClickListener {
+ clearReadings()
+ onResetClickListener?.invoke()
+ }
+
+ // Initialize title
+ titleText.text = "LIVE MAP"
+ }
+
+ private fun setupMetricSelector() {
+ val adapter = ArrayAdapter(
+ context,
+ android.R.layout.simple_spinner_item,
+ arrayOf("Dose Rate", "Count Rate")
+ )
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+ metricSelector.adapter = adapter
+
+ metricSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ currentMetric = if (position == 0) MetricType.DOSE_RATE else MetricType.COUNT_RATE
+ updateMarkersAndLegend()
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {}
+ }
+ }
+
+ /**
+ * Initialize the map. Should be called from Activity lifecycle methods.
+ */
+ fun onCreate(savedInstanceState: android.os.Bundle?) {
+ mapView.onCreate(savedInstanceState)
+ mapView.getMapAsync { map ->
+ googleMap = map
+ setupMap(map)
+ getCurrentLocation()
+ }
+ }
+
+ private fun setupMap(map: GoogleMap) {
+ map.uiSettings.apply {
+ isZoomControlsEnabled = true
+ isZoomGesturesEnabled = true
+ isScrollGesturesEnabled = true
+ isRotateGesturesEnabled = false
+ isTiltGesturesEnabled = false
+ }
+
+ // Enable location if permission granted
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
+ == PackageManager.PERMISSION_GRANTED
+ ) {
+ map.isMyLocationEnabled = true
+ }
+
+ // Set map style to dark theme
+ try {
+ map.setMapStyle(MapStyleOptions.loadRawResourceStyle(context, R.raw.map_style_dark))
+ } catch (_: Exception) {
+ // Dark style not available, use default
+ }
+ }
+
+ private fun getCurrentLocation() {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ return
+ }
+
+ fusedLocationClient.lastLocation.addOnSuccessListener { location ->
+ location?.let {
+ currentLocation = it
+ if (!hasInitialLocation) {
+ moveToLocation(it.latitude, it.longitude)
+ hasInitialLocation = true
+ }
+ }
+ }
+ }
+
+ private fun moveToLocation(latitude: Double, longitude: Double) {
+ googleMap?.animateCamera(
+ CameraUpdateFactory.newLatLngZoom(
+ LatLng(latitude, longitude),
+ DEFAULT_ZOOM
+ )
+ )
+ }
+
+ /**
+ * Add a new reading to the map.
+ */
+ fun addReading(reading: MapReading) {
+ readings.add(reading)
+ updateMarkersAndLegend()
+ }
+
+ /**
+ * Add a reading with current location (if available).
+ */
+ fun addReadingAtCurrentLocation(doseRate: Float, countRate: Float, timestampMs: Long) {
+ currentLocation?.let { loc ->
+ addReading(MapReading(loc.latitude, loc.longitude, doseRate, countRate, timestampMs))
+ }
+ }
+
+ /**
+ * Clear all readings and markers from the map.
+ */
+ fun clearReadings() {
+ readings.clear()
+ markers.forEach { it.remove() }
+ markers.clear()
+ minValue = 0f
+ maxValue = 0f
+ updateLegend()
+ }
+
+ private fun updateMarkersAndLegend() {
+ // Remove existing markers
+ markers.forEach { it.remove() }
+ markers.clear()
+
+ if (readings.isEmpty()) {
+ minValue = 0f
+ maxValue = 0f
+ updateLegend()
+ return
+ }
+
+ // Calculate min/max for current metric
+ val values = readings.map {
+ when (currentMetric) {
+ MetricType.DOSE_RATE -> it.doseRate
+ MetricType.COUNT_RATE -> it.countRate
+ }
+ }
+
+ minValue = values.minOrNull() ?: 0f
+ maxValue = values.maxOrNull() ?: 0f
+
+ // Add markers
+ val map = googleMap ?: return
+ for (reading in readings) {
+ val value = when (currentMetric) {
+ MetricType.DOSE_RATE -> reading.doseRate
+ MetricType.COUNT_RATE -> reading.countRate
+ }
+
+ val color = getColorForValue(value, minValue, maxValue)
+ val circle = map.addCircle(
+ CircleOptions()
+ .center(LatLng(reading.latitude, reading.longitude))
+ .radius(10.0) // 10 meters
+ .fillColor(color)
+ .strokeColor(Color.WHITE)
+ .strokeWidth(2f)
+ )
+ markers.add(circle)
+ }
+
+ updateLegend()
+ }
+
+ private fun getColorForValue(value: Float, min: Float, max: Float): Int {
+ if (max <= min) return Color.argb(180, 0, 255, 0)
+
+ val normalized = ((value - min) / (max - min)).coerceIn(0f, 1f)
+
+ // Color gradient: green (low) -> yellow (medium) -> red (high)
+ val red: Int
+ val green: Int
+ if (normalized < 0.5f) {
+ // Green to yellow
+ red = (normalized * 2f * 255).toInt()
+ green = 255
+ } else {
+ // Yellow to red
+ red = 255
+ green = ((1f - normalized) * 2f * 255).toInt()
+ }
+
+ return Color.argb(180, red, green, 0)
+ }
+
+ private fun updateLegend() {
+ val unit = when (currentMetric) {
+ MetricType.DOSE_RATE -> "μSv/h"
+ MetricType.COUNT_RATE -> "cps"
+ }
+
+ if (maxValue > minValue) {
+ legendMinLabel.text = String.format(Locale.US, "%.2f %s", minValue, unit)
+ legendMaxLabel.text = String.format(Locale.US, "%.2f %s", maxValue, unit)
+ } else {
+ legendMinLabel.text = "0 $unit"
+ legendMaxLabel.text = "0 $unit"
+ }
+ }
+
+ // Lifecycle methods to forward to MapView
+ fun onStart() {
+ mapView.onStart()
+ }
+
+ fun onResume() {
+ mapView.onResume()
+ getCurrentLocation() // Refresh location on resume
+ }
+
+ fun onPause() {
+ mapView.onPause()
+ }
+
+ fun onStop() {
+ mapView.onStop()
+ }
+
+ fun onDestroy() {
+ mapView.onDestroy()
+ }
+
+ fun onLowMemory() {
+ mapView.onLowMemory()
+ }
+
+ fun onSaveInstanceState(outState: android.os.Bundle) {
+ mapView.onSaveInstanceState(outState)
+ }
+}
diff --git a/app/src/main/res/drawable/legend_gradient.xml b/app/src/main/res/drawable/legend_gradient.xml
new file mode 100644
index 0000000..f2ac46e
--- /dev/null
+++ b/app/src/main/res/drawable/legend_gradient.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index c4793e5..c82135f 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -646,6 +646,13 @@
android:layout_marginTop="8dp" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/raw/map_style_dark.json b/app/src/main/res/raw/map_style_dark.json
new file mode 100644
index 0000000..c3b27fe
--- /dev/null
+++ b/app/src/main/res/raw/map_style_dark.json
@@ -0,0 +1,80 @@
+[
+ {
+ "elementType": "geometry",
+ "stylers": [
+ {
+ "color": "#1a1a1e"
+ }
+ ]
+ },
+ {
+ "elementType": "labels.text.fill",
+ "stylers": [
+ {
+ "color": "#9e9ea8"
+ }
+ ]
+ },
+ {
+ "elementType": "labels.text.stroke",
+ "stylers": [
+ {
+ "color": "#0d0d0f"
+ }
+ ]
+ },
+ {
+ "featureType": "administrative",
+ "elementType": "geometry.stroke",
+ "stylers": [
+ {
+ "color": "#2a2a2e"
+ }
+ ]
+ },
+ {
+ "featureType": "poi",
+ "elementType": "geometry",
+ "stylers": [
+ {
+ "color": "#242428"
+ }
+ ]
+ },
+ {
+ "featureType": "poi.park",
+ "elementType": "geometry",
+ "stylers": [
+ {
+ "color": "#1e3a1e"
+ }
+ ]
+ },
+ {
+ "featureType": "road",
+ "elementType": "geometry",
+ "stylers": [
+ {
+ "color": "#2a2a2e"
+ }
+ ]
+ },
+ {
+ "featureType": "road.highway",
+ "elementType": "geometry",
+ "stylers": [
+ {
+ "color": "#3a3a3e"
+ }
+ ]
+ },
+ {
+ "featureType": "water",
+ "elementType": "geometry",
+ "stylers": [
+ {
+ "color": "#0a0a1e"
+ }
+ ]
+ }
+]
diff --git a/docs/MAP_FEATURE.md b/docs/MAP_FEATURE.md
new file mode 100644
index 0000000..0e54263
--- /dev/null
+++ b/docs/MAP_FEATURE.md
@@ -0,0 +1,175 @@
+# Live Map View Implementation
+
+This document describes the live map view feature implementation for the Open RadiaCode Android app.
+
+## Overview
+
+The live map view displays radiation readings on an interactive Google Map, allowing users to visualize spatial distribution of dose/count rate measurements as they collect data.
+
+## Components
+
+### 1. MapCardView (`ui/MapCardView.kt`)
+
+Custom Android view that integrates Google Maps with radiation data visualization.
+
+**Features:**
+- Interactive Google Map with user location
+- Color-coded markers for readings (green → yellow → red gradient)
+- Metric selector: Dose Rate (μSv/h) or Count Rate (cps/cpm)
+- Dynamic legend showing min/max values
+- Reset button to clear all markers
+- Dark theme map styling
+- Persistent storage of readings with GPS coordinates
+
+**Key Methods:**
+- `onCreate(Bundle)` - Initialize map view (call from Activity lifecycle)
+- `addReading(MapReading)` - Add a reading with GPS coordinates
+- `addReadingAtCurrentLocation(...)` - Add reading using current device location
+- `clearReadings()` - Remove all markers from map
+- `onResume()`, `onPause()`, etc. - Lifecycle methods to forward to MapView
+
+### 2. Data Storage (`Prefs.kt`)
+
+Extended Prefs object with map reading storage.
+
+**New Types:**
+```kotlin
+data class MapReading(
+ val latitude: Double,
+ val longitude: Double,
+ val uSvPerHour: Float,
+ val cps: Float,
+ val timestampMs: Long
+)
+```
+
+**New Methods:**
+- `addMapReading(Context, String, MapReading)` - Store reading with GPS
+- `getMapReadings(Context, String): List` - Retrieve readings for device
+- `clearMapReadings(Context, String)` - Clear all map readings for device
+
+Storage format: Semicolon-separated entries of `timestamp,lat,lon,dose,cps`
+
+### 3. Integration (`MainActivity.kt`)
+
+Map card integrated into dashboard panel.
+
+**Changes:**
+- Added location permissions (ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)
+- Added map card view binding
+- Added `setupMapCard(Bundle)` initialization
+- Added map lifecycle forwarding (onStart, onResume, onPause, onStop, etc.)
+- Added automatic reading updates with GPS location in UI loop
+- Readings automatically saved to map when GPS location is available
+
+## Layout Structure
+
+```xml
+
+
+```
+
+The card includes:
+- Header with title, metric selector dropdown, and reset button
+- Google MapView (300dp height by default)
+- Legend bar with color gradient and min/max labels
+
+## Permissions
+
+Required permissions added to AndroidManifest.xml:
+```xml
+
+
+```
+
+Google Maps API key:
+```xml
+
+```
+
+## Dependencies
+
+Added to `app/build.gradle.kts`:
+```kotlin
+implementation("com.google.android.gms:play-services-maps:18.2.0")
+implementation("com.google.android.gms:play-services-location:21.1.0")
+```
+
+## Color Coding
+
+Readings are color-coded based on normalized value (min to max):
+- **Green** (low): Values near minimum
+- **Yellow** (medium): Mid-range values
+- **Red** (high): Values near maximum
+
+Formula:
+```kotlin
+normalized = (value - min) / (max - min)
+if (normalized < 0.5) {
+ // Green to yellow transition
+ red = normalized * 2 * 255
+ green = 255
+} else {
+ // Yellow to red transition
+ red = 255
+ green = (1 - normalized) * 2 * 255
+}
+```
+
+## Map Styling
+
+Dark theme map style applied via `map_style_dark.json` to match app design:
+- Dark background (#1a1a1e)
+- Muted labels and features
+- Low-contrast roads and boundaries
+- Dark water bodies
+
+## Usage Flow
+
+1. User launches app → Location permissions requested
+2. App connects to RadiaCode device
+3. New readings arrive → GPS location captured
+4. Reading + GPS stored and displayed on map as colored circle
+5. Legend updates to show current min/max range
+6. User can switch between dose rate and count rate views
+7. User can reset map to clear all markers
+8. Readings persist across app restarts (per device)
+
+## Testing Checklist
+
+- [ ] Location permission prompt appears on first launch
+- [ ] Map displays with user location
+- [ ] Readings appear as colored circles when device connected
+- [ ] Colors change appropriately based on value range
+- [ ] Metric selector switches between dose/count rate
+- [ ] Legend shows correct min/max values
+- [ ] Reset button clears all markers
+- [ ] Map retains readings after app restart
+- [ ] Map works with multiple devices (separate data per device)
+- [ ] Dark theme applied correctly
+
+## Known Limitations
+
+1. **API Key**: Uses unrestricted key suitable for development. For production, restrict key to app package and SHA-1 fingerprint.
+
+2. **GPS Accuracy**: Readings require GPS fix. Indoor or poor GPS conditions may result in missing/inaccurate locations.
+
+3. **Memory**: Large numbers of markers (>1000) may impact performance. Current limit: 1000 readings per device.
+
+4. **Network**: Map tiles require internet connectivity. Cached tiles used when offline.
+
+## Future Enhancements
+
+- [ ] Heatmap overlay option
+- [ ] Export map as image
+- [ ] Clustering for high-density areas
+- [ ] Time-based filtering (show last hour/day/week)
+- [ ] Route replay animation
+- [ ] Offline map tile caching
+- [ ] Custom marker shapes/sizes based on value
+- [ ] Map bounds auto-adjustment to show all markers
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755