mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Add KMP URI handling, import, and QR code generation support (#4856)
This commit is contained in:
parent
4eb711ce58
commit
1e55e554be
33 changed files with 379 additions and 209 deletions
|
|
@ -46,16 +46,19 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
|||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.koin.core.context.startKoin
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.service.MeshServiceOrchestrator
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
|
||||
import org.meshtastic.desktop.di.desktopModule
|
||||
import org.meshtastic.desktop.di.desktopPlatformModule
|
||||
import org.meshtastic.desktop.ui.DesktopMainScreen
|
||||
import org.meshtastic.desktop.ui.navSavedStateConfig
|
||||
import java.awt.Desktop
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
|
|
@ -75,11 +78,35 @@ import java.util.Locale
|
|||
private val LocalAppLocale = staticCompositionLocalOf { "" }
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
fun main() = application(exitProcessOnExit = false) {
|
||||
fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
||||
Logger.i { "Meshtastic Desktop — Starting" }
|
||||
|
||||
val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
|
||||
val systemLocale = remember { Locale.getDefault() }
|
||||
val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
|
||||
|
||||
LaunchedEffect(args) {
|
||||
args.forEach { arg ->
|
||||
if (
|
||||
arg.startsWith("meshtastic://") ||
|
||||
arg.startsWith("http://meshtastic.org") ||
|
||||
arg.startsWith("https://meshtastic.org")
|
||||
) {
|
||||
uiViewModel.handleScannedUri(MeshtasticUri(arg)) {
|
||||
Logger.e { "Invalid Meshtastic URI passed via args: $arg" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) {
|
||||
Desktop.getDesktop().setOpenURIHandler { event ->
|
||||
val uriStr = event.uri.toString()
|
||||
uiViewModel.handleScannedUri(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the mesh service processing chain (desktop equivalent of Android's MeshService)
|
||||
val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() }
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ 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.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.desktop.ui.messaging.DesktopAdaptiveContactsScreen
|
||||
import org.meshtastic.desktop.ui.messaging.DesktopMessageContent
|
||||
|
|
@ -39,12 +40,18 @@ import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
|
|||
fun EntryProviderScope<NavKey>.desktopMessagingGraph(backStack: NavBackStack<NavKey>) {
|
||||
entry<ContactsRoutes.ContactsGraph> {
|
||||
val viewModel: ContactsViewModel = koinViewModel()
|
||||
DesktopAdaptiveContactsScreen(viewModel = viewModel)
|
||||
DesktopAdaptiveContactsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
|
||||
)
|
||||
}
|
||||
|
||||
entry<ContactsRoutes.Contacts> {
|
||||
val viewModel: ContactsViewModel = koinViewModel()
|
||||
DesktopAdaptiveContactsScreen(viewModel = viewModel)
|
||||
DesktopAdaptiveContactsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
|
||||
)
|
||||
}
|
||||
|
||||
entry<ContactsRoutes.Messages> { route ->
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.ConnectionsRoutes
|
||||
import org.meshtastic.core.navigation.FirmwareRoutes
|
||||
import org.meshtastic.core.navigation.MapRoutes
|
||||
import org.meshtastic.desktop.ui.firmware.DesktopFirmwareScreen
|
||||
import org.meshtastic.desktop.ui.map.KmpMapPlaceholder
|
||||
import org.meshtastic.feature.connections.ui.ConnectionsScreen
|
||||
import org.meshtastic.feature.settings.radio.channel.channelsGraph
|
||||
|
||||
/**
|
||||
* Registers entry providers for all top-level desktop destinations.
|
||||
|
|
@ -60,8 +60,7 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>)
|
|||
desktopSettingsGraph(backStack)
|
||||
|
||||
// Channels
|
||||
entry<ChannelsRoutes.ChannelsGraph> { PlaceholderScreen("Channels") }
|
||||
entry<ChannelsRoutes.Channels> { PlaceholderScreen("Channels") }
|
||||
channelsGraph(backStack)
|
||||
|
||||
// Connections — shared screen
|
||||
entry<ConnectionsRoutes.ConnectionsGraph> {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import kotlinx.serialization.modules.SerializersModule
|
|||
import kotlinx.serialization.modules.polymorphic
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.ConnectionsRoutes
|
||||
|
|
@ -49,6 +51,9 @@ import org.meshtastic.core.navigation.SettingsRoutes
|
|||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.ui.navigation.icon
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.share.SharedContactDialog
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.desktop.navigation.desktopNavGraph
|
||||
|
||||
/**
|
||||
|
|
@ -142,7 +147,11 @@ internal val navSavedStateConfig = SavedStateConfiguration {
|
|||
* app, proving the shared backstack architecture works across targets.
|
||||
*/
|
||||
@Composable
|
||||
fun DesktopMainScreen(backStack: NavBackStack<NavKey>, radioService: RadioInterfaceService = koinInject()) {
|
||||
fun DesktopMainScreen(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
radioService: RadioInterfaceService = koinInject(),
|
||||
uiViewModel: UIViewModel = koinViewModel(),
|
||||
) {
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val selected = TopLevelDestination.fromNavKey(currentKey)
|
||||
|
||||
|
|
@ -150,6 +159,19 @@ fun DesktopMainScreen(backStack: NavBackStack<NavKey>, radioService: RadioInterf
|
|||
val selectedDevice by radioService.currentDeviceAddressFlow.collectAsStateWithLifecycle()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
sharedContactRequested?.let {
|
||||
SharedContactDialog(sharedContact = it, onDismiss = { uiViewModel.clearSharedContactRequested() })
|
||||
}
|
||||
|
||||
requestChannelSet?.let { newChannelSet ->
|
||||
ScannedQrCodeDialog(newChannelSet, onDismiss = { uiViewModel.clearRequestChannelUrl() })
|
||||
}
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
NavigationRail {
|
||||
|
|
|
|||
|
|
@ -39,13 +39,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.conversations
|
||||
import org.meshtastic.core.resources.mark_as_read
|
||||
import org.meshtastic.core.resources.unread_count
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticImportFAB
|
||||
import org.meshtastic.core.ui.icon.MarkChatRead
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.feature.messaging.MessageViewModel
|
||||
import org.meshtastic.feature.messaging.component.EmptyConversationsPlaceholder
|
||||
import org.meshtastic.feature.messaging.ui.contact.ContactItem
|
||||
|
|
@ -63,13 +66,20 @@ import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
|
|||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun DesktopAdaptiveContactsScreen(viewModel: ContactsViewModel) {
|
||||
fun DesktopAdaptiveContactsScreen(
|
||||
viewModel: ContactsViewModel,
|
||||
onNavigateToShareChannels: () -> Unit = {},
|
||||
uiViewModel: UIViewModel = koinViewModel(),
|
||||
) {
|
||||
val contacts by viewModel.contactList.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val unreadTotal by viewModel.unreadCountTotal.collectAsStateWithLifecycle()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
|
|
@ -102,6 +112,23 @@ fun DesktopAdaptiveContactsScreen(viewModel: ContactsViewModel) {
|
|||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
MeshtasticImportFAB(
|
||||
onImport = { uriString ->
|
||||
uiViewModel.handleScannedUri(
|
||||
org.meshtastic.core.common.util.MeshtasticUri(uriString),
|
||||
) {
|
||||
// OnInvalid
|
||||
}
|
||||
},
|
||||
onShareChannels = onNavigateToShareChannels,
|
||||
sharedContact = sharedContactRequested,
|
||||
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
|
||||
isContactContext = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { contentPadding ->
|
||||
if (contacts.isEmpty()) {
|
||||
EmptyConversationsPlaceholder(modifier = Modifier.padding(contentPadding))
|
||||
|
|
|
|||
|
|
@ -48,14 +48,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.node_count_template
|
||||
import org.meshtastic.core.resources.nodes
|
||||
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticImportFAB
|
||||
import org.meshtastic.core.ui.component.SharedContactDialog
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Nodes
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.feature.node.component.NodeContextMenu
|
||||
import org.meshtastic.feature.node.component.NodeFilterTextField
|
||||
import org.meshtastic.feature.node.component.NodeItem
|
||||
|
|
@ -77,12 +81,13 @@ import org.meshtastic.feature.node.model.NodeDetailAction
|
|||
* bottom sheets) are no-ops on desktop.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun DesktopAdaptiveNodeListScreen(
|
||||
viewModel: NodeListViewModel,
|
||||
initialNodeId: Int? = null,
|
||||
onNavigate: (Route) -> Unit = {},
|
||||
uiViewModel: UIViewModel = koinViewModel(),
|
||||
) {
|
||||
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
|
||||
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
|
||||
|
|
@ -96,6 +101,13 @@ fun DesktopAdaptiveNodeListScreen(
|
|||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
var shareNode by remember { mutableStateOf<org.meshtastic.core.model.Node?>(null) }
|
||||
|
||||
if (shareNode != null) {
|
||||
SharedContactDialog(contact = shareNode, onDismiss = { shareNode = null })
|
||||
}
|
||||
|
||||
LaunchedEffect(initialNodeId) {
|
||||
initialNodeId?.let { nodeId -> navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
|
||||
}
|
||||
|
|
@ -124,6 +136,22 @@ fun DesktopAdaptiveNodeListScreen(
|
|||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
MeshtasticImportFAB(
|
||||
onImport = { uriString ->
|
||||
uiViewModel.handleScannedUri(
|
||||
org.meshtastic.core.common.util.MeshtasticUri(uriString),
|
||||
) {
|
||||
// OnInvalid
|
||||
}
|
||||
},
|
||||
sharedContact = sharedContactRequested,
|
||||
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
|
||||
isContactContext = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { contentPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
|
|
@ -227,6 +255,7 @@ fun DesktopAdaptiveNodeListScreen(
|
|||
is NodeDetailAction.Navigate -> onNavigate(action.route)
|
||||
is NodeDetailAction.TriggerServiceAction ->
|
||||
detailViewModel.onServiceAction(action.action)
|
||||
is NodeDetailAction.ShareContact -> shareNode = detailUiState.node
|
||||
is NodeDetailAction.HandleNodeMenuAction -> {
|
||||
val menuAction = action.action
|
||||
if (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue