Start migration away from global top app bar (#3132)

This commit is contained in:
Phil Oliver
2025-09-17 18:38:22 -04:00
committed by GitHub
parent e4bfce0989
commit fed3ebbd36
17 changed files with 685 additions and 466 deletions

View File

@@ -26,6 +26,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.map.rememberMapViewWithLifecycle
@@ -40,6 +41,7 @@ private const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(
navController: NavHostController,
@Suppress("UNUSED_PARAMETER") uiViewModel: UIViewModel = hiltViewModel(),
viewModel: MetricsViewModel = hiltViewModel(),
) {

View File

@@ -17,20 +17,56 @@
package com.geeksville.mesh.ui.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.map.MapView
const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(uiViewModel: UIViewModel, metricsViewModel: MetricsViewModel = hiltViewModel()) {
fun NodeMapScreen(
navController: NavHostController,
uiViewModel: UIViewModel,
metricsViewModel: MetricsViewModel = hiltViewModel(),
) {
val state by metricsViewModel.state.collectAsState()
val positions = state.positionLogs
val destNum = state.node?.num
MapView(uiViewModel = uiViewModel, focusedNodeNum = destNum, nodeTrack = positions, navigateToNodeDetails = {})
val ourNodeInfo by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle()
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = ourNodeInfo,
isConnected = isConnected,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = navController::navigateUp,
actions = {},
onAction = {},
)
},
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
MapView(
uiViewModel = uiViewModel,
focusedNodeNum = destNum,
nodeTrack = positions,
navigateToNodeDetails = {},
)
}
}
}

View File

@@ -42,6 +42,12 @@ fun NavGraphBuilder.connectionsGraph(navController: NavHostController, bluetooth
ConnectionsScreen(
bluetoothViewModel = bluetoothViewModel,
radioConfigViewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
onNavigateToSettings = { navController.navigate(SettingsRoutes.Settings()) },
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> navController.navigate(route) },

View File

@@ -37,6 +37,12 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel:
) {
ContactsScreen(
uiViewModel,
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },

View File

@@ -17,18 +17,58 @@
package com.geeksville.mesh.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import com.geeksville.mesh.R
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.map.MapView
import com.geeksville.mesh.ui.node.components.NodeMenuAction
fun NavGraphBuilder.mapGraph(navController: NavHostController, uiViewModel: UIViewModel) {
composable<MapRoutes.Map>(deepLinks = listOf(navDeepLink<MapRoutes.Map>(basePath = "$DEEP_LINK_BASE_URI/map"))) {
MapView(
uiViewModel = uiViewModel,
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)
val ourNodeInfo by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle()
Scaffold(
topBar = {
MainAppBar(
title = stringResource(R.string.map),
ourNode = ourNodeInfo,
isConnected = isConnected,
showNodeChip = ourNodeInfo != null && isConnected,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> {
navController.navigate(NodesRoutes.NodeDetailGraph(action.node.num)) {
launchSingleTop = true
restoreState = true
}
}
else -> {}
}
},
)
},
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
MapView(
uiViewModel = uiViewModel,
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)
}
}
}
}

View File

