mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(sharing): Refactor QR/NFC scanning with ML Kit and CameraX (#4471)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
3971c0a9f4
commit
96551761c8
37 changed files with 1455 additions and 464 deletions
|
|
@ -246,6 +246,15 @@ constructor(
|
|||
onFailure()
|
||||
}
|
||||
|
||||
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
|
||||
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
|
||||
if (uri.path?.contains("/v/") == true) {
|
||||
setSharedContactRequested(uri, onInvalid)
|
||||
} else {
|
||||
requestChannelUrl(uri, onInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
|
||||
|
||||
/** Called immediately after activity observes requestChannelUrl */
|
||||
|
|
|
|||
|
|
@ -32,14 +32,12 @@ import androidx.compose.material3.Button
|
|||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.animateFloatingActionButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
|
|
@ -53,6 +51,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
|
@ -62,8 +61,11 @@ import androidx.paging.LoadState
|
|||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.geeksville.mesh.model.Contact
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.ContactSettings
|
||||
|
|
@ -71,6 +73,7 @@ import org.meshtastic.core.model.util.formatMuteRemainingTime
|
|||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.channel_invalid
|
||||
import org.meshtastic.core.strings.close_selection
|
||||
import org.meshtastic.core.strings.conversations
|
||||
import org.meshtastic.core.strings.currently
|
||||
|
|
@ -87,33 +90,36 @@ import org.meshtastic.core.strings.mute_status_muted_for_hours
|
|||
import org.meshtastic.core.strings.mute_status_unmuted
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.select_all
|
||||
import org.meshtastic.core.strings.share_contact
|
||||
import org.meshtastic.core.strings.unmute
|
||||
import org.meshtastic.core.ui.component.AddContactFAB
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.smartScrollToTop
|
||||
import org.meshtastic.core.ui.icon.Close
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.QrCode2
|
||||
import org.meshtastic.core.ui.icon.SelectAll
|
||||
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
|
||||
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
onNavigateToShare: () -> Unit,
|
||||
viewModel: ContactsViewModel = hiltViewModel(),
|
||||
uIViewModel: UIViewModel = hiltViewModel(),
|
||||
onClickNodeChip: (Int) -> Unit = {},
|
||||
onNavigateToMessages: (String) -> Unit = {},
|
||||
onNavigateToNodeDetails: (Int) -> Unit = {},
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
|
||||
activeContactKey: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
var showMuteDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -171,6 +177,8 @@ fun ContactsScreen(
|
|||
}
|
||||
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
|
||||
|
||||
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
|
||||
// Callback functions for item interaction
|
||||
val onContactClick: (Contact) -> Unit = { contact ->
|
||||
if (isSelectionModeActive) {
|
||||
|
|
@ -210,6 +218,7 @@ fun ContactsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
|
|
@ -223,15 +232,17 @@ fun ContactsScreen(
|
|||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
modifier =
|
||||
Modifier.animateFloatingActionButton(
|
||||
visible = connectionState.isConnected(),
|
||||
alignment = Alignment.BottomEnd,
|
||||
),
|
||||
onClick = onNavigateToShare,
|
||||
) {
|
||||
Icon(MeshtasticIcons.QrCode2, contentDescription = stringResource(Res.string.share_contact))
|
||||
if (connectionState.isConnected()) {
|
||||
AddContactFAB(
|
||||
sharedContact = sharedContactRequested,
|
||||
onResult = { uri ->
|
||||
uIViewModel.handleScannedUri(uri) {
|
||||
scope.launch { context.showToast(Res.string.channel_invalid) }
|
||||
}
|
||||
},
|
||||
onShareChannels = onNavigateToShare,
|
||||
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
|
|
|
|||
|
|
@ -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 com.geeksville.mesh.ui.node
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
|
|
@ -47,6 +46,7 @@ import androidx.navigation.NavHostController
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.nodes
|
||||
|
|
@ -121,6 +121,7 @@ fun AdaptiveNodeListScreen(
|
|||
navigateToNodeDetails = { nodeId ->
|
||||
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
|
||||
},
|
||||
onNavigateToChannels = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
activeNodeId = navigator.currentDestination?.contentKey,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,39 +16,30 @@
|
|||
*/
|
||||
package com.geeksville.mesh.ui.sharing
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ChevronRight
|
||||
import androidx.compose.material.icons.twotone.Check
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.ContentCopy
|
||||
import androidx.compose.material.icons.twotone.QrCodeScanner
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
|
|
@ -56,7 +47,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -69,35 +59,20 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
|
|||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.model.util.getChannelUrl
|
||||
import org.meshtastic.core.model.util.qrCode
|
||||
import org.meshtastic.core.model.util.toChannelSet
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.strings.Res
|
||||
|
|
@ -106,22 +81,19 @@ import org.meshtastic.core.strings.apply
|
|||
import org.meshtastic.core.strings.are_you_sure_change_default
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.cant_change_no_radio
|
||||
import org.meshtastic.core.strings.channel_invalid
|
||||
import org.meshtastic.core.strings.copy
|
||||
import org.meshtastic.core.strings.edit
|
||||
import org.meshtastic.core.strings.generate_qr_code
|
||||
import org.meshtastic.core.strings.modem_preset
|
||||
import org.meshtastic.core.strings.navigate_into_label
|
||||
import org.meshtastic.core.strings.qr_code
|
||||
import org.meshtastic.core.strings.replace
|
||||
import org.meshtastic.core.strings.reset
|
||||
import org.meshtastic.core.strings.reset_to_defaults
|
||||
import org.meshtastic.core.strings.scan
|
||||
import org.meshtastic.core.strings.send
|
||||
import org.meshtastic.core.strings.url
|
||||
import org.meshtastic.core.strings.share_channels_qr
|
||||
import org.meshtastic.core.ui.component.AdaptiveTwoPane
|
||||
import org.meshtastic.core.ui.component.ChannelSelection
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.PreferenceFooter
|
||||
import org.meshtastic.core.ui.component.QrDialog
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
|
|
@ -136,9 +108,8 @@ import org.meshtastic.proto.Config
|
|||
* Composable screen for managing and sharing Meshtastic channels. Allows users to view, edit, and share channel
|
||||
* configurations via QR codes or URLs.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
fun ChannelScreen(
|
||||
viewModel: ChannelViewModel = hiltViewModel(),
|
||||
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
|
|
@ -199,35 +170,6 @@ fun ChannelScreen(
|
|||
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val barcodeLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
viewModel.requestChannelUrl(result.contents.toUri()) {
|
||||
scope.launch { context.showToast(Res.string.channel_invalid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun zxingScan() {
|
||||
Logger.d { "Starting zxing QR code scanner" }
|
||||
val zxingScan = ScanOptions()
|
||||
zxingScan.setCameraId(0)
|
||||
zxingScan.setPrompt("")
|
||||
zxingScan.setBeepEnabled(false)
|
||||
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
barcodeLauncher.launch(zxingScan)
|
||||
}
|
||||
|
||||
val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.status) {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
// If permission was granted as a result of a request, and not initially,
|
||||
// we might want to trigger the scan. However, simple auto-triggering on grant
|
||||
// might not always be desired UX. For now, rely on user re-click if needed.
|
||||
// If auto-scan is desired after grant: add a flag to track if request was made.
|
||||
}
|
||||
}
|
||||
|
||||
// Send new channel settings to the device
|
||||
fun installSettings(newChannelSet: ChannelSet) {
|
||||
|
|
@ -289,6 +231,16 @@ fun ChannelScreen(
|
|||
|
||||
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) }
|
||||
|
||||
var showShareDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showShareDialog) {
|
||||
ChannelShareDialog(
|
||||
channelSet = selectedChannelSet,
|
||||
shouldAddChannel = shouldAddChannelsState,
|
||||
onDismiss = { showShareDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
|
|
@ -314,21 +266,11 @@ fun ChannelScreen(
|
|||
channelSet = channelSet,
|
||||
modemPresetName = modemPresetName,
|
||||
channelSelections = channelSelections,
|
||||
shouldAddChannel = shouldAddChannelsState,
|
||||
onClick = {
|
||||
onClickEdit = {
|
||||
isWaiting = true
|
||||
radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
|
||||
},
|
||||
)
|
||||
EditChannelUrl(
|
||||
enabled = enabled,
|
||||
channelUrl = selectedChannelSet.getChannelUrl(shouldAdd = shouldAddChannelsState),
|
||||
onTrackShare = viewModel::trackShare,
|
||||
onConfirm = {
|
||||
viewModel.requestChannelUrl(it) {
|
||||
scope.launch { context.showToast(Res.string.channel_invalid) }
|
||||
}
|
||||
},
|
||||
onClickShare = { showShareDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
@ -358,147 +300,40 @@ fun ChannelScreen(
|
|||
}
|
||||
item {
|
||||
PreferenceFooter(
|
||||
modifier = Modifier,
|
||||
enabled = enabled,
|
||||
negativeText = stringResource(Res.string.reset),
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
showResetDialog = true
|
||||
},
|
||||
positiveText = stringResource(Res.string.scan),
|
||||
onPositiveClicked = {
|
||||
focusManager.clearFocus()
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
zxingScan()
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
positiveText = null,
|
||||
onPositiveClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun EditChannelUrl(
|
||||
enabled: Boolean,
|
||||
channelUrl: Uri,
|
||||
modifier: Modifier = Modifier,
|
||||
onTrackShare: () -> Unit,
|
||||
onConfirm: (Uri) -> Unit,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var valueState by remember(channelUrl) { mutableStateOf(channelUrl) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
// Trigger dialog automatically when users paste a new valid URL
|
||||
LaunchedEffect(valueState, isError) {
|
||||
if (!isError && valueState != channelUrl) {
|
||||
onConfirm(valueState)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = valueState.toString(),
|
||||
onValueChange = {
|
||||
isError =
|
||||
runCatching {
|
||||
valueState = it.toUri()
|
||||
valueState.toChannelSet()
|
||||
}
|
||||
.isFailure
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(Res.string.url)) },
|
||||
isError = isError,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
trailingIcon = {
|
||||
val label = stringResource(Res.string.url)
|
||||
val isUrlEqual = valueState == channelUrl
|
||||
IconButton(
|
||||
onClick = {
|
||||
when {
|
||||
isError -> {
|
||||
isError = false
|
||||
valueState = channelUrl
|
||||
}
|
||||
|
||||
!isUrlEqual -> {
|
||||
onConfirm(valueState)
|
||||
valueState = channelUrl
|
||||
}
|
||||
|
||||
else -> {
|
||||
// track how many times users share channels
|
||||
onTrackShare()
|
||||
coroutineScope.launch {
|
||||
clipboardManager.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(label, valueState.toString())),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
when {
|
||||
isError -> Icons.TwoTone.Close
|
||||
!isUrlEqual -> Icons.TwoTone.Check
|
||||
else -> Icons.TwoTone.ContentCopy
|
||||
},
|
||||
contentDescription =
|
||||
when {
|
||||
isError -> stringResource(Res.string.copy)
|
||||
!isUrlEqual -> stringResource(Res.string.send)
|
||||
else -> stringResource(Res.string.copy)
|
||||
},
|
||||
tint =
|
||||
if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
private fun ChannelShareDialog(channelSet: ChannelSet, shouldAddChannel: Boolean, onDismiss: () -> Unit) {
|
||||
val url = channelSet.getChannelUrl(shouldAddChannel)
|
||||
QrDialog(
|
||||
title = stringResource(Res.string.share_channels_qr),
|
||||
uri = url,
|
||||
qrCode = channelSet.qrCode(shouldAddChannel),
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrCodeImage(
|
||||
enabled: Boolean,
|
||||
channelSet: ChannelSet,
|
||||
modifier: Modifier = Modifier,
|
||||
shouldAddChannel: Boolean = false,
|
||||
) = Image(
|
||||
painter =
|
||||
channelSet.qrCode(shouldAddChannel)?.let { BitmapPainter(it.asImageBitmap()) }
|
||||
?: painterResource(id = org.meshtastic.core.ui.R.drawable.qrcode),
|
||||
contentDescription = stringResource(Res.string.qr_code),
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Inside,
|
||||
alpha = if (enabled) 1.0f else 0.7f,
|
||||
// colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ChannelListView(
|
||||
enabled: Boolean,
|
||||
channelSet: ChannelSet,
|
||||
modemPresetName: String,
|
||||
channelSelections: SnapshotStateList<Boolean>,
|
||||
shouldAddChannel: Boolean = false,
|
||||
onClick: () -> Unit = {},
|
||||
onClickEdit: () -> Unit = {},
|
||||
onClickShare: () -> Unit = {},
|
||||
) {
|
||||
val selectedChannelSet =
|
||||
channelSet.copy(settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true })
|
||||
|
|
@ -523,7 +358,7 @@ private fun ChannelListView(
|
|||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
onClick = onClickEdit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
|
||||
|
|
@ -532,12 +367,13 @@ private fun ChannelListView(
|
|||
}
|
||||
},
|
||||
second = {
|
||||
QrCodeImage(
|
||||
enabled = enabled,
|
||||
channelSet = selectedChannelSet,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
shouldAddChannel = shouldAddChannel,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Button(onClick = onClickShare, modifier = Modifier.padding(16.dp), enabled = enabled) {
|
||||
Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(Res.string.generate_qr_code))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue