mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-11 04:53:45 -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 parent
a5390a80e7
commit
875cf1cff2
440 files changed
+3738
-3508
No files matched your search
+6
-17
@@ -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))
|
||||
+28
@@ -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
|
||||
}
|
||||
}
|
||||
+10
-21
@@ -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
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-2
@@ -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
|
||||
-1
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+4
-2
@@ -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(
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-4
@@ -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,
|
||||
+11
-16
@@ -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,
|
||||
+4
-3
@@ -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,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-2
@@ -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()
|
||||
File renamed without changes.
+1
-2
@@ -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()
|
||||
File renamed without changes.
+1
-2
@@ -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() }
|
||||
|
||||
+1
-6
@@ -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() }
|
||||
|
||||
+1
-2
@@ -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()
|
||||
+1
-2
@@ -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() }
|
||||
|
||||
+1
-2
@@ -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()
|
||||
+1
-2
@@ -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()
|
||||
File renamed without changes.
+1
-2
@@ -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 = { _, _ -> },
|
||||
) {
|
||||
+5
-6
@@ -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)
|
||||
File renamed without changes.
File renamed without changes.
+10
-9
@@ -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>
|
||||
}
|
||||
File renamed without changes.
+7
-11
@@ -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
|
||||
}
|
||||
+3
-8
@@ -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
|
||||
}
|
||||
+34
@@ -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>
|
||||
}
|
||||
File renamed without changes.
+3
-19
@@ -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,
|
||||
+4
-8
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -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()
|
||||
+7
-11
@@ -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
|
||||
+3
-2
@@ -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(
|
||||
+2
-2
@@ -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,
|
||||
+3
-2
@@ -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
|
||||
+4
-8
@@ -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 },
|
||||
File renamed without changes.
+17
-69
@@ -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)
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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