@@ -183,7 +183,12 @@ private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenCompos
navController: NavHostController,
uiViewModel: UIViewModel,
routeInfo: NodeDetailRoute,
crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, passedUiViewModel: UIViewModel) -> Unit,
crossinline screenContent:
@Composable (
navController: NavHostController,
metricsViewModel: MetricsViewModel,
passedUiViewModel: UIViewModel,
) -> Unit,
) {
composable<R>(
deepLinks =
@@ -195,7 +200,7 @@ private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenCompos
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
screenContent(metricsViewModel, uiViewModel)
screenContent(navController, metricsViewModel, uiViewModel)
}
}
@@ -203,60 +208,65 @@ enum class NodeDetailRoute(
@StringRes val title: Int,
val route: Route,
val icon: ImageVector?,
val screenComposable: @Composable (metricsViewModel: MetricsViewModel, uiViewModel: UIViewModel) -> Unit,
val screenComposable:
@Composable (
navController: NavHostController,
metricsViewModel: MetricsViewModel,
uiViewModel: UIViewModel,
) -> Unit,
) {
DEVICE(
R.string.device,
NodeDetailRoutes.DeviceMetrics,
Icons.Default.Router,
{ metricsVM, _ -> DeviceMetricsScreen(metricsVM) },
{ _, metricsVM, _ -> DeviceMetricsScreen(metricsVM) },
),
NODE_MAP(
R.string.node_map,
NodeDetailRoutes.NodeMap,
Icons.Default.LocationOn,
{ metricsVM, uiVM -> NodeMapScreen(uiVM, metricsVM) },
{ navController, metricsVM, uiVM -> NodeMapScreen(navController, uiVM, metricsVM) },
),
POSITION_LOG(
R.string.position_log,
NodeDetailRoutes.PositionLog,
Icons.Default.LocationOn,
{ metricsVM, _ -> PositionLogScreen(metricsVM) },
{ _, metricsVM, _ -> PositionLogScreen(metricsVM) },
),
ENVIRONMENT(
R.string.environment,
NodeDetailRoutes.EnvironmentMetrics,
Icons.Default.LightMode,
{ metricsVM, _ -> EnvironmentMetricsScreen(metricsVM) },
{ _, metricsVM, _ -> EnvironmentMetricsScreen(metricsVM) },
),
SIGNAL(
R.string.signal,
NodeDetailRoutes.SignalMetrics,
Icons.Default.CellTower,
{ metricsVM, _ -> SignalMetricsScreen(metricsVM) },
{ _, metricsVM, _ -> SignalMetricsScreen(metricsVM) },
),
TRACEROUTE(
R.string.traceroute,
NodeDetailRoutes.TracerouteLog,
Icons.Default.PermScanWifi,
{ metricsVM, _ -> TracerouteLogScreen(viewModel = metricsVM) },
{ _, metricsVM, _ -> TracerouteLogScreen(viewModel = metricsVM) },
),
POWER(
R.string.power,
NodeDetailRoutes.PowerMetrics,
Icons.Default.Power,
{ metricsVM, _ -> PowerMetricsScreen(metricsVM) },
{ _, metricsVM, _ -> PowerMetricsScreen(metricsVM) },
),
HOST(
R.string.host,
NodeDetailRoutes.HostMetricsLog,
Icons.Default.Memory,
{ metricsVM, _ -> HostMetricsLogScreen(metricsVM) },
{ _, metricsVM, _ -> HostMetricsLogScreen(metricsVM) },
),
PAX(
R.string.pax,
NodeDetailRoutes.PaxMetrics,
Icons.Default.People,
{ metricsVM, _ -> PaxMetricsScreen(metricsVM) },
{ _, metricsVM, _ -> PaxMetricsScreen(metricsVM) },
),
}

View File

@@ -97,7 +97,16 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController, uiViewModel:
) { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
SettingsScreen(uiViewModel = uiViewModel, viewModel = hiltViewModel(parentEntry)) {
SettingsScreen(
uiViewModel = uiViewModel,
viewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
) {
navController.navigate(it) { popUpTo(SettingsRoutes.Settings()) { inclusive = false } }
}
}

View File

@@ -22,6 +22,7 @@ package com.geeksville.mesh.ui
import android.Manifest
import android.os.Build
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
@@ -87,6 +88,7 @@ import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.ConnectionsRoutes
import com.geeksville.mesh.navigation.ContactsRoutes
import com.geeksville.mesh.navigation.MapRoutes
import com.geeksville.mesh.navigation.NodeDetailRoutes
import com.geeksville.mesh.navigation.NodesRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.SettingsRoutes
@@ -135,7 +137,6 @@ enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector,
NodesRoutes.Nodes::class,
MapRoutes.Map::class,
ConnectionsRoutes.Connections::class,
SettingsRoutes.Settings::class,
)
.any { this.hasRoute(it) }
@@ -349,26 +350,42 @@ fun MainScreen(
if (sharedContact != null) {
SharedContactDialog(contact = sharedContact, onDismiss = { sharedContact = null })
}
MainAppBar(
viewModel = uIViewModel,
navController = navController,
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> {
navController.navigate(
NodesRoutes.NodeDetailGraph(action.node.num),
{
launchSingleTop = true
restoreState = true
},
)
}
is NodeMenuAction.Share -> sharedContact = action.node
else -> {}
}
},
)
fun NavDestination.hasGlobalAppBar(): Boolean =
// List of screens to exclude from having the global app bar
listOf(
ConnectionsRoutes.Connections::class,
ContactsRoutes.Contacts::class,
MapRoutes.Map::class,
NodeDetailRoutes.NodeMap::class,
NodesRoutes.Nodes::class,
NodesRoutes.NodeDetail::class,
SettingsRoutes.Settings::class,
)
.none { this.hasRoute(it) }
AnimatedVisibility(visible = currentDestination?.hasGlobalAppBar() ?: true) {
MainAppBar(
viewModel = uIViewModel,
navController = navController,
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> {
navController.navigate(
NodesRoutes.NodeDetailGraph(action.node.num),
{
launchSingleTop = true
restoreState = true
},
)
}
is NodeMenuAction.Share -> sharedContact = action.node
else -> {}
}
},
)
}
NavHost(
navController = navController,

View File

@@ -75,8 +75,6 @@ fun MainAppBar(
}
val longTitle by viewModel.title.collectAsStateWithLifecycle("")
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
@@ -95,17 +93,10 @@ fun MainAppBar(
else -> stringResource(id = R.string.app_name)
}
val subtitle =
if (currentDestination?.hasRoute<NodesRoutes.Nodes>() == true) {
stringResource(R.string.node_count_template, onlineNodeCount, totalNodeCount)
} else {
null
}
MainAppBar(
modifier = modifier,
title = title,
subtitle = subtitle,
subtitle = null,
canNavigateUp = navController.previousBackStackEntry != null && currentDestination?.isTopLevel() == false,
ourNode = ourNode,
isConnected = isConnected,
@@ -125,7 +116,7 @@ fun MainAppBar(
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun MainAppBar(
fun MainAppBar(
modifier: Modifier = Modifier,
title: String,
subtitle: String? = null,

View File

@@ -38,6 +38,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -69,12 +70,14 @@ import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.SettingsRoutes
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.common.components.TitledCard
import com.geeksville.mesh.ui.connections.components.BLEDevices
import com.geeksville.mesh.ui.connections.components.ConnectionsSegmentedBar
import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedCard
import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedInfo
import com.geeksville.mesh.ui.connections.components.NetworkDevices
import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.settings.components.SettingsItem
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialog
@@ -101,6 +104,7 @@ fun ConnectionsScreen(
scanModel: BTScanModel = hiltViewModel(),
bluetoothViewModel: BluetoothViewModel = hiltViewModel(),
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToNodeDetails: (Int) -> Unit,
onConfigNavigate: (Route) -> Unit,
@@ -114,6 +118,7 @@ fun ConnectionsScreen(
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
val context = LocalContext.current
val info by connectionsViewModel.myNodeInfo.collectAsStateWithLifecycle()
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothEnabled by bluetoothViewModel.enabled.collectAsStateWithLifecycle(false)
val regionUnset = config.lora.region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET
@@ -179,171 +184,191 @@ fun ConnectionsScreen(
SharedContactDialog(contact = showSharedContact, onDismiss = { showSharedContact = null })
}
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(scrollState).height(IntrinsicSize.Max).padding(16.dp),
) {
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
AnimatedVisibility(
visible = connectionState.isConnected(),
modifier = Modifier.padding(bottom = 16.dp),
Scaffold(
topBar = {
MainAppBar(
title = stringResource(R.string.connections),
ourNode = ourNode,
isConnected = connectionState.isConnected(),
showNodeChip = ourNode != null && connectionState.isConnected(),
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> onClickNodeChip(action.node.num)
else -> {}
}
},
)
},
) { paddingValues ->
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
Column(
modifier =
Modifier.fillMaxSize()
.verticalScroll(scrollState)
.height(IntrinsicSize.Max)
.padding(paddingValues)
.padding(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ourNode?.let { node ->
Text(
stringResource(R.string.connected_device),
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(8.dp))
CurrentlyConnectedCard(
node = node,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onSetShowSharedContact = { showSharedContact = it },
onNavigateToSettings = onNavigateToSettings,
onClickDisconnect = { scanModel.disconnect() },
)
}
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
SettingsItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(id = R.string.set_your_region),
) {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
AnimatedVisibility(
visible = connectionState.isConnected(),
modifier = Modifier.padding(bottom = 16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ourNode?.let { node ->
TitledCard(title = stringResource(R.string.connected_device)) {
CurrentlyConnectedInfo(
node = node,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onSetShowSharedContact = { showSharedContact = it },
onNavigateToSettings = onNavigateToSettings,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
}
}
}
var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) }
LaunchedEffect(selectedDevice) {
DeviceType.fromAddress(selectedDevice)?.let { type -> selectedDeviceType = type }
}
ConnectionsSegmentedBar(modifier = Modifier.fillMaxWidth()) { selectedDeviceType = it }
Spacer(modifier = Modifier.height(4.dp))
Column(modifier = Modifier.fillMaxSize()) {
when (selectedDeviceType) {
DeviceType.BLE -> {
BLEDevices(
connectionState = connectionState,
btDevices = bleDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
bluetoothEnabled = bluetoothEnabled,
)
}
DeviceType.TCP -> {
NetworkDevices(
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
DeviceType.USB -> {
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Warning Not Paired
val hasShownNotPairedWarning by
connectionsViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle()
val showWarningNotPaired =
!connectionState.isConnected() &&
!hasShownNotPairedWarning &&
bleDevices.none { it is DeviceListEntry.Ble && it.bonded }
if (showWarningNotPaired) {
Text(
text = stringResource(R.string.warning_not_paired),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(Unit) { connectionsViewModel.suppressNoPairedWarning() }
}
}
}
// Compose Device Scan Dialog
if (showScanDialog) {
Dialog(
onDismissRequest = {
showScanDialog = false
scanModel.clearScanResults()
},
) {
Surface(shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Select a Bluetooth device",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp),
)
Column(modifier = Modifier.selectableGroup()) {
scanResults.values.forEach { device ->
Row(
modifier =
Modifier.fillMaxWidth()
.selectable(
selected = false, // No pre-selection in this dialog
onClick = {
scanModel.onSelected(device)
scanModel.clearScanResults()
showScanDialog = false
},
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
SettingsItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(id = R.string.set_your_region),
) {
Text(text = device.name)
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
}
}
}
}
}
var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) }
LaunchedEffect(Unit) { DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } }
ConnectionsSegmentedBar(
selectedDeviceType = selectedDeviceType,
modifier = Modifier.fillMaxWidth(),
) {
selectedDeviceType = it
}
Spacer(modifier = Modifier.height(4.dp))
Column(modifier = Modifier.fillMaxSize()) {
when (selectedDeviceType) {
DeviceType.BLE -> {
BLEDevices(
connectionState = connectionState,
btDevices = bleDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
bluetoothEnabled = bluetoothEnabled,
)
}
DeviceType.TCP -> {
NetworkDevices(
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
DeviceType.USB -> {
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Warning Not Paired
val hasShownNotPairedWarning by
connectionsViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle()
val showWarningNotPaired =
!connectionState.isConnected() &&
!hasShownNotPairedWarning &&
bleDevices.none { it is DeviceListEntry.Ble && it.bonded }
if (showWarningNotPaired) {
Text(
text = stringResource(R.string.warning_not_paired),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = {
scanModel.clearScanResults()
showScanDialog = false
},
) {
Text(stringResource(R.string.cancel))
LaunchedEffect(Unit) { connectionsViewModel.suppressNoPairedWarning() }
}
}
}
// Compose Device Scan Dialog
if (showScanDialog) {
Dialog(
onDismissRequest = {
showScanDialog = false
scanModel.clearScanResults()
},
) {
Surface(shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Select a Bluetooth device",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp),
)
Column(modifier = Modifier.selectableGroup()) {
scanResults.values.forEach { device ->
Row(
modifier =
Modifier.fillMaxWidth()
.selectable(
selected = false, // No pre-selection in this dialog
onClick = {
scanModel.onSelected(device)
scanModel.clearScanResults()
showScanDialog = false
},
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = device.name)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = {
scanModel.clearScanResults()
showScanDialog = false
},
) {
Text(stringResource(R.string.cancel))
}
}
}
}
}
}
}
Box(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text(
text = scanStatusText.orEmpty(),
modifier = Modifier.fillMaxWidth(),
fontSize = 10.sp,
textAlign = TextAlign.End,
)
Box(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text(
text = scanStatusText.orEmpty(),
modifier = Modifier.fillMaxWidth(),
fontSize = 10.sp,
textAlign = TextAlign.End,
)
}
}
}
}

View File

@@ -28,10 +28,6 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@@ -43,19 +39,18 @@ import com.geeksville.mesh.ui.connections.DeviceType
@Suppress("LambdaParameterEventTrailing")
@Composable
fun ConnectionsSegmentedBar(modifier: Modifier = Modifier, onClickDeviceType: (DeviceType) -> Unit) {
var selectedItem by remember { mutableStateOf(Item.BLUETOOTH) }
fun ConnectionsSegmentedBar(
selectedDeviceType: DeviceType,
modifier: Modifier = Modifier,
onClickDeviceType: (DeviceType) -> Unit,
) {
SingleChoiceSegmentedButtonRow(modifier = modifier) {
Item.entries.forEachIndexed { index, item ->
val text = stringResource(item.textRes)
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index, Item.entries.size),
onClick = {
selectedItem = item
onClickDeviceType(item.deviceType)
},
selected = item == selectedItem,
onClick = { onClickDeviceType(item.deviceType) },
selected = item.deviceType == selectedDeviceType,
icon = { Icon(imageVector = item.imageVector, contentDescription = text) },
label = { Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
@@ -72,5 +67,5 @@ private enum class Item(val imageVector: ImageVector, @StringRes val textRes: In
@Preview(showBackground = true)
@Composable
private fun ConnectionsSegmentedBarPreview() {
AppTheme { ConnectionsSegmentedBar {} }
AppTheme { ConnectionsSegmentedBar(selectedDeviceType = DeviceType.BLE) {} }
}

View File

@@ -27,7 +27,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -53,71 +52,69 @@ import com.geeksville.mesh.ui.node.components.NodeChip
import com.geeksville.mesh.ui.node.components.NodeMenuAction
@Composable
fun CurrentlyConnectedCard(
fun CurrentlyConnectedInfo(
node: Node,
modifier: Modifier = Modifier,
onNavigateToNodeDetails: (Int) -> Unit,
onSetShowSharedContact: (Node) -> Unit,
onNavigateToSettings: () -> Unit,
onClickDisconnect: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(modifier = modifier) {
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
NodeChip(
node = node,
isThisNode = true,
isConnected = true,
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> onNavigateToNodeDetails(node.num)
is NodeMenuAction.Share -> onSetShowSharedContact(node)
else -> {}
}
},
)
MaterialBatteryInfo(level = node.batteryLevel)
}
Column(modifier = Modifier.weight(1f, fill = true)) {
Text(text = node.user.longName, style = MaterialTheme.typography.titleMedium)
node.metadata?.firmwareVersion?.let { firmwareVersion ->
Text(
text = stringResource(R.string.firmware_version, firmwareVersion),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(enabled = true, onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(id = R.string.radio_configuration),
)
}
}
Button(
shape = RectangleShape,
modifier = Modifier.fillMaxWidth().height(40.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.StatusRed,
contentColor = Color.White,
),
onClick = onClickDisconnect,
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(stringResource(R.string.disconnect))
NodeChip(
node = node,
isThisNode = true,
isConnected = true,
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> onNavigateToNodeDetails(node.num)
is NodeMenuAction.Share -> onSetShowSharedContact(node)
else -> {}
}
},
)
MaterialBatteryInfo(level = node.batteryLevel)
}
Column(modifier = Modifier.weight(1f, fill = true)) {
Text(text = node.user.longName, style = MaterialTheme.typography.titleMedium)
node.metadata?.firmwareVersion?.let { firmwareVersion ->
Text(
text = stringResource(R.string.firmware_version, firmwareVersion),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(enabled = true, onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(id = R.string.radio_configuration),
)
}
}
Button(
shape = RectangleShape,
modifier = Modifier.fillMaxWidth().height(40.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.StatusRed,
contentColor = Color.White,
),
onClick = onClickDisconnect,
) {
Text(stringResource(R.string.disconnect))
}
}
}
@@ -125,9 +122,9 @@ fun CurrentlyConnectedCard(
@Suppress("MagicNumber")
@PreviewLightDark
@Composable
private fun CurrentlyConnectedCardPreview() {
private fun CurrentlyConnectedInfoPreview() {
AppTheme {
CurrentlyConnectedCard(
CurrentlyConnectedInfo(
node =
Node(
num = 13444,

View File

@@ -57,7 +57,7 @@ private fun UsbDevices(
EmptyStateContent(imageVector = Icons.Rounded.UsbOff, text = stringResource(R.string.no_usb_devices))
else ->
TitledCard(title = "") {
TitledCard(title = null) {
usbDevices.forEach { device ->
DeviceListItem(
connected =

View File

@@ -18,7 +18,6 @@
package com.geeksville.mesh.ui.contact
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -66,6 +65,8 @@ import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.model.Contact
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -73,11 +74,13 @@ import java.util.concurrent.TimeUnit
@Composable
fun ContactsScreen(
uiViewModel: UIViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},
onNavigateToShare: () -> Unit,
) {
val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle()
val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
var showMuteDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -139,6 +142,32 @@ fun ContactsScreen(
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(R.string.conversations),
ourNode = ourNode,
isConnected = isConnected,
showNodeChip = ourNode != null && isConnected,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> onClickNodeChip(action.node.num)
else -> {}
}
},
)
},
floatingActionButton = {
FloatingActionButton(
modifier = Modifier.animateFloatingActionButton(visible = isConnected, alignment = Alignment.BottomEnd),
onClick = onNavigateToShare,
) {
Icon(Icons.Rounded.QrCode2, contentDescription = null)
}
},
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
if (isSelectionModeActive) {
// Display selection toolbar when in selection mode
SelectionToolbar(
@@ -153,26 +182,17 @@ fun ContactsScreen(
isAllMuted = isAllMuted, // Pass the derived state
)
}
},
floatingActionButton = {
FloatingActionButton(
modifier = Modifier.animateFloatingActionButton(visible = isConnected, alignment = Alignment.BottomEnd),
onClick = onNavigateToShare,
) {
Icon(Icons.Rounded.QrCode2, contentDescription = null)
}
},
) { paddingValues ->
val channels by uiViewModel.channels.collectAsStateWithLifecycle()
ContactListView(
contacts = contacts,
selectedList = selectedContactKeys,
onClick = onContactClick,
onLongClick = onContactLongClick,
contentPadding = paddingValues,
channels = channels,
onNodeChipClick = onNodeChipClick,
)
val channels by uiViewModel.channels.collectAsStateWithLifecycle()
ContactListView(
contacts = contacts,
selectedList = selectedContactKeys,
onClick = onContactClick,
onLongClick = onContactLongClick,
channels = channels,
onNodeChipClick = onNodeChipClick,
)
}
}
DeleteConfirmationDialog(
showDialog = showDeleteDialog,
@@ -351,12 +371,11 @@ fun ContactListView(
selectedList: List<String>,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
contentPadding: PaddingValues,
channels: AppOnlyProtos.ChannelSet? = null,
onNodeChipClick: (Contact) -> Unit,
) {
val haptics = LocalHapticFeedback.current
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = contentPadding) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(contacts, key = { it.contactKey }) { contact ->
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }

View File

@@ -92,6 +92,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -138,6 +139,7 @@ import com.geeksville.mesh.navigation.NodeDetailRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.SettingsRoutes
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.common.components.TitledCard
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.common.theme.AppTheme
@@ -176,12 +178,13 @@ private data class DrawableMetricInfo(
val rotateIcon: Float = 0f,
)
@Suppress("LongMethod")
@Composable
fun NodeDetailScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
uiViewModel: UIViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit,
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
@@ -189,6 +192,7 @@ fun NodeDetailScreen(
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val lastTracerouteTime by uiViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
val availableLogs by
remember(state, environmentState) {
@@ -210,29 +214,49 @@ fun NodeDetailScreen(
}
val node = state.node
if (node != null) {
NodeDetailContent(
node = node,
ourNode = ourNode,
metricsState = state,
lastTracerouteTime = lastTracerouteTime,
availableLogs = availableLogs,
uiViewModel = uiViewModel,
onAction = { action ->
handleNodeAction(
action = action,
uiViewModel = uiViewModel,
node = node,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
)
},
modifier = modifier,
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
@Suppress("ModifierNotUsedAtRoot")
Scaffold(
topBar = {
MainAppBar(
title = node?.user?.longName ?: "",
ourNode = ourNode,
isConnected = isConnected,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onAction = {},
)
},
) { paddingValues ->
if (node != null) {
@Suppress("ViewModelForwarding")
NodeDetailContent(
node = node,
ourNode = ourNode,
metricsState = state,
lastTracerouteTime = lastTracerouteTime,
availableLogs = availableLogs,
uiViewModel = uiViewModel,
onAction = { action ->
handleNodeAction(
action = action,
uiViewModel = uiViewModel,
node = node,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
)
},
modifier = modifier.padding(paddingValues),
)
} else {
Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}

View File

@@ -42,14 +42,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.R
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.common.components.rememberTimeTickWithLifecycle
import com.geeksville.mesh.ui.node.components.NodeFilterTextField
import com.geeksville.mesh.ui.node.components.NodeItem
@@ -70,6 +73,8 @@ fun NodeScreen(
val nodes by model.nodeList.collectAsStateWithLifecycle()
val ourNode by model.ourNodeInfo.collectAsStateWithLifecycle()
val onlineNodeCount by model.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by model.totalNodeCount.collectAsStateWithLifecycle(0)
val unfilteredNodes by model.unfilteredNodeList.collectAsStateWithLifecycle()
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
@@ -85,6 +90,19 @@ fun NodeScreen(
val isScrollInProgress by remember { derivedStateOf { listState.isScrollInProgress } }
Scaffold(
topBar = {
MainAppBar(
title = stringResource(R.string.nodes),
subtitle = stringResource(R.string.node_count_template, onlineNodeCount, totalNodeCount),
ourNode = ourNode,
isConnected = connectionState.isConnected(),
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onAction = {},
)
},
floatingActionButton = {
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
val shareCapable = firmwareVersion.supportsQrCodeSharing()

View File

@@ -37,6 +37,7 @@ import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.Output
import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -59,8 +60,10 @@ import com.geeksville.mesh.android.gpsDisabled
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.common.components.TitledCard
import com.geeksville.mesh.ui.common.theme.MODE_DYNAMIC
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.settings.components.SettingsItem
import com.geeksville.mesh.ui.settings.components.SettingsItemDetail
import com.geeksville.mesh.ui.settings.components.SettingsItemSwitch
@@ -83,12 +86,13 @@ import kotlin.time.Duration.Companion.seconds
fun SettingsScreen(
viewModel: RadioConfigViewModel = hiltViewModel(),
uiViewModel: UIViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit = {},
onNavigate: (Route) -> Unit = {},
) {
uiViewModel.setTitle(stringResource(R.string.bottom_nav_settings))
val excludedModulesUnlocked by uiViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by uiViewModel.localConfig.collectAsStateWithLifecycle()
val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
@@ -162,163 +166,183 @@ fun SettingsScreen(
)
}
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(16.dp)) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security.isManaged,
excludedModulesUnlocked = excludedModulesUnlocked,
onRouteClick = { route ->
isWaiting = true
viewModel.setResponseStateLoading(route)
},
onImport = {
viewModel.clearPacketResponse()
deviceProfile = null
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
Scaffold(
topBar = {
MainAppBar(
title = stringResource(R.string.bottom_nav_settings),
ourNode = ourNode,
isConnected = isConnected,
showNodeChip = ourNode != null && isConnected,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> onClickNodeChip(action.node.num)
else -> {}
}
importConfigLauncher.launch(intent)
},
onExport = {
viewModel.clearPacketResponse()
deviceProfile = null
showEditDeviceProfileDialog = true
},
onNavigate = onNavigate,
)
},
)
},
) { paddingValues ->
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security.isManaged,
excludedModulesUnlocked = excludedModulesUnlocked,
onRouteClick = { route ->
isWaiting = true
viewModel.setResponseStateLoading(route)
},
onImport = {
viewModel.clearPacketResponse()
deviceProfile = null
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
}
importConfigLauncher.launch(intent)
},
onExport = {
viewModel.clearPacketResponse()
deviceProfile = null
showEditDeviceProfileDialog = true
},
onNavigate = onNavigate,
)
val context = LocalContext.current
val context = LocalContext.current
TitledCard(title = stringResource(R.string.app_settings), modifier = Modifier.padding(top = 16.dp)) {
if (state.analyticsAvailable) {
SettingsItemSwitch(
text = stringResource(R.string.analytics_okay),
checked = state.analyticsEnabled,
leadingIcon = Icons.Default.BugReport,
onClick = { viewModel.toggleAnalytics() },
)
}
TitledCard(title = stringResource(R.string.app_settings), modifier = Modifier.padding(top = 16.dp)) {
if (state.analyticsAvailable) {
SettingsItemSwitch(
text = stringResource(R.string.analytics_okay),
checked = state.analyticsEnabled,
leadingIcon = Icons.Default.BugReport,
onClick = { viewModel.toggleAnalytics() },
)
}
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
val isGpsDisabled = context.gpsDisabled()
val provideLocation by uiViewModel.provideLocation.collectAsState(false)
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
val isGpsDisabled = context.gpsDisabled()
val provideLocation by uiViewModel.provideLocation.collectAsState(false)
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
if (provideLocation) {
if (locationPermissionsState.allPermissionsGranted) {
if (!isGpsDisabled) {
uiViewModel.meshService?.startProvideLocation()
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
if (provideLocation) {
if (locationPermissionsState.allPermissionsGranted) {
if (!isGpsDisabled) {
uiViewModel.meshService?.startProvideLocation()
} else {
uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
}
} else {
uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
// Request permissions if not granted and user wants to provide location
locationPermissionsState.launchMultiplePermissionRequest()
}
} else {
// Request permissions if not granted and user wants to provide location
locationPermissionsState.launchMultiplePermissionRequest()
}
} else {
uiViewModel.meshService?.stopProvideLocation()
}
}
SettingsItemSwitch(
text = stringResource(R.string.provide_location_to_mesh),
leadingIcon = Icons.Rounded.LocationOn,
enabled = !isGpsDisabled,
checked = provideLocation,
) {
uiViewModel.setProvideLocation(!provideLocation)
}
val languageTags = remember { LanguageUtils.getLanguageTags(context) }
SettingsItem(
text = stringResource(R.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = null,
) {
val lang = LanguageUtils.getLocale()
debug("Lang from prefs: $lang")
val langMap = languageTags.mapValues { (_, value) -> { LanguageUtils.setLocale(value) } }
uiViewModel.showAlert(
title = context.getString(R.string.preferences_language),
message = "",
choices = langMap,
)
}
val themeMap = remember {
mapOf(
context.getString(R.string.dynamic) to MODE_DYNAMIC,
context.getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO,
context.getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES,
context.getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
)
}
SettingsItem(
text = stringResource(R.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
uiViewModel.showAlert(
title = context.getString(R.string.choose_theme),
message = "",
choices = themeMap.mapValues { (_, value) -> { uiViewModel.setTheme(value) } },
)
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val exportRangeTestLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> uiViewModel.saveDataCsv(uri) }
uiViewModel.meshService?.stopProvideLocation()
}
}
SettingsItem(
text = stringResource(R.string.save_rangetest),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_$timestamp.csv")
}
exportRangeTestLauncher.launch(intent)
}
val exportDataLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> uiViewModel.saveDataCsv(uri) }
}
SettingsItemSwitch(
text = stringResource(R.string.provide_location_to_mesh),
leadingIcon = Icons.Rounded.LocationOn,
enabled = !isGpsDisabled,
checked = provideLocation,
) {
uiViewModel.setProvideLocation(!provideLocation)
}
SettingsItem(
text = stringResource(R.string.export_data_csv),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_$timestamp.csv")
val languageTags = remember { LanguageUtils.getLanguageTags(context) }
SettingsItem(
text = stringResource(R.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = null,
) {
val lang = LanguageUtils.getLocale()
debug("Lang from prefs: $lang")
val langMap = languageTags.mapValues { (_, value) -> { LanguageUtils.setLocale(value) } }
uiViewModel.showAlert(
title = context.getString(R.string.preferences_language),
message = "",
choices = langMap,
)
}
val themeMap = remember {
mapOf(
context.getString(R.string.dynamic) to MODE_DYNAMIC,
context.getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO,
context.getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES,
context.getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
)
}
SettingsItem(
text = stringResource(R.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
uiViewModel.showAlert(
title = context.getString(R.string.choose_theme),
message = "",
choices = themeMap.mapValues { (_, value) -> { uiViewModel.setTheme(value) } },
)
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val exportRangeTestLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> uiViewModel.saveDataCsv(uri) }
}
}
exportDataLauncher.launch(intent)
}
SettingsItem(
text = stringResource(R.string.save_rangetest),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_$timestamp.csv")
}
exportRangeTestLauncher.launch(intent)
}
SettingsItem(
text = stringResource(R.string.intro_show),
leadingIcon = Icons.Rounded.WavingHand,
trailingIcon = null,
) {
uiViewModel.showAppIntro()
}
val exportDataLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> uiViewModel.saveDataCsv(uri) }
}
}
SettingsItem(
text = stringResource(R.string.export_data_csv),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_$timestamp.csv")
}
exportDataLauncher.launch(intent)
}
AppVersionButton(excludedModulesUnlocked) { uiViewModel.unlockExcludedModules() }
SettingsItem(
text = stringResource(R.string.intro_show),
leadingIcon = Icons.Rounded.WavingHand,
trailingIcon = null,
) {
uiViewModel.showAppIntro()
}
AppVersionButton(excludedModulesUnlocked) { uiViewModel.unlockExcludedModules() }
}
}
}
}