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
|
|
@ -14,54 +14,31 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("detekt:ALL")
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import android.Manifest
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.QrCodeScanner
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
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.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.WriterException
|
||||
import com.journeyapps.barcodescanner.BarcodeEncoder
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.util.CONTACT_SHARE_PATH
|
||||
import org.meshtastic.core.model.util.CONTACT_URL_PREFIX
|
||||
import org.meshtastic.core.model.util.MESHTASTIC_HOST
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.qr_code
|
||||
import org.meshtastic.core.strings.scan_qr_code
|
||||
import org.meshtastic.core.strings.share_contact
|
||||
import org.meshtastic.core.ui.R
|
||||
import org.meshtastic.core.ui.share.SharedContactDialog
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import java.net.MalformedURLException
|
||||
|
|
@ -72,84 +49,18 @@ import java.net.MalformedURLException
|
|||
*
|
||||
* @param modifier Modifier for this composable.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun AddContactFAB(
|
||||
sharedContact: SharedContact?,
|
||||
modifier: Modifier = Modifier,
|
||||
onSharedContactRequested: (SharedContact?) -> Unit,
|
||||
onResult: (Uri) -> Unit,
|
||||
onShareChannels: (() -> Unit)? = null,
|
||||
onDismissSharedContact: () -> Unit,
|
||||
) {
|
||||
val barcodeLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
val uri = result.contents.toUri()
|
||||
val sharedContact =
|
||||
try {
|
||||
uri.toSharedContact()
|
||||
} catch (ex: MalformedURLException) {
|
||||
Logger.e { "URL was malformed: ${ex.message}" }
|
||||
null
|
||||
}
|
||||
if (sharedContact != null) {
|
||||
onSharedContactRequested(sharedContact)
|
||||
}
|
||||
}
|
||||
}
|
||||
sharedContact?.let { SharedContactImportDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
|
||||
|
||||
sharedContact?.let { SharedContactDialog(sharedContact = it, onDismiss = { onSharedContactRequested(null) }) }
|
||||
|
||||
fun zxingScan() {
|
||||
Logger.d { "Starting zxing QR code scanner" }
|
||||
val zxingScan = ScanOptions()
|
||||
zxingScan.setCameraId(CAMERA_ID)
|
||||
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) {
|
||||
Logger.d { "Camera permission granted" }
|
||||
} else {
|
||||
Logger.d { "Camera permission denied" }
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
zxingScan()
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = stringResource(Res.string.scan_qr_code))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrCodeImage(uri: Uri, modifier: Modifier = Modifier) = Image(
|
||||
painter = uri.qrCode?.let { BitmapPainter(it.asImageBitmap()) } ?: painterResource(id = R.drawable.qrcode),
|
||||
contentDescription = stringResource(Res.string.qr_code),
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Inside,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SharedContact(contactUri: Uri) {
|
||||
Column {
|
||||
QrCodeImage(uri = contactUri, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = contactUri.toString(), modifier = Modifier.weight(1f))
|
||||
CopyIconButton(valueToCopy = contactUri.toString(), modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
ImportFab(onImport = onResult, modifier = modifier, onShareChannels = onShareChannels, isContactContext = true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -161,48 +72,52 @@ private fun SharedContact(contactUri: Uri) {
|
|||
@Composable
|
||||
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
|
||||
if (contact == null) return
|
||||
val sharedContact = SharedContact(user = contact.user, node_num = contact.num)
|
||||
val uri = sharedContact.getSharedContactUrl()
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.share_contact,
|
||||
text = {
|
||||
Column {
|
||||
Text(contact.user.long_name)
|
||||
SharedContact(contactUri = uri)
|
||||
}
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
val contactToShare = SharedContact(user = contact.user, node_num = contact.num)
|
||||
val uri = contactToShare.getSharedContactUrl()
|
||||
QrDialog(title = stringResource(Res.string.share_contact), uri = uri, qrCode = uri.qrCode, onDismiss = onDismiss)
|
||||
}
|
||||
|
||||
@Preview
|
||||
/**
|
||||
* Displays a dialog for importing a shared contact.
|
||||
*
|
||||
* @param sharedContact The [SharedContact] to import.
|
||||
* @param onDismiss Callback invoked when the dialog is dismissed.
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareContactPreview() {
|
||||
SharedContact(contactUri = "https://example.com".toUri())
|
||||
fun SharedContactImportDialog(sharedContact: SharedContact, onDismiss: () -> Unit) {
|
||||
org.meshtastic.core.ui.share.SharedContactDialog(sharedContact = sharedContact, onDismiss = onDismiss)
|
||||
}
|
||||
|
||||
/** Bitmap representation of the Uri as a QR code, or null if generation fails. */
|
||||
@Suppress("detekt:MagicNumber")
|
||||
val Uri.qrCode: Bitmap?
|
||||
get() =
|
||||
try {
|
||||
val multiFormatWriter = MultiFormatWriter()
|
||||
val bitMatrix =
|
||||
multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, BARCODE_PIXEL_SIZE, BARCODE_PIXEL_SIZE)
|
||||
val barcodeEncoder = BarcodeEncoder()
|
||||
barcodeEncoder.createBitmap(bitMatrix)
|
||||
val bitMatrix = multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, 960, 960)
|
||||
bitMatrix.toBitmap()
|
||||
} catch (ex: WriterException) {
|
||||
Logger.e { "URL was too complex to render as barcode: ${ex.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
private const val BARCODE_PIXEL_SIZE = 960
|
||||
private const val MESHTASTIC_HOST = "meshtastic.org"
|
||||
private const val CONTACT_SHARE_PATH = "/v/"
|
||||
@Suppress("detekt:MagicNumber")
|
||||
private fun BitMatrix.toBitmap(): Bitmap {
|
||||
val width = width
|
||||
val height = height
|
||||
val pixels = IntArray(width * height)
|
||||
for (y in 0 until height) {
|
||||
val offset = y * width
|
||||
for (x in 0 until width) {
|
||||
pixels[offset + x] = if (get(x, y)) Color.BLACK else Color.WHITE
|
||||
}
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/** Prefix for Meshtastic contact sharing URLs. */
|
||||
internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
|
||||
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
|
||||
private const val CAMERA_ID = 0
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Throws(MalformedURLException::class)
|
||||
|
|
@ -217,7 +132,7 @@ fun Uri.toSharedContact(): SharedContact {
|
|||
fun SharedContact.getSharedContactUrl(): Uri {
|
||||
val bytes = SharedContact.ADAPTER.encode(this)
|
||||
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
|
||||
return "$URL_PREFIX$enc".toUri()
|
||||
return "$CONTACT_URL_PREFIX$enc".toUri()
|
||||
}
|
||||
|
||||
/** Compares two [User] objects and returns a string detailing the differences. */
|
||||
|
|
@ -230,7 +145,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
|
|||
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
|
||||
}
|
||||
if (oldUser.macaddr != newUser.macaddr) {
|
||||
changes.add("macaddr: ${oldUser.macaddr?.base64()} -> ${newUser.macaddr?.base64()}")
|
||||
changes.add("macaddr: ${oldUser.macaddr?.base64String()} -> ${newUser.macaddr?.base64String()}")
|
||||
}
|
||||
if (oldUser.hw_model != newUser.hw_model) changes.add("hw_model: ${oldUser.hw_model} -> ${newUser.hw_model}")
|
||||
if (oldUser.is_licensed != newUser.is_licensed) {
|
||||
|
|
@ -238,7 +153,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
|
|||
}
|
||||
if (oldUser.role != newUser.role) changes.add("role: ${oldUser.role} -> ${newUser.role}")
|
||||
if (oldUser.public_key != newUser.public_key) {
|
||||
changes.add("public_key: ${oldUser.public_key?.base64()} -> ${newUser.public_key?.base64()}")
|
||||
changes.add("public_key: ${oldUser.public_key?.base64String()} -> ${newUser.public_key?.base64String()}")
|
||||
}
|
||||
|
||||
return if (changes.isEmpty()) {
|
||||
|
|
@ -255,13 +170,13 @@ fun userFieldsToString(user: User): String {
|
|||
fieldLines.add("id: ${user.id}")
|
||||
fieldLines.add("long_name: ${user.long_name}")
|
||||
fieldLines.add("short_name: ${user.short_name}")
|
||||
fieldLines.add("macaddr: ${user.macaddr?.base64()}")
|
||||
fieldLines.add("macaddr: ${user.macaddr?.base64String()}")
|
||||
fieldLines.add("hw_model: ${user.hw_model}")
|
||||
fieldLines.add("is_licensed: ${user.is_licensed}")
|
||||
fieldLines.add("role: ${user.role}")
|
||||
fieldLines.add("public_key: ${user.public_key?.base64()}")
|
||||
fieldLines.add("public_key: ${user.public_key?.base64String()}")
|
||||
|
||||
return fieldLines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun ByteString.base64(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
|
||||
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.material.icons.rounded.Nfc
|
||||
import androidx.compose.material.icons.twotone.QrCodeScanner
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.barcode.rememberBarcodeScanner
|
||||
import org.meshtastic.core.nfc.NfcScannerEffect
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.input_shared_contact_url
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.scan_channels_nfc
|
||||
import org.meshtastic.core.strings.scan_channels_qr
|
||||
import org.meshtastic.core.strings.scan_nfc
|
||||
import org.meshtastic.core.strings.scan_nfc_text
|
||||
import org.meshtastic.core.strings.scan_shared_contact_nfc
|
||||
import org.meshtastic.core.strings.scan_shared_contact_qr
|
||||
import org.meshtastic.core.strings.share_channels_qr
|
||||
import org.meshtastic.core.strings.url
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.QrCode2
|
||||
|
||||
/**
|
||||
* Unified Floating Action Button for importing Meshtastic data (Contacts, Channels, etc.) via NFC, QR, or URL.
|
||||
*
|
||||
* @param modifier Modifier for this composable.
|
||||
* @param onImport Callback when a valid Meshtastic URI is scanned or input.
|
||||
* @param onShareChannels Optional callback to trigger sharing channels.
|
||||
* @param isContactContext Hint to customize UI strings for contact importing context.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ImportFab(
|
||||
onImport: (Uri) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onShareChannels: (() -> Unit)? = null,
|
||||
isContactContext: Boolean = false,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showUrlDialog by remember { mutableStateOf(false) }
|
||||
var isNfcScanning by remember { mutableStateOf(false) }
|
||||
|
||||
val barcodeScanner =
|
||||
rememberBarcodeScanner(
|
||||
onResult = { contents ->
|
||||
contents?.toUri()?.let {
|
||||
onImport(it)
|
||||
isNfcScanning = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (isNfcScanning) {
|
||||
NfcScannerEffect(
|
||||
onResult = { contents ->
|
||||
contents?.toUri()?.let {
|
||||
onImport(it)
|
||||
isNfcScanning = false
|
||||
}
|
||||
},
|
||||
)
|
||||
NfcScanningDialog(onDismiss = { isNfcScanning = false })
|
||||
}
|
||||
|
||||
if (showUrlDialog) {
|
||||
InputUrlDialog(
|
||||
title = stringResource(Res.string.input_shared_contact_url),
|
||||
onDismiss = { showUrlDialog = false },
|
||||
onConfirm = { contents ->
|
||||
onImport(contents.toUri())
|
||||
showUrlDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val items =
|
||||
mutableListOf(
|
||||
MenuFABItem(
|
||||
label =
|
||||
stringResource(
|
||||
if (isContactContext) Res.string.scan_shared_contact_nfc else Res.string.scan_channels_nfc,
|
||||
),
|
||||
icon = Icons.Rounded.Nfc,
|
||||
onClick = { isNfcScanning = true },
|
||||
),
|
||||
MenuFABItem(
|
||||
label =
|
||||
stringResource(
|
||||
if (isContactContext) Res.string.scan_shared_contact_qr else Res.string.scan_channels_qr,
|
||||
),
|
||||
icon = Icons.TwoTone.QrCodeScanner,
|
||||
onClick = { barcodeScanner.startScan() },
|
||||
),
|
||||
MenuFABItem(
|
||||
label = stringResource(Res.string.input_shared_contact_url),
|
||||
icon = Icons.Rounded.Link,
|
||||
onClick = { showUrlDialog = true },
|
||||
),
|
||||
)
|
||||
|
||||
onShareChannels?.let {
|
||||
items.add(
|
||||
MenuFABItem(
|
||||
label = stringResource(Res.string.share_channels_qr),
|
||||
icon = MeshtasticIcons.QrCode2,
|
||||
onClick = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
MenuFAB(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
items = items,
|
||||
modifier = modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NfcScanningDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(Res.string.scan_nfc)) },
|
||||
text = { Text(stringResource(Res.string.scan_nfc_text)) },
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputUrlDialog(title: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var urlText by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = urlText,
|
||||
onValueChange = { urlText = it },
|
||||
label = { Text(stringResource(Res.string.url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 4,
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(urlText) }) { Text(stringResource(Res.string.okay)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.OfflineShare
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingActionButtonMenu
|
||||
import androidx.compose.material3.FloatingActionButtonMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ToggleFloatingActionButton
|
||||
import androidx.compose.material3.ToggleFloatingActionButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MenuFAB(
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
items: List<MenuFABItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FloatingActionButtonMenu(
|
||||
modifier = modifier,
|
||||
expanded = expanded,
|
||||
button = {
|
||||
ToggleFloatingActionButton(
|
||||
checked = expanded,
|
||||
onCheckedChange = onExpandedChange,
|
||||
content = {
|
||||
val imageVector = if (expanded) Icons.Filled.Close else Icons.AutoMirrored.Rounded.OfflineShare
|
||||
Icon(imageVector = imageVector, contentDescription = null)
|
||||
},
|
||||
containerColor = ToggleFloatingActionButtonDefaults.containerColor(),
|
||||
)
|
||||
},
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
items.forEach { item ->
|
||||
FloatingActionButtonMenuItem(
|
||||
onClick = {
|
||||
item.onClick()
|
||||
onExpandedChange(false)
|
||||
},
|
||||
icon = { Icon(item.icon, contentDescription = null) },
|
||||
text = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MenuFABItem(val label: String, val icon: ImageVector, val onClick: () -> Unit)
|
||||
|
|
@ -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,6 +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/>.
|
||||
*/
|
||||
@file:Suppress("detekt:ALL")
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
|
|
@ -28,7 +29,6 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -44,54 +44,46 @@ fun PreferenceFooter(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PreferenceFooter(
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
negativeText = stringResource(negativeText),
|
||||
onNegativeClicked = onNegativeClicked,
|
||||
positiveText = stringResource(positiveText),
|
||||
onPositiveClicked = onPositiveClicked,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceFooter(
|
||||
enabled: Boolean,
|
||||
negativeText: String,
|
||||
onNegativeClicked: () -> Unit,
|
||||
positiveText: String,
|
||||
onPositiveClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
negativeText: String? = null,
|
||||
onNegativeClicked: () -> Unit = {},
|
||||
positiveText: String? = null,
|
||||
onPositiveClicked: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ElevatedButton(
|
||||
modifier = Modifier.height(48.dp).weight(1f),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(),
|
||||
onClick = onNegativeClicked,
|
||||
) {
|
||||
Text(text = negativeText)
|
||||
if (negativeText != null) {
|
||||
ElevatedButton(
|
||||
modifier = Modifier.height(48.dp).weight(1f),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(),
|
||||
onClick = onNegativeClicked,
|
||||
) {
|
||||
Text(text = negativeText)
|
||||
}
|
||||
}
|
||||
ElevatedButton(
|
||||
modifier = Modifier.height(48.dp).weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
onClick = { if (enabled) onPositiveClicked() },
|
||||
) {
|
||||
Text(text = positiveText)
|
||||
if (positiveText != null) {
|
||||
ElevatedButton(
|
||||
modifier = Modifier.height(48.dp).weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
onClick = { if (enabled) onPositiveClicked() },
|
||||
) {
|
||||
Text(text = positiveText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PreferenceFooterPreview() {
|
||||
PreferenceFooter(
|
||||
enabled = true,
|
||||
negativeText = "Cancel",
|
||||
onNegativeClicked = {},
|
||||
positiveText = "Save",
|
||||
onPositiveClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("detekt:ALL")
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import android.content.ClipData
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.ContentCopy
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.copy
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.qr_code
|
||||
import org.meshtastic.core.strings.url
|
||||
import org.meshtastic.core.ui.util.findActivity
|
||||
|
||||
private const val QR_IMAGE_SIZE = 320
|
||||
|
||||
@Composable
|
||||
fun QrDialog(title: String, uri: Uri, qrCode: Bitmap?, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val label = stringResource(Res.string.url)
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context.findActivity()
|
||||
val originalBrightness = activity?.window?.attributes?.screenBrightness ?: -1f
|
||||
activity?.window?.let { window ->
|
||||
val params = window.attributes
|
||||
params.screenBrightness = 1f
|
||||
window.attributes = params
|
||||
}
|
||||
onDispose {
|
||||
activity?.window?.let { window ->
|
||||
val params = window.attributes
|
||||
params.screenBrightness = originalBrightness
|
||||
window.attributes = params
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (qrCode != null) {
|
||||
Image(
|
||||
painter = BitmapPainter(qrCode.asImageBitmap()),
|
||||
contentDescription = stringResource(Res.string.qr_code),
|
||||
modifier = Modifier.size(QR_IMAGE_SIZE.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = uri.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
overflow = TextOverflow.Visible,
|
||||
softWrap = true,
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText(label, uri.toString())))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.ContentCopy,
|
||||
contentDescription = stringResource(Res.string.copy),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.okay)) } },
|
||||
)
|
||||
}
|
||||
|
|
@ -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/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon")
|
||||
|
||||
package org.meshtastic.core.ui.timezone
|
||||
|
|
@ -76,7 +75,7 @@ internal fun ZonedDateTime.timeZoneShortName(): String {
|
|||
return if (shortName.startsWith("GMT")) "GMT" else shortName
|
||||
}
|
||||
|
||||
internal fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
|
||||
fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
|
||||
|
||||
internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String {
|
||||
val transition = rule.createTransition(Year.now().value)
|
||||
|
|
@ -84,7 +83,7 @@ internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionR
|
|||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
internal fun formatPosixOffset(offset: ZoneOffset): String {
|
||||
fun formatPosixOffset(offset: ZoneOffset): String {
|
||||
val offsetSeconds = -offset.totalSeconds
|
||||
val hours = offsetSeconds / 3600
|
||||
val remainingSeconds = abs(offsetSeconds) % 3600
|
||||
|
|
|
|||
|
|
@ -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,10 +14,11 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.widget.Toast
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
|
@ -33,3 +34,10 @@ suspend fun Context.showToast(stringResource: StringResource, vararg formatArgs:
|
|||
suspend fun Context.showToast(text: String) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
/** Finds the [Activity] from a [Context]. */
|
||||
fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue