refactor: decouple feature/connections from feature/settings

- Move ResponseState<T> to core/model
- Move PacketResponseStateDialog to core/ui/component
- Create RadioConfigStateProvider interface in core/model
- RadioConfigViewModel implements RadioConfigStateProvider
- ConnectionsScreen accepts interface instead of concrete VM
- ConnectionsNavigation receives provider via lambda from app/desktop
- Remove projects.feature.settings dependency from connections build

This eliminates a feature→feature dependency, improving build
parallelism and enforcing proper module boundaries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-06 13:18:49 -05:00
parent b3542c76aa
commit 07214bd307
15 changed files with 110 additions and 35 deletions

View File

@@ -44,6 +44,7 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
import org.meshtastic.feature.node.navigation.nodesGraph
@@ -86,7 +87,7 @@ fun MainScreen() {
)
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
connectionsGraph(backStack) { koinViewModel<RadioConfigViewModel>() }
settingsGraph(backStack)
firmwareGraph(backStack)
wifiProvisionGraph(backStack)

View File

@@ -21,9 +21,12 @@ import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.RadioConfigStateProvider
import org.meshtastic.core.model.ResponseState
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
@@ -49,7 +52,14 @@ class NavigationAssemblyTest {
nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow())
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
connectionsGraph(backStack) {
object : RadioConfigStateProvider {
override val packetResponseState = MutableStateFlow<ResponseState<Boolean>>(ResponseState.Empty)
override val pendingRouteName = MutableStateFlow("")
override fun requestConfigLoad(routeName: String) {}
override fun clearPacketResponse() {}
}
}
settingsGraph(backStack)
firmwareGraph(backStack)
}

View File

@@ -0,0 +1,38 @@
/*
* 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
* 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.core.model
import kotlinx.coroutines.flow.StateFlow
/**
* Minimal interface exposing radio-config packet response state.
* Used by feature/connections to observe config-loading progress without
* depending on the full RadioConfigViewModel in feature/settings.
*/
interface RadioConfigStateProvider {
/** Current packet response state (loading/success/error/empty). */
val packetResponseState: StateFlow<ResponseState<Boolean>>
/** Route name associated with the pending config request (e.g. "LORA"). */
val pendingRouteName: StateFlow<String>
/** Initiate a config load for the given route name. */
fun requestConfigLoad(routeName: String)
/** Clear the packet response, resetting to [ResponseState.Empty]. */
fun clearPacketResponse()
}

View File

@@ -14,7 +14,7 @@
* 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.settings.radio
package org.meshtastic.core.model
import org.meshtastic.core.resources.UiText

View File

@@ -14,7 +14,7 @@
* 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.settings.radio.component
package org.meshtastic.core.ui.component
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
@@ -36,17 +36,16 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.ResponseState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delivery_confirmed
import org.meshtastic.core.resources.delivery_confirmed_reboot_warning
import org.meshtastic.core.resources.error
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Error
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Success
import org.meshtastic.feature.settings.radio.ResponseState
private const val AUTO_DISMISS_DELAY_MS = 1500L

View File

@@ -19,6 +19,7 @@ package org.meshtastic.desktop.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.ui.viewmodel.UIViewModel
@@ -28,6 +29,7 @@ import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
import org.meshtastic.feature.node.navigation.nodesGraph
import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
@@ -53,6 +55,6 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(
firmwareGraph(backStack)
settingsGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
connectionsGraph(backStack) { koinViewModel<RadioConfigViewModel>() }
wifiProvisionGraph(backStack)
}

View File

@@ -40,7 +40,6 @@ kotlin {
implementation(projects.core.ui)
implementation(projects.core.ble)
implementation(projects.core.network)
implementation(projects.feature.settings)
implementation(libs.jetbrains.navigation3.ui)
}

View File

@@ -16,22 +16,26 @@
*/
package org.meshtastic.feature.connections.navigation
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.RadioConfigStateProvider
import org.meshtastic.core.navigation.ConnectionsRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.ui.ConnectionsScreen
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoute.Connections]. */
fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
fun EntryProviderScope<NavKey>.connectionsGraph(
backStack: NavBackStack<NavKey>,
radioConfigStateProvider: @Composable () -> RadioConfigStateProvider,
) {
entry<ConnectionsRoute.ConnectionsGraph> {
ConnectionsScreen(
scanModel = koinViewModel<ScannerViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
radioConfigStateProvider = radioConfigStateProvider(),
onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) },
onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) },
onConfigNavigate = { route -> backStack.add(route) },
@@ -41,7 +45,7 @@ fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>)
entry<ConnectionsRoute.Connections> {
ConnectionsScreen(
scanModel = koinViewModel<ScannerViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
radioConfigStateProvider = radioConfigStateProvider(),
onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) },
onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) },
onConfigNavigate = { route -> backStack.add(route) },

View File

@@ -78,10 +78,8 @@ import org.meshtastic.feature.connections.ui.components.ConnectingDeviceInfo
import org.meshtastic.feature.connections.ui.components.CurrentlyConnectedInfo
import org.meshtastic.feature.connections.ui.components.DeviceList
import org.meshtastic.feature.connections.ui.components.TransportFilterChips
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.core.model.RadioConfigStateProvider
import org.meshtastic.core.ui.component.PacketResponseStateDialog
import kotlin.uuid.ExperimentalUuidApi
/**
@@ -98,12 +96,13 @@ private val CardMinHeight = 100.dp
fun ConnectionsScreen(
connectionsViewModel: ConnectionsViewModel = koinViewModel(),
scanModel: ScannerViewModel = koinViewModel(),
radioConfigViewModel: RadioConfigViewModel = koinViewModel(),
radioConfigStateProvider: RadioConfigStateProvider,
onClickNodeChip: (Int) -> Unit,
onNavigateToNodeDetails: (Int) -> Unit,
onConfigNavigate: (Route) -> Unit,
) {
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val responseState by radioConfigStateProvider.packetResponseState.collectAsStateWithLifecycle()
val pendingRoute by radioConfigStateProvider.pendingRouteName.collectAsStateWithLifecycle()
val connectionProgress by scanModel.connectionProgressText.collectAsStateWithLifecycle()
val connectionStatus by connectionsViewModel.connectionStatus.collectAsStateWithLifecycle()
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
@@ -153,18 +152,16 @@ fun ConnectionsScreen(
var isWaiting by remember { mutableStateOf(false) }
if (isWaiting) {
PacketResponseStateDialog(
state = radioConfigState.responseState,
state = responseState,
onDismiss = {
isWaiting = false
radioConfigViewModel.clearPacketResponse()
radioConfigStateProvider.clearPacketResponse()
},
onComplete = {
getNavRouteFrom(radioConfigState.route)?.let { route ->
if (pendingRoute == "LORA") {
isWaiting = false
radioConfigViewModel.clearPacketResponse()
if (route == SettingsRoute.LoRa) {
onConfigNavigate(SettingsRoute.LoRa)
}
radioConfigStateProvider.clearPacketResponse()
onConfigNavigate(SettingsRoute.LoRa)
}
},
)
@@ -264,7 +261,7 @@ fun ConnectionsScreen(
text = stringResource(Res.string.set_your_region),
onClick = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
radioConfigStateProvider.requestConfigLoad("LORA")
},
)
}

View File

@@ -51,9 +51,9 @@ import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.radio.AdminRoute
import org.meshtastic.feature.settings.radio.RadioConfigState
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.ResponseState
import org.meshtastic.core.model.ResponseState
import org.meshtastic.feature.settings.radio.component.LoadingOverlay
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.core.ui.component.PacketResponseStateDialog
import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
import org.meshtastic.feature.settings.radio.component.WarningDialog

View File

@@ -22,12 +22,15 @@ import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
@@ -48,6 +51,7 @@ import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioConfigStateProvider
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.HomoglyphPrefs
@@ -80,6 +84,7 @@ import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
import org.meshtastic.core.model.ResponseState
/** Data class that represents the current RadioConfig state. */
@androidx.compose.runtime.Immutable
@@ -124,7 +129,7 @@ open class RadioConfigViewModel(
private val locationService: LocationService,
private val fileService: FileService,
private val mqttManager: MqttManager,
) : ViewModel() {
) : ViewModel(), RadioConfigStateProvider {
val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed
fun toggleAnalyticsAllowed() {
@@ -398,10 +403,29 @@ open class RadioConfigViewModel(
safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
}
fun clearPacketResponse() {
// region RadioConfigStateProvider implementation
override val packetResponseState: StateFlow<ResponseState<Boolean>> =
_radioConfigState.map { it.responseState }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ResponseState.Empty)
override val pendingRouteName: StateFlow<String> =
_radioConfigState.map { it.route }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "")
override fun requestConfigLoad(routeName: String) {
val route = ConfigRoute.entries.find { it.name == routeName }
?: ModuleRoute.entries.find { it.name == routeName }
?: return
setResponseStateLoading(route)
}
override fun clearPacketResponse() {
_radioConfigState.update { it.copy(responseState = ResponseState.Empty) }
}
// endregion
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNumFlow.value ?: destNode.value?.num ?: return

View File

@@ -65,14 +65,14 @@ import org.meshtastic.core.ui.component.rememberDragDropState
import org.meshtastic.core.ui.icon.Add
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.ResponseState
import org.meshtastic.core.model.ResponseState
import org.meshtastic.feature.settings.radio.channel.component.ChannelCard
import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog
import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog
import org.meshtastic.feature.settings.radio.component.LoadingOverlay
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.core.ui.component.PacketResponseStateDialog
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config

View File

@@ -95,7 +95,7 @@ import org.meshtastic.feature.settings.channel.ChannelViewModel
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.core.ui.component.PacketResponseStateDialog
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config

View File

@@ -40,7 +40,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.feature.settings.radio.ResponseState
import org.meshtastic.core.model.ResponseState
private const val LOADING_OVERLAY_ALPHA = 0.8f
private const val PERCENTAGE_FACTOR = 100

View File

@@ -40,8 +40,9 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.discard_changes
import org.meshtastic.core.resources.save_changes
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.PacketResponseStateDialog
import org.meshtastic.core.ui.component.PreferenceFooter
import org.meshtastic.feature.settings.radio.ResponseState
import org.meshtastic.core.model.ResponseState
@Suppress("LongMethod")
@Composable