mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-28 02:32:24 -04:00
refactor: migrate from Hilt to Koin and expand KMP common modules (#4746)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,7 +14,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.node.compass
|
||||
|
||||
import android.content.Context
|
||||
@@ -22,29 +21,19 @@ import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import javax.inject.Inject
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
private const val ROTATION_MATRIX_SIZE = 9
|
||||
private const val ORIENTATION_SIZE = 3
|
||||
private const val FULL_CIRCLE_DEGREES = 360f
|
||||
|
||||
data class HeadingState(
|
||||
val heading: Float? = null, // 0..360 degrees
|
||||
val hasSensor: Boolean = true,
|
||||
val accuracy: Int = SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM,
|
||||
)
|
||||
@Single
|
||||
class AndroidCompassHeadingProvider(private val context: Context) : CompassHeadingProvider {
|
||||
|
||||
class CompassHeadingProvider @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
|
||||
/**
|
||||
* Emits compass heading in degrees (magnetic). Callers can correct for true north using the latest location data
|
||||
* when available.
|
||||
*/
|
||||
fun headingUpdates(): Flow<HeadingState> = callbackFlow {
|
||||
override fun headingUpdates(): Flow<HeadingState> = callbackFlow {
|
||||
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
||||
if (sensorManager == null) {
|
||||
trySend(HeadingState(hasSensor = false))
|
||||
@@ -93,7 +82,7 @@ class CompassHeadingProvider @Inject constructor(@ApplicationContext private val
|
||||
}
|
||||
|
||||
SensorManager.getOrientation(rotationMatrix, orientation)
|
||||
var azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
|
||||
val azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
|
||||
val heading = (azimuth + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
|
||||
|
||||
trySend(HeadingState(heading = heading, hasSensor = true, accuracy = event.accuracy))
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.compass
|
||||
|
||||
import android.hardware.GeomagneticField
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class AndroidMagneticFieldProvider : MagneticFieldProvider {
|
||||
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float {
|
||||
val geomagneticField = GeomagneticField(latitude.toFloat(), longitude.toFloat(), altitude.toFloat(), timeMillis)
|
||||
return geomagneticField.declination
|
||||
}
|
||||
}
|
||||
@@ -25,31 +25,18 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import androidx.core.location.LocationRequestCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PhoneLocationState(
|
||||
val permissionGranted: Boolean,
|
||||
val providerEnabled: Boolean,
|
||||
val location: Location? = null,
|
||||
) {
|
||||
val hasFix: Boolean
|
||||
get() = location != null
|
||||
}
|
||||
@Single
|
||||
class AndroidPhoneLocationProvider(private val context: Context, private val dispatchers: CoroutineDispatchers) :
|
||||
PhoneLocationProvider {
|
||||
|
||||
class PhoneLocationProvider
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
// Streams phone location (and permission/provider state) so the compass stays gated on real fixes.
|
||||
fun locationUpdates(): Flow<PhoneLocationState> = callbackFlow {
|
||||
override fun locationUpdates(): Flow<PhoneLocationState> = callbackFlow {
|
||||
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
|
||||
if (locationManager == null) {
|
||||
trySend(PhoneLocationState(permissionGranted = false, providerEnabled = false))
|
||||
@@ -59,7 +46,7 @@ constructor(
|
||||
|
||||
if (!hasLocationPermission()) {
|
||||
trySend(PhoneLocationState(permissionGranted = false, providerEnabled = false))
|
||||
close() // Just closing it off, like how I'll close my legs around your waist
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
@@ -70,7 +57,7 @@ constructor(
|
||||
PhoneLocationState(
|
||||
permissionGranted = true,
|
||||
providerEnabled = LocationManagerCompat.isLocationEnabled(locationManager),
|
||||
location = lastLocation,
|
||||
location = lastLocation?.toPhoneLocation(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -96,7 +83,6 @@ constructor(
|
||||
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||
|
||||
try {
|
||||
// Get initial fix if available
|
||||
lastLocation =
|
||||
providers
|
||||
.mapNotNull { provider -> locationManager.getLastKnownLocation(provider) }
|
||||
@@ -131,6 +117,9 @@ constructor(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun Location.toPhoneLocation() =
|
||||
PhoneLocation(latitude = latitude, longitude = longitude, altitude = altitude, timeMillis = time)
|
||||
|
||||
companion object {
|
||||
private const val MIN_UPDATE_INTERVAL_MS = 1_000L
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,7 +14,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.vectorResource
|
||||
@@ -52,6 +52,7 @@ import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.exchange_position
|
||||
import org.meshtastic.core.resources.open_compass
|
||||
import org.meshtastic.core.resources.position
|
||||
import org.meshtastic.core.ui.util.LocalInlineMapProvider
|
||||
import org.meshtastic.feature.node.model.LogsType
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
@@ -59,6 +60,7 @@ import org.meshtastic.proto.Config
|
||||
|
||||
private const val EXCHANGE_BUTTON_WEIGHT = 1.1f
|
||||
private const val COMPASS_BUTTON_WEIGHT = 0.9f
|
||||
private const val MAP_HEIGHT_DP = 200
|
||||
|
||||
/**
|
||||
* Displays node position details, last update time, distance, and related actions like requesting position and
|
||||
@@ -126,8 +128,8 @@ fun PositionSection(
|
||||
@Composable
|
||||
private fun PositionMap(node: Node, distance: String?) {
|
||||
Box(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(200.dp)) {
|
||||
InlineMap(node = node, Modifier.fillMaxSize())
|
||||
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(MAP_HEIGHT_DP.dp)) {
|
||||
LocalInlineMapProvider.current(node, Modifier.fillMaxSize())
|
||||
}
|
||||
if (distance != null && distance.isNotEmpty()) {
|
||||
Surface(
|
||||
@@ -17,15 +17,13 @@
|
||||
package org.meshtastic.feature.node.detail
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@Single
|
||||
class NodeDetailActions
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeManagementActions: NodeManagementActions,
|
||||
private val nodeRequestActions: NodeRequestActions,
|
||||
@@ -21,10 +21,7 @@ import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -55,9 +52,9 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.navigation.Route
|
||||
@@ -94,10 +91,11 @@ private sealed interface NodeDetailOverlay {
|
||||
fun NodeDetailScreen(
|
||||
nodeId: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: NodeDetailViewModel = hiltViewModel(),
|
||||
viewModel: NodeDetailViewModel,
|
||||
navigateToMessages: (String) -> Unit = {},
|
||||
onNavigate: (Route) -> Unit = {},
|
||||
onNavigateUp: () -> Unit = {},
|
||||
compassViewModel: CompassViewModel? = null,
|
||||
) {
|
||||
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
|
||||
|
||||
@@ -120,6 +118,7 @@ fun NodeDetailScreen(
|
||||
navigateToMessages = navigateToMessages,
|
||||
onNavigate = onNavigate,
|
||||
onNavigateUp = onNavigateUp,
|
||||
compassViewModel = compassViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,12 +132,13 @@ private fun NodeDetailScaffold(
|
||||
navigateToMessages: (String) -> Unit,
|
||||
onNavigate: (Route) -> Unit,
|
||||
onNavigateUp: () -> Unit,
|
||||
compassViewModel: CompassViewModel? = null,
|
||||
) {
|
||||
var activeOverlay by remember { mutableStateOf<NodeDetailOverlay?>(null) }
|
||||
val inspectionMode = LocalInspectionMode.current
|
||||
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
|
||||
val actualCompassViewModel = compassViewModel ?: if (inspectionMode) null else koinViewModel()
|
||||
val compassUiState by
|
||||
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
|
||||
actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
|
||||
|
||||
val node = uiState.node
|
||||
val listState = rememberLazyListState()
|
||||
@@ -167,7 +167,7 @@ private fun NodeDetailScaffold(
|
||||
when (action) {
|
||||
is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
|
||||
is NodeDetailAction.OpenCompass -> {
|
||||
compassViewModel?.start(action.node, action.displayUnits)
|
||||
actualCompassViewModel?.start(action.node, action.displayUnits)
|
||||
activeOverlay = NodeDetailOverlay.Compass
|
||||
}
|
||||
else ->
|
||||
@@ -186,7 +186,7 @@ private fun NodeDetailScaffold(
|
||||
)
|
||||
}
|
||||
|
||||
NodeDetailOverlays(activeOverlay, node, compassUiState, compassViewModel, { activeOverlay = null }) {
|
||||
NodeDetailOverlays(activeOverlay, node, compassUiState, actualCompassViewModel, { activeOverlay = null }) {
|
||||
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
|
||||
}
|
||||
}
|
||||
@@ -200,12 +200,7 @@ private fun NodeDetailContent(
|
||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = uiState.node != null,
|
||||
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
|
||||
label = "NodeDetailContent",
|
||||
modifier = modifier,
|
||||
) { isNodePresent ->
|
||||
Crossfade(targetState = uiState.node != null, label = "NodeDetailContent", modifier = modifier) { isNodePresent ->
|
||||
if (isNodePresent && uiState.node != null) {
|
||||
NodeDetailList(
|
||||
node = uiState.node,
|
||||
@@ -61,7 +61,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@@ -96,8 +95,8 @@ import org.meshtastic.proto.SharedContact
|
||||
@Composable
|
||||
fun NodeListScreen(
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
viewModel: NodeListViewModel,
|
||||
onNavigateToChannels: () -> Unit = {},
|
||||
viewModel: NodeListViewModel = hiltViewModel(),
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
|
||||
activeNodeId: Int? = null,
|
||||
) {
|
||||
@@ -156,7 +155,9 @@ fun NodeListScreen(
|
||||
alignment = Alignment.BottomEnd,
|
||||
),
|
||||
onImport = { uri ->
|
||||
viewModel.handleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
|
||||
viewModel.handleScannedUri(uri.toString()) {
|
||||
scope.launch { context.showToast(Res.string.channel_invalid) }
|
||||
}
|
||||
},
|
||||
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
|
||||
isContactContext = true,
|
||||
@@ -51,7 +51,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
|
||||
@@ -123,7 +122,7 @@ private val LEGEND_DATA =
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
|
||||
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
|
||||
@@ -46,7 +46,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
@@ -73,7 +72,7 @@ import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
||||
@Composable
|
||||
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val graphData by viewModel.environmentGraphingData.collectAsStateWithLifecycle()
|
||||
val filteredTelemetries by viewModel.filteredEnvironmentMetrics.collectAsStateWithLifecycle()
|
||||
@@ -53,7 +53,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
@@ -78,7 +77,7 @@ import java.text.DecimalFormat
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
||||
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -38,7 +38,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
@@ -61,11 +60,7 @@ import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun NeighborInfoLogScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
onNavigateUp: () -> Unit,
|
||||
) {
|
||||
fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -43,7 +43,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
|
||||
@@ -174,7 +173,7 @@ private fun PaxMetricsChart(
|
||||
|
||||
@Composable
|
||||
@Suppress("MagicNumber", "LongMethod")
|
||||
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
||||
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||
val paxMetrics by metricsViewModel.filteredPaxMetrics.collectAsStateWithLifecycle()
|
||||
val timeFrame by metricsViewModel.timeFrame.collectAsStateWithLifecycle()
|
||||
@@ -59,7 +59,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
@@ -172,7 +171,7 @@ private fun ActionButtons(
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -51,7 +51,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
|
||||
@@ -107,7 +106,7 @@ private val LEGEND_DATA =
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
|
||||
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
|
||||
@@ -47,7 +47,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
|
||||
@@ -85,7 +84,7 @@ private val LEGEND_DATA =
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
|
||||
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
|
||||
@@ -42,7 +42,6 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -83,7 +82,7 @@ import org.meshtastic.proto.RouteDiscovery
|
||||
@Composable
|
||||
fun TracerouteLogScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
viewModel: MetricsViewModel,
|
||||
onNavigateUp: () -> Unit,
|
||||
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
@@ -38,7 +38,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -53,12 +52,13 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Route
|
||||
import org.meshtastic.core.ui.theme.TracerouteColors
|
||||
import org.meshtastic.core.ui.util.LocalMapViewProvider
|
||||
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.proto.Position
|
||||
|
||||
@Composable
|
||||
fun TracerouteMapScreen(
|
||||
metricsViewModel: MetricsViewModel = hiltViewModel(),
|
||||
metricsViewModel: MetricsViewModel,
|
||||
requestId: Int,
|
||||
logUuid: String? = null,
|
||||
onNavigateUp: () -> Unit,
|
||||
@@ -102,6 +102,7 @@ private fun TracerouteMapScaffold(
|
||||
) {
|
||||
var tracerouteNodesShown by remember { mutableStateOf(0) }
|
||||
var tracerouteNodesTotal by remember { mutableStateOf(0) }
|
||||
val insets = LocalTracerouteMapOverlayInsetsProvider.current
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
@@ -128,10 +129,8 @@ private fun TracerouteMapScaffold(
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.align(TracerouteMapOverlayInsets.overlayAlignment)
|
||||
.padding(TracerouteMapOverlayInsets.overlayPadding),
|
||||
horizontalAlignment = TracerouteMapOverlayInsets.contentHorizontalAlignment,
|
||||
modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding),
|
||||
horizontalAlignment = insets.contentHorizontalAlignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal)
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,15 +14,16 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.compass
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
data class HeadingState(
|
||||
val heading: Float? = null, // 0..360 degrees
|
||||
val hasSensor: Boolean = true,
|
||||
val accuracy: Int = 0,
|
||||
)
|
||||
|
||||
internal object TracerouteMapOverlayInsets {
|
||||
val overlayAlignment: Alignment = Alignment.BottomEnd
|
||||
val overlayPadding: PaddingValues = PaddingValues(end = 16.dp, bottom = 16.dp)
|
||||
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.End
|
||||
interface CompassHeadingProvider {
|
||||
fun headingUpdates(): Flow<HeadingState>
|
||||
}
|
||||
@@ -16,11 +16,9 @@
|
||||
*/
|
||||
package org.meshtastic.feature.node.compass
|
||||
|
||||
import android.hardware.GeomagneticField
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -39,7 +37,6 @@ import org.meshtastic.core.model.util.toDistanceString
|
||||
import org.meshtastic.core.ui.component.precisionBitsToMeters
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Position
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.min
|
||||
@@ -54,13 +51,11 @@ private const val SECONDS_PER_MINUTE = 60
|
||||
private const val HUNDRED = 100f
|
||||
private const val MILLIMETERS_PER_METER = 1000f
|
||||
|
||||
@HiltViewModel
|
||||
@Suppress("TooManyFunctions")
|
||||
class CompassViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
open class CompassViewModel(
|
||||
private val headingProvider: CompassHeadingProvider,
|
||||
private val phoneLocationProvider: PhoneLocationProvider,
|
||||
private val magneticFieldProvider: MagneticFieldProvider,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -192,9 +187,8 @@ constructor(
|
||||
private fun applyTrueNorthCorrection(heading: Float?, locationState: PhoneLocationState): Float? {
|
||||
val loc = locationState.location ?: return heading
|
||||
val baseHeading = heading ?: return null
|
||||
val geomagnetic =
|
||||
GeomagneticField(loc.latitude.toFloat(), loc.longitude.toFloat(), loc.altitude.toFloat(), nowMillis)
|
||||
return (baseHeading + geomagnetic.declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
|
||||
val declination = magneticFieldProvider.getDeclination(loc.latitude, loc.longitude, loc.altitude, nowMillis)
|
||||
return (baseHeading + declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
|
||||
}
|
||||
|
||||
private fun formatElapsed(timestampSec: Long): String {
|
||||
@@ -246,6 +240,8 @@ constructor(
|
||||
if (distance <= 0) return FULL_CIRCLE_DEGREES / 2
|
||||
|
||||
val radians = atan2(accuracy.toDouble(), distance.toDouble())
|
||||
return Math.toDegrees(radians).toFloat().coerceIn(0f, FULL_CIRCLE_DEGREES / 2)
|
||||
return radiansToDegrees(radians).toFloat().coerceIn(0f, FULL_CIRCLE_DEGREES / 2)
|
||||
}
|
||||
|
||||
private fun radiansToDegrees(radians: Double): Double = radians * 180.0 / kotlin.math.PI
|
||||
}
|
||||
@@ -14,13 +14,8 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.component
|
||||
package org.meshtastic.feature.node.compass
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.meshtastic.core.model.Node
|
||||
|
||||
@Composable
|
||||
internal fun InlineMap(node: Node, modifier: Modifier = Modifier) {
|
||||
// No-op for F-Droid builds
|
||||
interface MagneticFieldProvider {
|
||||
fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.compass
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
data class PhoneLocation(val latitude: Double, val longitude: Double, val altitude: Double, val timeMillis: Long)
|
||||
|
||||
data class PhoneLocationState(
|
||||
val permissionGranted: Boolean,
|
||||
val providerEnabled: Boolean,
|
||||
val location: PhoneLocation? = null,
|
||||
) {
|
||||
val hasFix: Boolean
|
||||
get() = location != null
|
||||
}
|
||||
|
||||
interface PhoneLocationProvider {
|
||||
fun locationUpdates(): Flow<PhoneLocationState>
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -43,20 +42,8 @@ import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
|
||||
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
|
||||
import org.meshtastic.feature.node.model.LogsType
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* UI state for the Node Details screen.
|
||||
*
|
||||
* @property node The node being viewed, or null if loading.
|
||||
* @property nodeName The display name for the node, resolved in the UI.
|
||||
* @property ourNode Information about the locally connected node.
|
||||
* @property metricsState Aggregated sensor and signal metrics.
|
||||
* @property environmentState Standardized environmental sensor data.
|
||||
* @property availableLogs a set of log types available for this node.
|
||||
* @property lastTracerouteTime Timestamp of the last successful traceroute request.
|
||||
* @property lastRequestNeighborsTime Timestamp of the last successful neighbor info request.
|
||||
*/
|
||||
/** UI state for the Node Details screen. */
|
||||
@androidx.compose.runtime.Stable
|
||||
data class NodeDetailUiState(
|
||||
val node: Node? = null,
|
||||
@@ -73,11 +60,8 @@ data class NodeDetailUiState(
|
||||
* ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class NodeDetailViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
open class NodeDetailViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val nodeManagementActions: NodeManagementActions,
|
||||
private val nodeRequestActions: NodeRequestActions,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
@@ -40,12 +41,9 @@ import org.meshtastic.core.resources.remove
|
||||
import org.meshtastic.core.resources.remove_node_text
|
||||
import org.meshtastic.core.resources.unmute
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@Single
|
||||
class NodeManagementActions
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
@@ -127,10 +125,8 @@ constructor(
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
nodeRepository.setNodeNotes(nodeNum, notes)
|
||||
} catch (ex: java.io.IOException) {
|
||||
Logger.e { "Set node notes IO error: ${ex.message}" }
|
||||
} catch (ex: java.sql.SQLException) {
|
||||
Logger.e { "Set node notes SQL error: ${ex.message}" }
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(ex) { "Set node notes error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.RadioController
|
||||
@@ -45,15 +46,13 @@ import org.meshtastic.core.resources.requesting_from
|
||||
import org.meshtastic.core.resources.signal_quality
|
||||
import org.meshtastic.core.resources.traceroute
|
||||
import org.meshtastic.core.resources.user_info
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
sealed class NodeRequestEffect {
|
||||
data class ShowFeedback(val text: UiText) : NodeRequestEffect()
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class NodeRequestActions @Inject constructor(private val radioController: RadioController) {
|
||||
@Single
|
||||
class NodeRequestActions constructor(private val radioController: RadioController) {
|
||||
|
||||
private val _effects = MutableSharedFlow<NodeRequestEffect>()
|
||||
val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,15 +14,11 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.di
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal object TracerouteMapOverlayInsets {
|
||||
val overlayAlignment: Alignment = Alignment.BottomCenter
|
||||
val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp)
|
||||
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally
|
||||
}
|
||||
@Module
|
||||
@ComponentScan("org.meshtastic.feature.node")
|
||||
class FeatureNodeModule
|
||||
@@ -18,15 +18,16 @@ package org.meshtastic.feature.node.domain.usecase
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeSortOption
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.feature.node.list.NodeFilterState
|
||||
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
||||
import org.meshtastic.proto.Config
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetFilteredNodesUseCase @Inject constructor(private val nodeRepository: NodeRepository) {
|
||||
@Single
|
||||
class GetFilteredNodesUseCase constructor(private val nodeRepository: NodeRepository) {
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
operator fun invoke(filter: NodeFilterState, sort: NodeSortOption): Flow<List<Node>> = nodeRepository
|
||||
.getNodes(
|
||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
@@ -49,10 +50,9 @@ import org.meshtastic.proto.FirmwareEdition
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import javax.inject.Inject
|
||||
|
||||
@Single
|
||||
class GetNodeDetailsUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
@@ -17,11 +17,12 @@
|
||||
package org.meshtastic.feature.node.list
|
||||
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.model.NodeSortOption
|
||||
import javax.inject.Inject
|
||||
|
||||
class NodeFilterPreferences @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
|
||||
@Single
|
||||
class NodeFilterPreferences constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
|
||||
val includeUnknown = uiPreferencesDataSource.includeUnknown
|
||||
val excludeInfrastructure = uiPreferencesDataSource.excludeInfrastructure
|
||||
val onlyOnline = uiPreferencesDataSource.onlyOnline
|
||||
@@ -16,11 +16,9 @@
|
||||
*/
|
||||
package org.meshtastic.feature.node.list
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -28,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeSortOption
|
||||
import org.meshtastic.core.model.RadioController
|
||||
@@ -41,13 +40,9 @@ import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@HiltViewModel
|
||||
class NodeListViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
open class NodeListViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
@@ -138,7 +133,8 @@ constructor(
|
||||
}
|
||||
|
||||
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
|
||||
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
|
||||
fun handleScannedUri(uriString: String, onInvalid: () -> Unit) {
|
||||
val uri = CommonUri.parse(uriString)
|
||||
uri.dispatchMeshtasticUri(
|
||||
onContact = { _sharedContactRequested.value = it },
|
||||
onChannel = { _requestChannelSet.value = it },
|
||||
@@ -16,17 +16,12 @@
|
||||
*/
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import co.touchlab.kermit.Logger
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -40,11 +35,8 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.common.util.toDate
|
||||
import org.meshtastic.core.common.util.toInstant
|
||||
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
@@ -52,7 +44,6 @@ import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||
import org.meshtastic.core.model.util.UnitConversions
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.repository.MeshLogRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
@@ -71,26 +62,15 @@ import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import java.io.BufferedWriter
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileWriter
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import org.meshtastic.proto.Paxcount as ProtoPaxcount
|
||||
|
||||
/**
|
||||
* ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node.
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class MetricsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val app: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
open class MetricsViewModel(
|
||||
val destNum: Int,
|
||||
protected val dispatchers: CoroutineDispatchers,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
@@ -100,8 +80,8 @@ constructor(
|
||||
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
|
||||
) : ViewModel() {
|
||||
|
||||
private val nodeIdFromRoute: Int? =
|
||||
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
|
||||
private val nodeIdFromRoute: Int?
|
||||
get() = destNum
|
||||
|
||||
private val manualNodeId = MutableStateFlow<Int?>(null)
|
||||
private val activeNodeId =
|
||||
@@ -134,7 +114,8 @@ constructor(
|
||||
val availableTimeFrames: StateFlow<List<TimeFrame>> =
|
||||
combine(state, environmentState) { currentState, envState ->
|
||||
val stateOldest = currentState.oldestTimestampSeconds()
|
||||
val envOldest = envState.environmentMetrics.minOfOrNull { it.time.toLong() }?.takeIf { it > 0 }
|
||||
val envOldest =
|
||||
envState.environmentMetrics.minOfOrNull { it.time.toLong() }?.takeIf { it > 0 } ?: nowSeconds
|
||||
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: nowSeconds
|
||||
TimeFrame.entries.filter { it.isAvailable(oldest) }
|
||||
}
|
||||
@@ -331,44 +312,10 @@ constructor(
|
||||
Logger.d { "MetricsViewModel cleared" }
|
||||
}
|
||||
|
||||
fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) {
|
||||
val positions = state.value.positionLogs
|
||||
writeToUri(uri) { writer ->
|
||||
writer.appendLine(
|
||||
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"",
|
||||
)
|
||||
|
||||
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
|
||||
|
||||
positions.forEach { position ->
|
||||
val rxDateTime = dateFormat.format((position.time.toLong() * 1000L).toInstant().toDate())
|
||||
val latitude = (position.latitude_i ?: 0) * 1e-7
|
||||
val longitude = (position.longitude_i ?: 0) * 1e-7
|
||||
val altitude = position.altitude
|
||||
val satsInView = position.sats_in_view
|
||||
val speed = position.ground_speed
|
||||
val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
|
||||
|
||||
writer.appendLine(
|
||||
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"",
|
||||
)
|
||||
}
|
||||
}
|
||||
open fun savePositionCSV(uri: Any) {
|
||||
// To be implemented in platform-specific subclass
|
||||
}
|
||||
|
||||
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) =
|
||||
withContext(dispatchers.io) {
|
||||
try {
|
||||
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
|
||||
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
|
||||
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
|
||||
}
|
||||
}
|
||||
} catch (ex: FileNotFoundException) {
|
||||
Logger.e(ex) { "Can't write file error" }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
|
||||
fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? {
|
||||
try {
|
||||
@@ -379,25 +326,26 @@ constructor(
|
||||
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
|
||||
if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) return pax
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Failed to parse Paxcount from binary data" }
|
||||
}
|
||||
try {
|
||||
val base64 = log.raw_message.trim()
|
||||
if (base64.matches(Regex("^[A-Za-z0-9+/=\\r\\n]+$"))) {
|
||||
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
||||
val bytes = decodeBase64(base64)
|
||||
return ProtoPaxcount.ADAPTER.decode(bytes)
|
||||
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
|
||||
val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
return ProtoPaxcount.ADAPTER.decode(bytes)
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.e(e) { "Failed to parse Paxcount from decoded data" }
|
||||
} catch (e: IOException) {
|
||||
Logger.e(e) { "Failed to parse Paxcount from decoded data" }
|
||||
} catch (e: NumberFormatException) {
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Failed to parse Paxcount from decoded data" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
protected open fun decodeBase64(base64: String): ByteArray {
|
||||
// To be overridden in platform-specific subclass or use KMP library
|
||||
return ByteArray(0)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.google.android.gms.maps.model.CameraPosition
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.maps.android.compose.Circle
|
||||
import com.google.maps.android.compose.ComposeMapColorScheme
|
||||
import com.google.maps.android.compose.GoogleMap
|
||||
import com.google.maps.android.compose.MapUiSettings
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.MarkerComposable
|
||||
import com.google.maps.android.compose.rememberCameraPositionState
|
||||
import com.google.maps.android.compose.rememberUpdatedMarkerState
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.component.precisionBitsToMeters
|
||||
|
||||
private const val DEFAULT_ZOOM = 15f
|
||||
|
||||
@OptIn(MapsComposeExperimentalApi::class)
|
||||
@Composable
|
||||
internal fun InlineMap(node: Node, modifier: Modifier = Modifier) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val mapColorScheme =
|
||||
when (dark) {
|
||||
true -> ComposeMapColorScheme.DARK
|
||||
else -> ComposeMapColorScheme.LIGHT
|
||||
}
|
||||
key(node.num) {
|
||||
val location = LatLng(node.latitude, node.longitude)
|
||||
val cameraState = rememberCameraPositionState {
|
||||
position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM)
|
||||
}
|
||||
|
||||
GoogleMap(
|
||||
mapColorScheme = mapColorScheme,
|
||||
modifier = modifier,
|
||||
uiSettings =
|
||||
MapUiSettings(
|
||||
zoomControlsEnabled = true,
|
||||
mapToolbarEnabled = false,
|
||||
compassEnabled = false,
|
||||
myLocationButtonEnabled = false,
|
||||
rotationGesturesEnabled = false,
|
||||
scrollGesturesEnabled = false,
|
||||
tiltGesturesEnabled = false,
|
||||
zoomGesturesEnabled = false,
|
||||
),
|
||||
cameraPositionState = cameraState,
|
||||
) {
|
||||
val precisionMeters = precisionBitsToMeters(node.position.precision_bits ?: 0)
|
||||
val latLng = LatLng(node.latitude, node.longitude)
|
||||
if (precisionMeters > 0) {
|
||||
Circle(
|
||||
center = latLng,
|
||||
radius = precisionMeters,
|
||||
fillColor = Color(node.colors.second).copy(alpha = 0.2f),
|
||||
strokeColor = Color(node.colors.second),
|
||||
strokeWidth = 2f,
|
||||
)
|
||||
}
|
||||
MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user