diff --git a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt index fb7d761b1..7a16673fc 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt @@ -61,6 +61,7 @@ import com.geeksville.mesh.model.MetricsViewModel import com.geeksville.mesh.model.RadioConfigViewModel import com.geeksville.mesh.ui.components.DeviceMetricsScreen import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen +import com.geeksville.mesh.ui.components.NodeMapScreen import com.geeksville.mesh.ui.components.PositionLogScreen import com.geeksville.mesh.ui.components.SignalMetricsScreen import com.geeksville.mesh.ui.components.TracerouteLogScreen @@ -251,6 +252,10 @@ fun NavGraph( val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } DeviceMetricsScreen(hiltViewModel(parentEntry)) } + composable("NodeMap") { + val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } + NodeMapScreen(hiltViewModel(parentEntry)) + } composable("PositionLog") { val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } PositionLogScreen(hiltViewModel(parentEntry)) diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt index 6ad598353..d74448628 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyOff import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.Numbers import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Power @@ -100,7 +101,6 @@ fun NodeDetailScreen( } } -@Suppress("LongMethod") @Composable private fun NodeDetailList( node: NodeEntity, @@ -135,50 +135,20 @@ private fun NodeDetailList( } item { - NavCard( - title = stringResource(R.string.device_metrics_log), - icon = Icons.Default.ChargingStation, - enabled = metricsState.hasDeviceMetrics() - ) { - onNavigate("DeviceMetrics") - } - - NavCard( - title = stringResource(R.string.position_log), - icon = Icons.Default.LocationOn, - enabled = metricsState.hasPositionLogs() - ) { onNavigate("PositionLog") } - - NavCard( - title = stringResource(R.string.env_metrics_log), - icon = Icons.Default.Thermostat, - enabled = metricsState.hasEnvironmentMetrics() - ) { - onNavigate("EnvironmentMetrics") - } - - NavCard( - title = stringResource(R.string.sig_metrics_log), - icon = Icons.Default.SignalCellularAlt, - enabled = metricsState.hasSignalMetrics() - ) { - onNavigate("SignalMetrics") - } - - NavCard( - title = stringResource(R.string.traceroute_log), - icon = Icons.Default.Route, - enabled = metricsState.hasTracerouteLogs() - ) { - onNavigate("TracerouteList") - } + PreferenceCategory(stringResource(id = R.string.logs)) + LogNavigationList(metricsState, onNavigate) + } - NavCard( - title = "Remote Administration", - icon = Icons.Default.Settings, - enabled = !metricsState.isManaged - ) { - onNavigate("RadioConfig") + if (!metricsState.isManaged) { + item { + PreferenceCategory(stringResource(id = R.string.administration)) + NavCard( + title = stringResource(id = R.string.remote_admin), + icon = Icons.Default.Settings, + enabled = true + ) { + onNavigate("RadioConfig") + } } } } @@ -260,6 +230,57 @@ private fun NodeDetailsContent(node: NodeEntity) { ) } +@Composable +fun LogNavigationList(state: MetricsState, onNavigate: (String) -> Unit) { + NavCard( + title = stringResource(R.string.device_metrics_log), + icon = Icons.Default.ChargingStation, + enabled = state.hasDeviceMetrics() + ) { + onNavigate("DeviceMetrics") + } + + NavCard( + title = stringResource(R.string.node_map), + icon = Icons.Default.Map, + enabled = state.hasPositionLogs() + ) { + onNavigate("NodeMap") + } + + NavCard( + title = stringResource(R.string.position_log), + icon = Icons.Default.LocationOn, + enabled = state.hasPositionLogs() + ) { + onNavigate("PositionLog") + } + + NavCard( + title = stringResource(R.string.env_metrics_log), + icon = Icons.Default.Thermostat, + enabled = state.hasEnvironmentMetrics() + ) { + onNavigate("EnvironmentMetrics") + } + + NavCard( + title = stringResource(R.string.sig_metrics_log), + icon = Icons.Default.SignalCellularAlt, + enabled = state.hasSignalMetrics() + ) { + onNavigate("SignalMetrics") + } + + NavCard( + title = stringResource(R.string.traceroute_log), + icon = Icons.Default.Route, + enabled = state.hasTracerouteLogs() + ) { + onNavigate("TracerouteList") + } +} + @Composable private fun InfoCard( icon: ImageVector, diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMap.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMap.kt new file mode 100644 index 000000000..e179038b9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMap.kt @@ -0,0 +1,84 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.ui.map.rememberMapViewWithLifecycle +import com.geeksville.mesh.util.addCopyright +import com.geeksville.mesh.util.addPositionMarkers +import com.geeksville.mesh.util.addPolyline +import com.geeksville.mesh.util.addScaleBarOverlay +import com.geeksville.mesh.util.requiredZoomLevel +import org.osmdroid.config.Configuration +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController + +private const val DegD = 1e-7 + +@Composable +fun NodeMapScreen( + viewModel: MetricsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val density = LocalDensity.current + val mapView = rememberMapViewWithLifecycle(context) + + val state by viewModel.state.collectAsStateWithLifecycle() + val geoPoints = state.positionLogs.map { GeoPoint(it.latitudeI * DegD, it.longitudeI * DegD) } + + var savedCenter by rememberSaveable(stateSaver = Saver( + save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) }, + restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) } + )) { + val box = BoundingBox.fromGeoPoints(geoPoints) + mutableStateOf(GeoPoint(box.centerLatitude, box.centerLongitude)) + } + var savedZoom by rememberSaveable { + val box = BoundingBox.fromGeoPoints(geoPoints) + mutableDoubleStateOf(box.requiredZoomLevel()) + } + + LifecycleStartEffect(true) { + onStopOrDispose { + savedCenter = mapView.projection.currentCenter + savedZoom = mapView.zoomLevelDouble + } + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + mapView.apply { + Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID + setMultiTouchControls(true) + isTilesScaledToDpi = true + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + controller.setCenter(savedCenter) + controller.setZoom(savedZoom) + } + }, + update = { map -> + map.overlays.clear() + map.addCopyright() + map.addScaleBarOverlay(density) + + map.addPolyline(density, geoPoints) {} + map.addPositionMarkers(state.positionLogs) {} + } + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt b/app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt index 61d7961ec..7a88eb224 100644 --- a/app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt +++ b/app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt @@ -1,18 +1,26 @@ package com.geeksville.mesh.util import android.graphics.Color +import android.graphics.DashPathEffect import android.graphics.Paint import android.graphics.Typeface import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.R import org.osmdroid.events.DelayedMapListener import org.osmdroid.events.MapListener import org.osmdroid.events.ScrollEvent import org.osmdroid.events.ZoomEvent +import org.osmdroid.util.GeoPoint import org.osmdroid.views.MapView import org.osmdroid.views.overlay.CopyrightOverlay +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.ScaleBarOverlay +import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2 /** @@ -81,3 +89,62 @@ fun MapView.addMapEventListener(onEvent: () -> Unit) { } }, INACTIVITY_DELAY_MILLIS)) } + +fun MapView.addPolyline( + density: Density, + geoPoints: List, + onClick: () -> Unit +): Polyline { + val polyline = Polyline(this).apply { + val borderPaint = Paint().apply { + color = Color.BLACK + isAntiAlias = true + strokeWidth = with(density) { 10.dp.toPx() } + style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) + } + outlinePaintLists.add(MonochromaticPaintList(borderPaint)) + val fillPaint = Paint().apply { + color = Color.WHITE + isAntiAlias = true + strokeWidth = with(density) { 6.dp.toPx() } + style = Paint.Style.FILL_AND_STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) + } + outlinePaintLists.add(MonochromaticPaintList(fillPaint)) + setPoints(geoPoints) + setOnClickListener { _, _, _ -> + onClick() + true + } + } + overlays.add(polyline) + + return polyline +} + +fun MapView.addPositionMarkers( + positions: List, + onClick: () -> Unit +): List { + val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation_24) + val markers = positions.map { + Marker(this).apply { + icon = navIcon + rotation = (it.groundTrack * 1e-5).toFloat() + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + position = GeoPoint(it.latitudeI * 1e-7, it.longitudeI * 1e-7) + setOnMarkerClickListener { _, _ -> + onClick() + true + } + } + } + overlays.addAll(markers) + + return markers +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1a1a9c8a..7a0f586ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,9 +280,12 @@ Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection. (Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500. Device Metrics Log + Node Map Position Log Environment Metrics Log Signal Metrics Log + Administration + Remote Administration Bad Fair Good diff --git a/config/detekt/detekt-baseline.xml b/config/detekt/detekt-baseline.xml index ac48b704d..87a1b0797 100644 --- a/config/detekt/detekt-baseline.xml +++ b/config/detekt/detekt-baseline.xml @@ -269,8 +269,12 @@ MagicNumber:MapFragment.kt$12F MagicNumber:MapFragment.kt$1e-7 MagicNumber:MapFragment.kt$<no name provided>$1e7 + MagicNumber:MapViewExtensions.kt$1e-5 + MagicNumber:MapViewExtensions.kt$1e-7 MagicNumber:MapViewExtensions.kt$3.0f MagicNumber:MapViewExtensions.kt$40f + MagicNumber:MapViewExtensions.kt$60f + MagicNumber:MapViewExtensions.kt$80f MagicNumber:MarkerWithLabel.kt$MarkerWithLabel$3 MagicNumber:MeshService.kt$MeshService$0xffffffff MagicNumber:MeshService.kt$MeshService$100