mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: consolidate dialogs (#4506)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
7bcc51863f
commit
ea6d1ffa32
59 changed files with 2042 additions and 1659 deletions
|
|
@ -41,6 +41,10 @@ This file serves as a comprehensive guide for AI agents and developers working o
|
|||
|
||||
Text(text = stringResource(Res.string.your_string_key))
|
||||
```
|
||||
- **Dialogs:**
|
||||
- Use the centralized `MeshtasticDialog` for all alerts and confirmation boxes.
|
||||
- **Specialized Overloads:** Use `MeshtasticResourceDialog` (for resource-only content) or `MeshtasticTextDialog` (for mixed resource/text content) to reduce boilerplate.
|
||||
- **Location:** Defined in `core/ui/src/main/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt`.
|
||||
- **Previews:** Create `@Preview` functions for your Composables to ensure they render correctly.
|
||||
|
||||
### B. Architecture & State
|
||||
|
|
|
|||
|
|
@ -47,11 +47,10 @@ import com.geeksville.mesh.ui.MainScreen
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.model.util.handleMeshtasticUri
|
||||
import org.meshtastic.core.model.util.dispatchMeshtasticUri
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.channel_invalid
|
||||
import org.meshtastic.core.strings.contact_invalid
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
|
|
@ -157,20 +156,10 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
private fun handleMeshtasticUri(uri: Uri) {
|
||||
Logger.d { "Handling Meshtastic URI: $uri" }
|
||||
handleMeshtasticUri(
|
||||
uri = uri,
|
||||
onChannel = {
|
||||
model.requestChannelUrl(
|
||||
url = it,
|
||||
onFailure = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } },
|
||||
)
|
||||
},
|
||||
onContact = {
|
||||
model.setSharedContactRequested(
|
||||
url = it,
|
||||
onFailure = { lifecycleScope.launch { showToast(Res.string.contact_invalid) } },
|
||||
)
|
||||
},
|
||||
uri.dispatchMeshtasticUri(
|
||||
onChannel = { model.setRequestChannelSet(it) },
|
||||
onContact = { model.setSharedContactRequested(it) },
|
||||
onInvalid = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.map
|
|||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.analytics.platform.PlatformAnalytics
|
||||
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
||||
|
|
@ -54,15 +55,17 @@ import org.meshtastic.core.database.entity.asDeviceVersion
|
|||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.model.TracerouteMapAvailability
|
||||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||
import org.meshtastic.core.model.util.toChannelSet
|
||||
import org.meshtastic.core.model.util.dispatchMeshtasticUri
|
||||
import org.meshtastic.core.service.IMeshService
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.service.TracerouteResponse
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.client_notification
|
||||
import org.meshtastic.core.strings.compromised_keys
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.toSharedContact
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import org.meshtastic.core.ui.util.ComposableContent
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
|
|
@ -114,6 +117,7 @@ constructor(
|
|||
private val meshServiceNotifications: MeshServiceNotifications,
|
||||
private val analytics: PlatformAnalytics,
|
||||
packetRepository: PacketRepository,
|
||||
private val alertManager: AlertManager,
|
||||
) : ViewModel() {
|
||||
|
||||
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
|
||||
|
|
@ -142,17 +146,7 @@ constructor(
|
|||
_scrollToTopEventFlow.tryEmit(event)
|
||||
}
|
||||
|
||||
data class AlertData(
|
||||
val title: String,
|
||||
val message: String? = null,
|
||||
val html: String? = null,
|
||||
val onConfirm: (() -> Unit)? = null,
|
||||
val onDismiss: (() -> Unit)? = null,
|
||||
val choices: Map<String, () -> Unit> = emptyMap(),
|
||||
)
|
||||
|
||||
private val _currentAlert: MutableStateFlow<AlertData?> = MutableStateFlow(null)
|
||||
val currentAlert = _currentAlert.asStateFlow()
|
||||
val currentAlert = alertManager.currentAlert
|
||||
|
||||
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
|
||||
evaluateTracerouteMapAvailability(
|
||||
|
|
@ -163,29 +157,39 @@ constructor(
|
|||
)
|
||||
|
||||
fun showAlert(
|
||||
title: String,
|
||||
title: String? = null,
|
||||
titleRes: StringResource? = null,
|
||||
message: String? = null,
|
||||
messageRes: StringResource? = null,
|
||||
composableMessage: ComposableContent? = null,
|
||||
html: String? = null,
|
||||
onConfirm: (() -> Unit)? = {},
|
||||
dismissable: Boolean = true,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
confirmText: String? = null,
|
||||
confirmTextRes: StringResource? = null,
|
||||
dismissText: String? = null,
|
||||
dismissTextRes: StringResource? = null,
|
||||
choices: Map<String, () -> Unit> = emptyMap(),
|
||||
) {
|
||||
_currentAlert.value =
|
||||
AlertData(
|
||||
title = title,
|
||||
message = message,
|
||||
html = html,
|
||||
onConfirm = {
|
||||
onConfirm?.invoke()
|
||||
dismissAlert()
|
||||
},
|
||||
onDismiss = { if (dismissable) dismissAlert() },
|
||||
choices = choices,
|
||||
)
|
||||
alertManager.showAlert(
|
||||
title = title,
|
||||
titleRes = titleRes,
|
||||
message = message,
|
||||
messageRes = messageRes,
|
||||
composableMessage = composableMessage,
|
||||
html = html,
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
confirmText = confirmText,
|
||||
confirmTextRes = confirmTextRes,
|
||||
dismissText = dismissText,
|
||||
dismissTextRes = dismissTextRes,
|
||||
choices = choices,
|
||||
)
|
||||
}
|
||||
|
||||
private fun dismissAlert() {
|
||||
_currentAlert.value = null
|
||||
fun dismissAlert() {
|
||||
alertManager.dismissAlert()
|
||||
}
|
||||
|
||||
val meshService: IMeshService?
|
||||
|
|
@ -203,10 +207,25 @@ constructor(
|
|||
.filterNotNull()
|
||||
.onEach {
|
||||
showAlert(
|
||||
title = getString(Res.string.client_notification),
|
||||
titleRes = Res.string.client_notification,
|
||||
message = it,
|
||||
onConfirm = { serviceRepository.clearErrorMessage() },
|
||||
dismissable = false,
|
||||
)
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
serviceRepository.clientNotification
|
||||
.filterNotNull()
|
||||
.onEach { notification ->
|
||||
val isCompromised = notification.low_entropy_key != null || notification.duplicated_public_key != null
|
||||
showAlert(
|
||||
titleRes = Res.string.client_notification,
|
||||
message = if (isCompromised) getString(Res.string.compromised_keys) else notification.message,
|
||||
onConfirm = {
|
||||
// Action for compromised keys should be handled via a callback or event
|
||||
clearClientNotification(notification)
|
||||
},
|
||||
onDismiss = { clearClientNotification(notification) },
|
||||
)
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
|
@ -218,12 +237,8 @@ constructor(
|
|||
val sharedContactRequested: StateFlow<SharedContact?>
|
||||
get() = _sharedContactRequested.asStateFlow()
|
||||
|
||||
fun setSharedContactRequested(url: Uri, onFailure: () -> Unit) {
|
||||
runCatching { _sharedContactRequested.value = url.toSharedContact() }
|
||||
.onFailure { ex ->
|
||||
Logger.e(ex) { "Shared contact error" }
|
||||
onFailure()
|
||||
}
|
||||
fun setSharedContactRequested(contact: SharedContact?) {
|
||||
_sharedContactRequested.value = contact
|
||||
}
|
||||
|
||||
/** Called immediately after activity observes requestChannelUrl */
|
||||
|
|
@ -239,20 +254,17 @@ constructor(
|
|||
val requestChannelSet: StateFlow<ChannelSet?>
|
||||
get() = _requestChannelSet
|
||||
|
||||
fun requestChannelUrl(url: Uri, onFailure: () -> Unit) =
|
||||
runCatching { _requestChannelSet.value = url.toChannelSet() }
|
||||
.onFailure { ex ->
|
||||
Logger.e(ex) { "Channel url error" }
|
||||
onFailure()
|
||||
}
|
||||
fun setRequestChannelSet(channelSet: ChannelSet?) {
|
||||
_requestChannelSet.value = channelSet
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
uri.dispatchMeshtasticUri(
|
||||
onContact = { setSharedContactRequested(it) },
|
||||
onChannel = { setRequestChannelSet(it) },
|
||||
onInvalid = onInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
|
||||
|
|
@ -267,8 +279,8 @@ constructor(
|
|||
Logger.d { "ViewModel cleared" }
|
||||
}
|
||||
|
||||
val tracerouteResponse: LiveData<TracerouteResponse?>
|
||||
get() = serviceRepository.tracerouteResponse.asLiveData()
|
||||
val tracerouteResponse: Flow<TracerouteResponse?>
|
||||
get() = serviceRepository.tracerouteResponse
|
||||
|
||||
fun clearTracerouteResponse() {
|
||||
serviceRepository.clearTracerouteResponse()
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ import androidx.compose.material3.rememberTooltipState
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -116,9 +115,6 @@ import org.meshtastic.core.service.ConnectionState
|
|||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.app_too_old
|
||||
import org.meshtastic.core.strings.bottom_nav_settings
|
||||
import org.meshtastic.core.strings.client_notification
|
||||
import org.meshtastic.core.strings.close
|
||||
import org.meshtastic.core.strings.compromised_keys
|
||||
import org.meshtastic.core.strings.connected
|
||||
import org.meshtastic.core.strings.connecting
|
||||
import org.meshtastic.core.strings.connections
|
||||
|
|
@ -135,9 +131,8 @@ import org.meshtastic.core.strings.should_update
|
|||
import org.meshtastic.core.strings.should_update_firmware
|
||||
import org.meshtastic.core.strings.traceroute
|
||||
import org.meshtastic.core.strings.view_on_map
|
||||
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.core.ui.icon.Conversations
|
||||
import org.meshtastic.core.ui.icon.Map
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
|
|
@ -148,8 +143,10 @@ import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
|||
import org.meshtastic.core.ui.share.SharedContactDialog
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.core.ui.util.annotateTraceroute
|
||||
import org.meshtastic.core.ui.util.toMessageRes
|
||||
import org.meshtastic.feature.node.metrics.annotateTraceroute
|
||||
|
||||
enum class TopLevelDestination(val label: StringResource, val icon: ImageVector, val route: Route) {
|
||||
Conversations(Res.string.conversations, MeshtasticIcons.Conversations, ContactsRoutes.ContactsGraph),
|
||||
|
|
@ -197,63 +194,57 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
|
|||
uIViewModel.AddNavigationTrackingEffect(navController)
|
||||
|
||||
VersionChecks(uIViewModel)
|
||||
|
||||
val alertDialogState by uIViewModel.currentAlert.collectAsStateWithLifecycle()
|
||||
alertDialogState?.let { state ->
|
||||
if (state.choices.isNotEmpty()) {
|
||||
MultipleChoiceAlertDialog(
|
||||
title = state.title,
|
||||
message = state.message,
|
||||
choices = state.choices,
|
||||
onDismissRequest = { state.onDismiss?.let { it() } },
|
||||
)
|
||||
} else {
|
||||
SimpleAlertDialog(
|
||||
title = state.title,
|
||||
message = state.message,
|
||||
html = state.html,
|
||||
onConfirmRequest = { state.onConfirm?.let { it() } },
|
||||
onDismissRequest = { state.onDismiss?.let { it() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
val title = state.title ?: state.titleRes?.let { stringResource(it) } ?: ""
|
||||
val message = state.message ?: state.messageRes?.let { stringResource(it) }
|
||||
val confirmText = state.confirmText ?: state.confirmTextRes?.let { stringResource(it) }
|
||||
val dismissText = state.dismissText ?: state.dismissTextRes?.let { stringResource(it) }
|
||||
|
||||
val clientNotification by uIViewModel.clientNotification.collectAsStateWithLifecycle()
|
||||
clientNotification?.let { notification ->
|
||||
var message = notification.message
|
||||
val compromisedKeys =
|
||||
if (notification.low_entropy_key != null || notification.duplicated_public_key != null) {
|
||||
message = stringResource(Res.string.compromised_keys)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.client_notification,
|
||||
text = { Text(text = message) },
|
||||
onConfirm = {
|
||||
if (compromisedKeys) {
|
||||
navController.navigate(SettingsRoutes.Security)
|
||||
MeshtasticDialog(
|
||||
title = title,
|
||||
message = message,
|
||||
html = state.html,
|
||||
icon = state.icon,
|
||||
text = {
|
||||
val composableMsg = state.composableMessage
|
||||
if (composableMsg != null) {
|
||||
composableMsg.Content()
|
||||
} else {
|
||||
// message is handled internally by MeshtasticDialog
|
||||
}
|
||||
uIViewModel.clearClientNotification(notification)
|
||||
},
|
||||
onDismiss = { uIViewModel.clearClientNotification(notification) },
|
||||
confirmText = confirmText,
|
||||
onConfirm = state.onConfirm,
|
||||
dismissText = dismissText,
|
||||
onDismiss = state.onDismiss,
|
||||
choices = state.choices,
|
||||
dismissable = state.dismissable,
|
||||
)
|
||||
}
|
||||
|
||||
val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState()
|
||||
var tracerouteMapError by remember { mutableStateOf<StringResource?>(null) }
|
||||
val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
|
||||
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
|
||||
traceRouteResponse
|
||||
?.takeIf { it.requestId != dismissedTracerouteRequestId }
|
||||
?.let { response ->
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = {
|
||||
uIViewModel.showAlert(
|
||||
titleRes = Res.string.traceroute,
|
||||
composableMessage = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(text = annotateTraceroute(response.message))
|
||||
Text(
|
||||
text =
|
||||
annotateTraceroute(
|
||||
response.message,
|
||||
statusGreen = colorScheme.StatusGreen,
|
||||
statusYellow = colorScheme.StatusYellow,
|
||||
statusOrange = colorScheme.StatusOrange,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmText = stringResource(Res.string.view_on_map),
|
||||
confirmTextRes = Res.string.view_on_map,
|
||||
onConfirm = {
|
||||
val availability =
|
||||
uIViewModel.tracerouteMapAvailability(
|
||||
|
|
@ -271,25 +262,17 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
|
|||
),
|
||||
)
|
||||
} else {
|
||||
tracerouteMapError = errorRes
|
||||
uIViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
|
||||
uIViewModel.clearTracerouteResponse()
|
||||
}
|
||||
},
|
||||
dismissText = stringResource(Res.string.okay),
|
||||
dismissTextRes = Res.string.okay,
|
||||
onDismiss = {
|
||||
uIViewModel.clearTracerouteResponse()
|
||||
dismissedTracerouteRequestId = null
|
||||
},
|
||||
)
|
||||
}
|
||||
tracerouteMapError?.let { res ->
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = { Text(text = stringResource(res)) },
|
||||
dismissText = stringResource(Res.string.close),
|
||||
onDismiss = { tracerouteMapError = null },
|
||||
)
|
||||
}
|
||||
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
|
||||
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
|
||||
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)
|
||||
|
|
@ -532,9 +515,8 @@ private fun VersionChecks(viewModel: UIViewModel) {
|
|||
if (isOld) {
|
||||
Logger.w { "[FW_CHECK] App too old - showing update prompt" }
|
||||
viewModel.showAlert(
|
||||
getString(Res.string.app_too_old),
|
||||
getString(Res.string.must_update),
|
||||
dismissable = false,
|
||||
titleRes = Res.string.app_too_old,
|
||||
messageRes = Res.string.must_update,
|
||||
onConfirm = {
|
||||
val service = viewModel.meshService ?: return@showAlert
|
||||
MeshService.changeDeviceAddress(context, service, "n")
|
||||
|
|
@ -560,7 +542,6 @@ private fun VersionChecks(viewModel: UIViewModel) {
|
|||
viewModel.showAlert(
|
||||
title = title,
|
||||
html = message,
|
||||
dismissable = false,
|
||||
onConfirm = {
|
||||
val service = viewModel.meshService ?: return@showAlert
|
||||
MeshService.changeDeviceAddress(context, service, "n")
|
||||
|
|
@ -573,7 +554,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
|
|||
}
|
||||
val title = getString(Res.string.should_update_firmware)
|
||||
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
|
||||
viewModel.showAlert(title = title, message = message, dismissable = false, onConfirm = {})
|
||||
viewModel.showAlert(title = title, message = message, onConfirm = {})
|
||||
} else {
|
||||
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Wifi
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -65,6 +64,7 @@ import org.meshtastic.core.strings.forget_connection
|
|||
import org.meshtastic.core.strings.ip_port
|
||||
import org.meshtastic.core.strings.no_network_devices
|
||||
import org.meshtastic.core.strings.recent_network_devices
|
||||
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -237,21 +237,15 @@ private fun ConfirmDeleteDialog(
|
|||
onHideDialog: () -> Unit,
|
||||
onConfirm: (deviceFullAddress: String) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onHideDialog,
|
||||
title = { Text(stringResource(Res.string.forget_connection)) },
|
||||
text = { Text(stringResource(Res.string.confirm_forget_connection)) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onConfirm(fullAddressToDelete)
|
||||
onHideDialog()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.forget_connection))
|
||||
}
|
||||
MeshtasticResourceDialog(
|
||||
onDismiss = onHideDialog,
|
||||
titleRes = Res.string.forget_connection,
|
||||
messageRes = Res.string.confirm_forget_connection,
|
||||
confirmTextRes = Res.string.forget_connection,
|
||||
onConfirm = {
|
||||
onConfirm(fullAddressToDelete)
|
||||
onHideDialog()
|
||||
},
|
||||
dismissButton = { Button(onClick = { onHideDialog() }) { Text(stringResource(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ import androidx.compose.foundation.lazy.LazyListScope
|
|||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
|
|
@ -54,7 +52,6 @@ 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
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.LoadState
|
||||
|
|
@ -72,6 +69,7 @@ import org.meshtastic.core.database.entity.ContactSettings
|
|||
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.are_you_sure
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.channel_invalid
|
||||
import org.meshtastic.core.strings.close_selection
|
||||
|
|
@ -91,8 +89,10 @@ 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.unmute
|
||||
import org.meshtastic.core.ui.component.AddContactFAB
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.MeshtasticImportFAB
|
||||
import org.meshtastic.core.ui.component.MeshtasticTextDialog
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.smartScrollToTop
|
||||
import org.meshtastic.core.ui.icon.Close
|
||||
|
|
@ -101,6 +101,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
|
|||
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.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
|
@ -178,6 +179,8 @@ fun ContactsScreen(
|
|||
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
|
||||
|
||||
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { uIViewModel.clearRequestChannelUrl() }) }
|
||||
|
||||
// Callback functions for item interaction
|
||||
val onContactClick: (Contact) -> Unit = { contact ->
|
||||
|
|
@ -233,15 +236,16 @@ fun ContactsScreen(
|
|||
},
|
||||
floatingActionButton = {
|
||||
if (connectionState.isConnected()) {
|
||||
AddContactFAB(
|
||||
MeshtasticImportFAB(
|
||||
sharedContact = sharedContactRequested,
|
||||
onResult = { uri ->
|
||||
onImport = { uri ->
|
||||
uIViewModel.handleScannedUri(uri) {
|
||||
scope.launch { context.showToast(Res.string.channel_invalid) }
|
||||
}
|
||||
},
|
||||
onShareChannels = onNavigateToShare,
|
||||
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
|
||||
isContactContext = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -277,168 +281,140 @@ fun ContactsScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
DeleteConfirmationDialog(
|
||||
showDialog = showDeleteDialog,
|
||||
selectedCount = selectedCount,
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
onConfirm = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteContacts(selectedContactKeys.toList())
|
||||
selectedContactKeys.clear()
|
||||
},
|
||||
)
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
selectedCount = selectedCount,
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
onConfirm = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteContacts(selectedContactKeys.toList())
|
||||
selectedContactKeys.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Get contact settings for the dialog
|
||||
val contactSettings by viewModel.getContactSettings().collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
|
||||
MuteNotificationsDialog(
|
||||
showDialog = showMuteDialog,
|
||||
selectedContactKeys = selectedContactKeys.toList(),
|
||||
contactSettings = contactSettings,
|
||||
onDismiss = { showMuteDialog = false },
|
||||
onConfirm = { muteUntil ->
|
||||
showMuteDialog = false
|
||||
viewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil)
|
||||
selectedContactKeys.clear()
|
||||
},
|
||||
)
|
||||
if (showMuteDialog) {
|
||||
MuteNotificationsDialog(
|
||||
selectedContactKeys = selectedContactKeys.toList(),
|
||||
contactSettings = contactSettings,
|
||||
onDismiss = { showMuteDialog = false },
|
||||
onConfirm = { muteUntil ->
|
||||
showMuteDialog = false
|
||||
viewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil)
|
||||
selectedContactKeys.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun MuteNotificationsDialog(
|
||||
showDialog: Boolean,
|
||||
selectedContactKeys: List<String>,
|
||||
contactSettings: Map<String, ContactSettings>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Long) -> Unit, // Lambda to handle the confirmed mute duration
|
||||
) {
|
||||
if (showDialog) {
|
||||
// Options for mute duration
|
||||
val muteOptions = remember {
|
||||
listOf(
|
||||
Res.string.unmute to 0L,
|
||||
Res.string.mute_8_hours to TimeUnit.HOURS.toMillis(8),
|
||||
Res.string.mute_1_week to TimeUnit.DAYS.toMillis(7),
|
||||
Res.string.mute_always to Long.MAX_VALUE,
|
||||
)
|
||||
}
|
||||
|
||||
// State to hold the selected mute duration index
|
||||
var selectedOptionIndex by remember { mutableStateOf(2) } // Default to "Always"
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss, // Dismiss the dialog when clicked outside
|
||||
title = { Text(text = stringResource(Res.string.mute_notifications)) },
|
||||
text = {
|
||||
Column {
|
||||
// Show current mute status
|
||||
selectedContactKeys.forEach { contactKey ->
|
||||
contactSettings[contactKey]?.let { settings ->
|
||||
val now = System.currentTimeMillis()
|
||||
val statusText =
|
||||
when {
|
||||
settings.muteUntil > 0 && settings.muteUntil != Long.MAX_VALUE -> {
|
||||
val remaining = settings.muteUntil - now
|
||||
if (remaining > 0) {
|
||||
val (days, hours) = formatMuteRemainingTime(remaining)
|
||||
if (days >= 1) {
|
||||
stringResource(Res.string.mute_status_muted_for_days, days, hours)
|
||||
} else {
|
||||
stringResource(Res.string.mute_status_muted_for_hours, hours)
|
||||
}
|
||||
} else {
|
||||
stringResource(Res.string.mute_status_unmuted)
|
||||
}
|
||||
}
|
||||
settings.muteUntil == Long.MAX_VALUE ->
|
||||
stringResource(Res.string.mute_status_always)
|
||||
else -> stringResource(Res.string.mute_status_unmuted)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.currently) + " " + statusText,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
muteOptions.forEachIndexed { index, (stringRes, _) ->
|
||||
val isSelected = index == selectedOptionIndex
|
||||
val text = stringResource(stringRes)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.selectable(selected = isSelected, onClick = { selectedOptionIndex = index })
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = isSelected, onClick = { selectedOptionIndex = index })
|
||||
Text(text = text, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val selectedMuteDuration = muteOptions[selectedOptionIndex].second
|
||||
onConfirm(selectedMuteDuration)
|
||||
onDismiss() // Dismiss the dialog after confirming
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.okay))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(
|
||||
onClick = onDismiss, // Dismiss the dialog on cancel
|
||||
) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
},
|
||||
// Options for mute duration
|
||||
val muteOptions = remember {
|
||||
listOf(
|
||||
Res.string.unmute to 0L,
|
||||
Res.string.mute_8_hours to TimeUnit.HOURS.toMillis(8),
|
||||
Res.string.mute_1_week to TimeUnit.DAYS.toMillis(7),
|
||||
Res.string.mute_always to Long.MAX_VALUE,
|
||||
)
|
||||
}
|
||||
|
||||
// State to hold the selected mute duration index
|
||||
var selectedOptionIndex by remember { mutableStateOf(2) } // Default to "Always"
|
||||
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss, // Dismiss the dialog when clicked outside
|
||||
titleRes = Res.string.mute_notifications,
|
||||
confirmTextRes = Res.string.okay,
|
||||
onConfirm = {
|
||||
val selectedMuteDuration = muteOptions[selectedOptionIndex].second
|
||||
onConfirm(selectedMuteDuration)
|
||||
onDismiss() // Dismiss the dialog after confirming
|
||||
},
|
||||
dismissTextRes = Res.string.cancel,
|
||||
text = {
|
||||
Column {
|
||||
// Show current mute status
|
||||
selectedContactKeys.forEach { contactKey ->
|
||||
contactSettings[contactKey]?.let { settings ->
|
||||
val now = System.currentTimeMillis()
|
||||
val statusText =
|
||||
when {
|
||||
settings.muteUntil > 0 && settings.muteUntil != Long.MAX_VALUE -> {
|
||||
val remaining = settings.muteUntil - now
|
||||
if (remaining > 0) {
|
||||
val (days, hours) = formatMuteRemainingTime(remaining)
|
||||
if (days >= 1) {
|
||||
stringResource(Res.string.mute_status_muted_for_days, days, hours)
|
||||
} else {
|
||||
stringResource(Res.string.mute_status_muted_for_hours, hours)
|
||||
}
|
||||
} else {
|
||||
stringResource(Res.string.mute_status_unmuted)
|
||||
}
|
||||
}
|
||||
settings.muteUntil == Long.MAX_VALUE -> stringResource(Res.string.mute_status_always)
|
||||
else -> stringResource(Res.string.mute_status_unmuted)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.currently) + " " + statusText,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
muteOptions.forEachIndexed { index, (stringRes, _) ->
|
||||
val isSelected = index == selectedOptionIndex
|
||||
val text = stringResource(stringRes)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.selectable(selected = isSelected, onClick = { selectedOptionIndex = index })
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = isSelected, onClick = { selectedOptionIndex = index })
|
||||
Text(text = text, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteConfirmationDialog(
|
||||
showDialog: Boolean,
|
||||
selectedCount: Int, // Number of items to be deleted
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit, // Lambda to handle the delete action
|
||||
) {
|
||||
if (showDialog) {
|
||||
val deleteMessage =
|
||||
pluralStringResource(
|
||||
Res.plurals.delete_messages,
|
||||
selectedCount,
|
||||
selectedCount, // Pass the count as a format argument
|
||||
)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
// Optional: You could add a title here if needed, e.g., "Confirm Deletion"
|
||||
},
|
||||
text = { Text(text = deleteMessage) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onConfirm()
|
||||
onDismiss() // Dismiss the dialog after confirming
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.delete))
|
||||
}
|
||||
},
|
||||
dismissButton = { Button(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
properties =
|
||||
DialogProperties(
|
||||
dismissOnClickOutside = true, // Allow dismissing by clicking outside
|
||||
dismissOnBackPress = true, // Allow dismissing with the back button
|
||||
),
|
||||
val deleteMessage =
|
||||
pluralStringResource(
|
||||
Res.plurals.delete_messages,
|
||||
selectedCount,
|
||||
selectedCount, // Pass the count as a format argument
|
||||
)
|
||||
}
|
||||
|
||||
MeshtasticTextDialog(
|
||||
titleRes = Res.string.are_you_sure,
|
||||
message = deleteMessage,
|
||||
confirmTextRes = Res.string.delete,
|
||||
onConfirm = {
|
||||
onConfirm()
|
||||
onDismiss() // Dismiss the dialog after confirming
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ChevronRight
|
||||
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
|
||||
|
|
@ -45,7 +44,6 @@ import androidx.compose.material3.SegmentedButton
|
|||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
|
|
@ -92,6 +90,7 @@ 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.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.PreferenceFooter
|
||||
import org.meshtastic.core.ui.component.QrDialog
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
|
|
@ -193,39 +192,22 @@ fun ChannelScreen(
|
|||
}
|
||||
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
MeshtasticDialog(
|
||||
onDismiss = {
|
||||
channelSet = channels // throw away any edits
|
||||
showResetDialog = false
|
||||
},
|
||||
title = { Text(text = stringResource(Res.string.reset_to_defaults)) },
|
||||
text = { Text(text = stringResource(Res.string.are_you_sure_change_default)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
Logger.d { "Switching back to default channel" }
|
||||
val lora =
|
||||
(Channel.default.loraConfig).copy(
|
||||
region = viewModel.region,
|
||||
tx_enabled = viewModel.txEnabled,
|
||||
)
|
||||
installSettings(Channel.default.settings, lora)
|
||||
showResetDialog = false
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(Res.string.apply))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
channelSet = channels // throw away any edits
|
||||
showResetDialog = false
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(Res.string.cancel))
|
||||
}
|
||||
titleRes = Res.string.reset_to_defaults,
|
||||
messageRes = Res.string.are_you_sure_change_default,
|
||||
onConfirm = {
|
||||
Logger.d { "Switching back to default channel" }
|
||||
val lora =
|
||||
(Channel.default.loraConfig).copy(region = viewModel.region, tx_enabled = viewModel.txEnabled)
|
||||
installSettings(Channel.default.settings, lora)
|
||||
showResetDialog = false
|
||||
},
|
||||
confirmTextRes = Res.string.apply,
|
||||
dismissTextRes = Res.string.cancel,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 org.meshtastic.core.model.util
|
||||
|
||||
import android.net.Uri
|
||||
|
|
@ -62,4 +61,25 @@ class ChannelSetTest {
|
|||
Assert.assertEquals("Custom", cs.primaryChannel!!.name)
|
||||
Assert.assertFalse(cs.hasLoraConfig())
|
||||
}
|
||||
|
||||
/** validate that www.meshtastic.org host is accepted */
|
||||
@Test
|
||||
fun parseWwwHost() {
|
||||
val url = Uri.parse("https://www.meshtastic.org/e/#CgMSAQESBggBQANIAQ")
|
||||
Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name)
|
||||
}
|
||||
|
||||
/** validate that short /e path is accepted */
|
||||
@Test
|
||||
fun parseShortPath() {
|
||||
val url = Uri.parse("https://meshtastic.org/e#CgMSAQESBggBQANIAQ")
|
||||
Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name)
|
||||
}
|
||||
|
||||
/** validate that long /channel/e path is accepted */
|
||||
@Test
|
||||
fun parseLongPath() {
|
||||
val url = Uri.parse("https://meshtastic.org/channel/e/#CgMSAQESBggBQANIAQ")
|
||||
Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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.model.util
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SharedContactTest {
|
||||
|
||||
@Test
|
||||
fun testSharedContactUrlRoundTrip() {
|
||||
val original = SharedContact(user = User(long_name = "Suzume", short_name = "SZ"), node_num = 12345)
|
||||
val url = original.getSharedContactUrl()
|
||||
val parsed = url.toSharedContact()
|
||||
|
||||
assertEquals(original.node_num, parsed.node_num)
|
||||
assertEquals(original.user?.long_name, parsed.user?.long_name)
|
||||
assertEquals(original.user?.short_name, parsed.user?.short_name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWwwHostIsAccepted() {
|
||||
val url = Uri.parse("https://www.meshtastic.org/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
|
||||
val contact = url.toSharedContact()
|
||||
assertEquals("Suzume", contact.user?.long_name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLongPathIsAccepted() {
|
||||
val url = Uri.parse("https://meshtastic.org/contact/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
|
||||
val contact = url.toSharedContact()
|
||||
assertEquals("Suzume", contact.user?.long_name)
|
||||
}
|
||||
|
||||
@Test(expected = java.net.MalformedURLException::class)
|
||||
fun testInvalidHostThrows() {
|
||||
val url = Uri.parse("https://example.com/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
|
||||
url.toSharedContact()
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,13 @@ private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PAD
|
|||
*/
|
||||
@Throws(MalformedURLException::class)
|
||||
fun Uri.toChannelSet(): ChannelSet {
|
||||
if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CHANNEL_SHARE_PATH, true)) {
|
||||
val h = host ?: ""
|
||||
val isCorrectHost =
|
||||
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
|
||||
val segments = pathSegments
|
||||
val isCorrectPath = segments.any { it.equals("e", ignoreCase = true) }
|
||||
|
||||
if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
|
||||
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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.model.util
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import java.net.MalformedURLException
|
||||
|
||||
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
|
||||
|
||||
/**
|
||||
* Return a [SharedContact] that represents the contact encoded by the URL.
|
||||
*
|
||||
* @throws MalformedURLException when not recognized as a valid Meshtastic URL
|
||||
*/
|
||||
@Throws(MalformedURLException::class)
|
||||
fun Uri.toSharedContact(): SharedContact {
|
||||
val h = host ?: ""
|
||||
val isCorrectHost =
|
||||
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
|
||||
val segments = pathSegments
|
||||
val isCorrectPath = segments.any { it.equals("v", ignoreCase = true) }
|
||||
|
||||
if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
|
||||
throw MalformedURLException("Not a valid Meshtastic URL")
|
||||
}
|
||||
return SharedContact.ADAPTER.decode(Base64.decode(fragment!!, BASE64FLAGS).toByteString())
|
||||
}
|
||||
|
||||
/** Converts a [SharedContact] to its corresponding URI representation. */
|
||||
fun SharedContact.getSharedContactUrl(): Uri {
|
||||
val bytes = SharedContact.ADAPTER.encode(this)
|
||||
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
|
||||
return Uri.parse("$CONTACT_URL_PREFIX$enc")
|
||||
}
|
||||
|
||||
/** Compares two [User] objects and returns a string detailing the differences. */
|
||||
fun compareUsers(oldUser: User, newUser: User): String {
|
||||
val changes = mutableListOf<String>()
|
||||
|
||||
if (oldUser.id != newUser.id) changes.add("id: ${oldUser.id} -> ${newUser.id}")
|
||||
if (oldUser.long_name != newUser.long_name) changes.add("long_name: ${oldUser.long_name} -> ${newUser.long_name}")
|
||||
if (oldUser.short_name != newUser.short_name) {
|
||||
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
if (oldUser.macaddr != newUser.macaddr) {
|
||||
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) {
|
||||
changes.add("is_licensed: ${oldUser.is_licensed} -> ${newUser.is_licensed}")
|
||||
}
|
||||
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.base64String()} -> ${newUser.public_key.base64String()}")
|
||||
}
|
||||
|
||||
return if (changes.isEmpty()) {
|
||||
"No changes detected."
|
||||
} else {
|
||||
"Changes:\n" + changes.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts a [User] object to a string representation of its fields and values. */
|
||||
fun userFieldsToString(user: User): String {
|
||||
val fieldLines = mutableListOf<String>()
|
||||
|
||||
fieldLines.add("id: ${user.id}")
|
||||
fieldLines.add("long_name: ${user.long_name}")
|
||||
fieldLines.add("short_name: ${user.short_name}")
|
||||
@Suppress("DEPRECATION")
|
||||
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.base64String()}")
|
||||
|
||||
return fieldLines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
|
||||
|
|
@ -17,31 +17,75 @@
|
|||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.net.Uri
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
/**
|
||||
* Dispatches an incoming Meshtastic URI to the appropriate handler.
|
||||
* Dispatches an incoming Meshtastic URI to the appropriate handler based on its path.
|
||||
*
|
||||
* @param uri The URI to handle.
|
||||
* @param onChannel Callback if the URI is a Channel Set (path starts with /e/).
|
||||
* @param onContact Callback if the URI is a Shared Contact (path starts with /v/).
|
||||
* @param onChannel Callback if the URI is a Channel Set.
|
||||
* @param onContact Callback if the URI is a Shared Contact.
|
||||
* @return True if the URI was handled (matched a supported path), false otherwise.
|
||||
*/
|
||||
fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri) -> Unit = {}): Boolean {
|
||||
val path = uri.path
|
||||
// Only handle meshtastic.org URLs
|
||||
if (uri.host?.equals(MESHTASTIC_HOST, ignoreCase = true) != true || path == null) {
|
||||
return false
|
||||
}
|
||||
val h = uri.host ?: ""
|
||||
val isCorrectHost =
|
||||
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
|
||||
if (!isCorrectHost) return false
|
||||
|
||||
val segments = uri.pathSegments
|
||||
return when {
|
||||
path.startsWith(CHANNEL_SHARE_PATH, ignoreCase = true) -> {
|
||||
segments.any { it.equals("e", ignoreCase = true) } -> {
|
||||
onChannel(uri)
|
||||
true
|
||||
}
|
||||
path.startsWith(CONTACT_SHARE_PATH, ignoreCase = true) -> {
|
||||
segments.any { it.equals("v", ignoreCase = true) } -> {
|
||||
onContact(uri)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to parse a Meshtastic URI as a Channel Set or Shared Contact, including fallback logic.
|
||||
*
|
||||
* @param onChannel Callback when successfully parsed as a [ChannelSet].
|
||||
* @param onContact Callback when successfully parsed as a [SharedContact].
|
||||
* @param onInvalid Callback when parsing fails or the URI is not a Meshtastic URL.
|
||||
*/
|
||||
fun Uri.dispatchMeshtasticUri(
|
||||
onChannel: (ChannelSet) -> Unit,
|
||||
onContact: (SharedContact) -> Unit,
|
||||
onInvalid: () -> Unit,
|
||||
) {
|
||||
val handled =
|
||||
handleMeshtasticUri(
|
||||
uri = this,
|
||||
onChannel = { u ->
|
||||
runCatching { u.toChannelSet() }
|
||||
.onSuccess(onChannel)
|
||||
.onFailure { ex ->
|
||||
Logger.e(ex) { "Channel parsing error" }
|
||||
onInvalid()
|
||||
}
|
||||
},
|
||||
onContact = { u ->
|
||||
runCatching { u.toSharedContact() }
|
||||
.onSuccess(onContact)
|
||||
.onFailure { ex ->
|
||||
Logger.e(ex) { "Contact parsing error" }
|
||||
onInvalid()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (!handled) {
|
||||
// Fallback: try as contact first, then as channel
|
||||
runCatching { toSharedContact() }
|
||||
.onSuccess(onContact)
|
||||
.onFailure { runCatching { toChannelSet() }.onSuccess(onChannel).onFailure { onInvalid() } }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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.model.util
|
||||
|
||||
import android.net.Uri
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class SharedContactTest {
|
||||
|
||||
@Test
|
||||
fun testSharedContactUrlRoundTrip() {
|
||||
val original = SharedContact(user = User(long_name = "Suzume", short_name = "SZ"), node_num = 12345)
|
||||
val url = original.getSharedContactUrl()
|
||||
val parsed = url.toSharedContact()
|
||||
|
||||
assertEquals(original.node_num, parsed.node_num)
|
||||
assertEquals(original.user?.long_name, parsed.user?.long_name)
|
||||
assertEquals(original.user?.short_name, parsed.user?.short_name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWwwHostIsAccepted() {
|
||||
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
|
||||
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "www.meshtastic.org")
|
||||
val url = Uri.parse(urlStr)
|
||||
val contact = url.toSharedContact()
|
||||
assertEquals("Suzume", contact.user?.long_name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLongPathIsAccepted() {
|
||||
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
|
||||
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/contact/v/")
|
||||
val url = Uri.parse(urlStr)
|
||||
val contact = url.toSharedContact()
|
||||
assertEquals("Suzume", contact.user?.long_name)
|
||||
}
|
||||
|
||||
@Test(expected = java.net.MalformedURLException::class)
|
||||
fun testInvalidHostThrows() {
|
||||
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
|
||||
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
|
||||
val url = Uri.parse(urlStr)
|
||||
url.toSharedContact()
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ import org.junit.Assert.assertFalse
|
|||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
|
|
@ -68,4 +70,59 @@ class UriUtilsTest {
|
|||
assertTrue("Should handle mixed case URI", handled)
|
||||
assertTrue("Should invoke onChannel callback", channelCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMeshtasticUri handles www host`() {
|
||||
val uri = Uri.parse("https://www.meshtastic.org/e/somechannel")
|
||||
var channelCalled = false
|
||||
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
|
||||
assertTrue("Should handle www host", handled)
|
||||
assertTrue("Should invoke onChannel callback", channelCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMeshtasticUri handles long channel path`() {
|
||||
val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel")
|
||||
var channelCalled = false
|
||||
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
|
||||
assertTrue("Should handle long channel path", handled)
|
||||
assertTrue("Should invoke onChannel callback", channelCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMeshtasticUri handles long contact path`() {
|
||||
val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact")
|
||||
var contactCalled = false
|
||||
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
|
||||
assertTrue("Should handle long contact path", handled)
|
||||
assertTrue("Should invoke onContact callback", contactCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatchMeshtasticUri dispatches correctly`() {
|
||||
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
|
||||
val uri = original.getSharedContactUrl()
|
||||
var contactReceived: SharedContact? = null
|
||||
|
||||
uri.dispatchMeshtasticUri(onChannel = {}, onContact = { contactReceived = it }, onInvalid = {})
|
||||
|
||||
assertTrue("Contact should be received", contactReceived != null)
|
||||
assertTrue("Name should match", contactReceived?.user?.long_name == "Suzume")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatchMeshtasticUri handles invalid variants via fallback`() {
|
||||
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
|
||||
// Manual override to an "unknown" path that handleMeshtasticUri would reject
|
||||
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/fallback/")
|
||||
val uri = Uri.parse(urlStr)
|
||||
|
||||
var contactReceived: SharedContact? = null
|
||||
|
||||
uri.dispatchMeshtasticUri(onChannel = {}, onContact = { contactReceived = it }, onInvalid = {})
|
||||
|
||||
// This should fail both handleMeshtasticUri AND toSharedContact because of path validation
|
||||
// So contactReceived should be null and onInvalid called (if provided)
|
||||
assertTrue("Contact should NOT be received with invalid path", contactReceived == null)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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.material3.Text
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
class ImportFabUiTest {
|
||||
|
||||
@get:Rule val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun importFab_expands_onButtonClick() {
|
||||
val testTag = "import_fab"
|
||||
composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) }
|
||||
|
||||
// Expand the FAB
|
||||
composeTestRule.onNodeWithTag(testTag).performClick()
|
||||
|
||||
// Verify menu items are visible using their tags
|
||||
composeTestRule.onNodeWithTag("nfc_import").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("qr_import").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("url_import").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importFab_showsUrlDialog_whenUrlItemClicked() {
|
||||
val testTag = "import_fab"
|
||||
composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) }
|
||||
|
||||
composeTestRule.onNodeWithTag(testTag).performClick()
|
||||
composeTestRule.onNodeWithTag("url_import").performClick()
|
||||
|
||||
// The URL dialog should be shown.
|
||||
// We'll search for its title indirectly or check if an AlertDialog appeared.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importFab_showsShareChannels_whenCallbackProvided() {
|
||||
val testTag = "import_fab"
|
||||
composeTestRule.setContent {
|
||||
MeshtasticImportFAB(onImport = {}, onShareChannels = {}, isContactContext = false, testTag = testTag)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(testTag).performClick()
|
||||
composeTestRule.onNodeWithTag("share_channels").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importFab_showsSharedContactDialog_whenProvided() {
|
||||
val contact = SharedContact(user = User(long_name = "Suzume Goddess"), node_num = 1)
|
||||
composeTestRule.setContent {
|
||||
MeshtasticImportFAB(
|
||||
onImport = {},
|
||||
sharedContact = contact,
|
||||
onDismissSharedContact = {},
|
||||
importDialog = { shared, _ -> Text(text = "Importing ${shared.user?.long_name}") },
|
||||
)
|
||||
}
|
||||
|
||||
// Check if goddess is here
|
||||
composeTestRule.onNodeWithText("Importing Suzume Goddess").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.util
|
||||
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AlertManagerUiTest {
|
||||
|
||||
@get:Rule val composeTestRule = createComposeRule()
|
||||
|
||||
private val alertManager = AlertManager()
|
||||
|
||||
@Test
|
||||
fun alertManager_showsAlert_whenRequested() {
|
||||
composeTestRule.setContent {
|
||||
val alertData by alertManager.currentAlert.collectAsState()
|
||||
alertData?.let { data -> AlertPreviewRenderer(data) }
|
||||
}
|
||||
|
||||
val title = "UI Test Alert"
|
||||
val message = "This is a message from a UI test."
|
||||
|
||||
alertManager.showAlert(title = title, message = message)
|
||||
|
||||
composeTestRule.onNodeWithText(title).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(message).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alertManager_confirmButton_triggersCallbackAndDismisses() {
|
||||
var confirmClicked = false
|
||||
composeTestRule.setContent {
|
||||
val alertData by alertManager.currentAlert.collectAsState()
|
||||
alertData?.let { data -> AlertPreviewRenderer(data) }
|
||||
}
|
||||
|
||||
alertManager.showAlert(title = "Confirm Title", onConfirm = { confirmClicked = true })
|
||||
|
||||
// Default confirm text is "Okay" from resources, but AlertPreviewRenderer uses it
|
||||
// We'll search for the text "Okay" (assuming it matches the resource value)
|
||||
// Since we are in a test, we might need to use a hardcoded string or a resource
|
||||
// But for this test, let's just use the confirmText parameter to be sure
|
||||
alertManager.showAlert(title = "Confirm Title", confirmText = "Yes", onConfirm = { confirmClicked = true })
|
||||
|
||||
composeTestRule.onNodeWithText("Yes").performClick()
|
||||
|
||||
assert(confirmClicked)
|
||||
composeTestRule.onNodeWithText("Confirm Title").assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,44 +14,87 @@
|
|||
* 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.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.okay
|
||||
|
||||
/**
|
||||
* A comprehensive and flexible dialog component for the Meshtastic application.
|
||||
*
|
||||
* @param modifier Modifier for the dialog.
|
||||
* @param title The title text of the dialog.
|
||||
* @param titleRes The title string resource of the dialog.
|
||||
* @param message Optional plain text message.
|
||||
* @param messageRes Optional string resource message.
|
||||
* @param html Optional HTML formatted message.
|
||||
* @param icon Optional leading icon.
|
||||
* @param text Optional custom composable content for the body.
|
||||
* @param confirmText Text for the confirmation button.
|
||||
* @param confirmTextRes String resource for the confirmation button.
|
||||
* @param onConfirm Callback for the confirmation button.
|
||||
* @param dismissText Text for the dismiss button.
|
||||
* @param dismissTextRes String resource for the dismiss button.
|
||||
* @param onDismiss Callback for when the dialog is dismissed or the dismiss button is clicked.
|
||||
* @param choices If provided, displays a list of buttons instead of the standard confirm/dismiss actions.
|
||||
* @param dismissable Whether the dialog can be dismissed by clicking outside or pressing back.
|
||||
*/
|
||||
@Composable
|
||||
fun SimpleAlertDialog(
|
||||
title: String,
|
||||
message: String?,
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
fun MeshtasticDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
titleRes: StringResource? = null,
|
||||
message: String? = null,
|
||||
messageRes: StringResource? = null,
|
||||
html: String? = null,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirmRequest: () -> Unit = onDismissRequest, // Default confirm to dismiss
|
||||
icon: ImageVector? = null,
|
||||
text: @Composable (() -> Unit)? = null,
|
||||
confirmText: String? = null,
|
||||
confirmTextRes: StringResource? = null,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String? = null,
|
||||
dismissTextRes: StringResource? = null,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
choices: Map<String, () -> Unit> = emptyMap(),
|
||||
dismissable: Boolean = true,
|
||||
) {
|
||||
val annotatedString =
|
||||
val titleText = title ?: titleRes?.let { stringResource(it) } ?: ""
|
||||
val messageText = message ?: messageRes?.let { stringResource(it) }
|
||||
val confirmButtonText = confirmText ?: confirmTextRes?.let { stringResource(it) }
|
||||
val dismissButtonText = dismissText ?: dismissTextRes?.let { stringResource(it) }
|
||||
|
||||
val htmlAnnotated =
|
||||
html?.let {
|
||||
AnnotatedString.fromHtml(
|
||||
html,
|
||||
it,
|
||||
linkStyles =
|
||||
TextLinkStyles(
|
||||
style =
|
||||
|
|
@ -63,47 +106,116 @@ fun SimpleAlertDialog(
|
|||
),
|
||||
)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
if (annotatedString != null) {
|
||||
Text(text = annotatedString)
|
||||
} else {
|
||||
Text(text = message.orEmpty())
|
||||
onDismissRequest = { if (dismissable) onDismiss?.invoke() },
|
||||
modifier = modifier,
|
||||
icon = { icon?.let { Icon(it, contentDescription = null) } },
|
||||
dismissButton = {
|
||||
if (choices.isEmpty() && onDismiss != null) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
|
||||
) {
|
||||
Text(text = dismissButtonText ?: stringResource(Res.string.cancel))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onConfirmRequest) { Text(stringResource(Res.string.okay)) } },
|
||||
)
|
||||
}
|
||||
|
||||
// For Rationale Dialogs
|
||||
@Composable
|
||||
fun MultipleChoiceAlertDialog(
|
||||
title: String,
|
||||
message: String?,
|
||||
choices: Map<String, () -> Unit>,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(text = title) },
|
||||
confirmButton = {
|
||||
if (choices.isEmpty() && onConfirm != null) {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
|
||||
) {
|
||||
Text(text = confirmButtonText ?: stringResource(Res.string.okay))
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = titleText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
message?.let { Text(text = it, modifier = Modifier.padding(bottom = 8.dp)) }
|
||||
choices.forEach { (choice, action) ->
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
onClick = {
|
||||
action()
|
||||
onDismissRequest()
|
||||
},
|
||||
) {
|
||||
Text(text = choice)
|
||||
Column(modifier = if (choices.isNotEmpty()) Modifier.verticalScroll(rememberScrollState()) else Modifier) {
|
||||
if (text != null) {
|
||||
text()
|
||||
} else if (htmlAnnotated != null) {
|
||||
Text(text = htmlAnnotated)
|
||||
} else if (messageText != null) {
|
||||
Text(text = messageText)
|
||||
}
|
||||
|
||||
if (choices.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||
choices.forEach { (choice, action) ->
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
onClick = {
|
||||
action()
|
||||
onDismiss?.invoke()
|
||||
},
|
||||
) {
|
||||
Text(text = choice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A simplified [MeshtasticDialog] using only string resources. */
|
||||
@Composable
|
||||
fun MeshtasticResourceDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
titleRes: StringResource,
|
||||
messageRes: StringResource,
|
||||
confirmTextRes: StringResource? = null,
|
||||
dismissTextRes: StringResource? = null,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
dismissable: Boolean = true,
|
||||
) {
|
||||
MeshtasticDialog(
|
||||
modifier = modifier,
|
||||
titleRes = titleRes,
|
||||
messageRes = messageRes,
|
||||
confirmTextRes = confirmTextRes,
|
||||
dismissTextRes = dismissTextRes,
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
dismissable = dismissable,
|
||||
)
|
||||
}
|
||||
|
||||
/** A simplified [MeshtasticDialog] using a title resource and a plain text message. */
|
||||
@Composable
|
||||
fun MeshtasticTextDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
titleRes: StringResource,
|
||||
message: String,
|
||||
confirmTextRes: StringResource? = null,
|
||||
dismissTextRes: StringResource? = null,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
dismissable: Boolean = true,
|
||||
) {
|
||||
MeshtasticDialog(
|
||||
modifier = modifier,
|
||||
titleRes = titleRes,
|
||||
message = message,
|
||||
confirmTextRes = confirmTextRes,
|
||||
dismissTextRes = dismissTextRes,
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
dismissable = dismissable,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,47 +21,18 @@ package org.meshtastic.core.ui.component
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.net.toUri
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.WriterException
|
||||
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.model.util.getSharedContactUrl
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.share_contact
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import java.net.MalformedURLException
|
||||
|
||||
/**
|
||||
* Composable FloatingActionButton to initiate scanning a QR code for adding a contact. Handles camera permission
|
||||
* requests using Accompanist Permissions.
|
||||
*
|
||||
* @param modifier Modifier for this composable.
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun AddContactFAB(
|
||||
sharedContact: SharedContact?,
|
||||
modifier: Modifier = Modifier,
|
||||
onResult: (Uri) -> Unit,
|
||||
onShareChannels: (() -> Unit)? = null,
|
||||
onDismissSharedContact: () -> Unit,
|
||||
) {
|
||||
sharedContact?.let { SharedContactImportDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
|
||||
|
||||
ImportFab(onImport = onResult, modifier = modifier, onShareChannels = onShareChannels, isContactContext = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a dialog with the contact's information as a QR code and URI.
|
||||
|
|
@ -116,67 +87,3 @@ private fun BitMatrix.toBitmap(): Bitmap {
|
|||
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Throws(MalformedURLException::class)
|
||||
fun Uri.toSharedContact(): SharedContact {
|
||||
if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CONTACT_SHARE_PATH, true)) {
|
||||
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
|
||||
}
|
||||
return SharedContact.ADAPTER.decode(Base64.decode(fragment!!, BASE64FLAGS).toByteString())
|
||||
}
|
||||
|
||||
/** Converts a [SharedContact] to its corresponding URI representation. */
|
||||
fun SharedContact.getSharedContactUrl(): Uri {
|
||||
val bytes = SharedContact.ADAPTER.encode(this)
|
||||
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
|
||||
return "$CONTACT_URL_PREFIX$enc".toUri()
|
||||
}
|
||||
|
||||
/** Compares two [User] objects and returns a string detailing the differences. */
|
||||
fun compareUsers(oldUser: User, newUser: User): String {
|
||||
val changes = mutableListOf<String>()
|
||||
|
||||
if (oldUser.id != newUser.id) changes.add("id: ${oldUser.id} -> ${newUser.id}")
|
||||
if (oldUser.long_name != newUser.long_name) changes.add("long_name: ${oldUser.long_name} -> ${newUser.long_name}")
|
||||
if (oldUser.short_name != newUser.short_name) {
|
||||
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
|
||||
}
|
||||
if (oldUser.macaddr != newUser.macaddr) {
|
||||
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) {
|
||||
changes.add("is_licensed: ${oldUser.is_licensed} -> ${newUser.is_licensed}")
|
||||
}
|
||||
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?.base64String()} -> ${newUser.public_key?.base64String()}")
|
||||
}
|
||||
|
||||
return if (changes.isEmpty()) {
|
||||
"No changes detected."
|
||||
} else {
|
||||
"Changes:\n" + changes.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts a [User] object to a string representation of its fields and values. */
|
||||
fun userFieldsToString(user: User): String {
|
||||
val fieldLines = mutableListOf<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?.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?.base64String()}")
|
||||
|
||||
return fieldLines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
|
||||
|
|
|
|||
|
|
@ -17,23 +17,25 @@
|
|||
package org.meshtastic.core.ui.component
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -41,6 +43,8 @@ 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.import_label
|
||||
import org.meshtastic.core.strings.input_channel_url
|
||||
import org.meshtastic.core.strings.input_shared_contact_url
|
||||
import org.meshtastic.core.strings.nfc_disabled
|
||||
import org.meshtastic.core.strings.okay
|
||||
|
|
@ -55,39 +59,46 @@ 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
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.util.openNfcSettings
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
/**
|
||||
* Unified Floating Action Button for importing Meshtastic data (Contacts, Channels, etc.) via NFC, QR, or URL.
|
||||
* Unified Floating Action Button for importing Meshtastic data (Contacts, Channels, etc.) via NFC, QR, or URL. Handles
|
||||
* the [SharedContactImportDialog] if a contact is pending import.
|
||||
*
|
||||
* @param modifier Modifier for this composable.
|
||||
* @param onImport Callback when a valid Meshtastic URI is scanned or input.
|
||||
* @param modifier Modifier for this composable.
|
||||
* @param sharedContact Optional pending [SharedContact] to display an import dialog for.
|
||||
* @param onDismissSharedContact Callback to clear the pending shared contact.
|
||||
* @param onShareChannels Optional callback to trigger sharing channels.
|
||||
* @param isContactContext Hint to customize UI strings for contact importing context.
|
||||
* @param testTag Optional test tag for UI testing.
|
||||
* @param importDialog Composable to display the import dialog. Defaults to [SharedContactImportDialog].
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ImportFab(
|
||||
fun MeshtasticImportFAB(
|
||||
onImport: (Uri) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sharedContact: SharedContact? = null,
|
||||
onDismissSharedContact: () -> Unit = {},
|
||||
onShareChannels: (() -> Unit)? = null,
|
||||
isContactContext: Boolean = false,
|
||||
isContactContext: Boolean = true,
|
||||
testTag: String? = null,
|
||||
importDialog: @Composable (SharedContact, () -> Unit) -> Unit = { contact, dismiss ->
|
||||
SharedContactImportDialog(sharedContact = contact, onDismiss = dismiss)
|
||||
},
|
||||
) {
|
||||
sharedContact?.let { importDialog(it, onDismissSharedContact) }
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showUrlDialog by remember { mutableStateOf(false) }
|
||||
var isNfcScanning by remember { mutableStateOf(false) }
|
||||
var showNfcDisabledDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val barcodeScanner =
|
||||
rememberBarcodeScanner(
|
||||
onResult = { contents ->
|
||||
contents?.toUri()?.let {
|
||||
onImport(it)
|
||||
isNfcScanning = false
|
||||
}
|
||||
},
|
||||
)
|
||||
val barcodeScanner = rememberBarcodeScanner(onResult = { contents -> contents?.toUri()?.let { onImport(it) } })
|
||||
|
||||
if (isNfcScanning) {
|
||||
NfcScannerEffect(
|
||||
|
|
@ -106,29 +117,25 @@ fun ImportFab(
|
|||
}
|
||||
|
||||
if (showNfcDisabledDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showNfcDisabledDialog = false },
|
||||
title = { Text(stringResource(Res.string.scan_nfc)) },
|
||||
text = { Text(stringResource(Res.string.nfc_disabled)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.openNfcSettings()
|
||||
showNfcDisabledDialog = false
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.open_settings))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showNfcDisabledDialog = false }) { Text(stringResource(Res.string.cancel)) }
|
||||
MeshtasticDialog(
|
||||
onDismiss = { showNfcDisabledDialog = false },
|
||||
titleRes = Res.string.scan_nfc,
|
||||
messageRes = Res.string.nfc_disabled,
|
||||
onConfirm = {
|
||||
context.openNfcSettings()
|
||||
showNfcDisabledDialog = false
|
||||
},
|
||||
confirmTextRes = Res.string.open_settings,
|
||||
dismissTextRes = Res.string.cancel,
|
||||
)
|
||||
}
|
||||
|
||||
if (showUrlDialog) {
|
||||
InputUrlDialog(
|
||||
title = stringResource(Res.string.input_shared_contact_url),
|
||||
title =
|
||||
stringResource(
|
||||
if (isContactContext) Res.string.input_shared_contact_url else Res.string.input_channel_url,
|
||||
),
|
||||
onDismiss = { showUrlDialog = false },
|
||||
onConfirm = { contents ->
|
||||
onImport(contents.toUri())
|
||||
|
|
@ -146,6 +153,7 @@ fun ImportFab(
|
|||
),
|
||||
icon = Icons.Rounded.Nfc,
|
||||
onClick = { isNfcScanning = true },
|
||||
testTag = "nfc_import",
|
||||
),
|
||||
MenuFABItem(
|
||||
label =
|
||||
|
|
@ -154,11 +162,16 @@ fun ImportFab(
|
|||
),
|
||||
icon = Icons.TwoTone.QrCodeScanner,
|
||||
onClick = { barcodeScanner.startScan() },
|
||||
testTag = "qr_import",
|
||||
),
|
||||
MenuFABItem(
|
||||
label = stringResource(Res.string.input_shared_contact_url),
|
||||
label =
|
||||
stringResource(
|
||||
if (isContactContext) Res.string.input_shared_contact_url else Res.string.input_channel_url,
|
||||
),
|
||||
icon = Icons.Rounded.Link,
|
||||
onClick = { showUrlDialog = true },
|
||||
testTag = "url_import",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -168,6 +181,7 @@ fun ImportFab(
|
|||
label = stringResource(Res.string.share_channels_qr),
|
||||
icon = MeshtasticIcons.QrCode2,
|
||||
onClick = it,
|
||||
testTag = "share_channels",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -177,26 +191,27 @@ fun ImportFab(
|
|||
onExpandedChange = { expanded = it },
|
||||
items = items,
|
||||
modifier = modifier.padding(bottom = 16.dp),
|
||||
contentDescription = stringResource(Res.string.import_label),
|
||||
testTag = testTag,
|
||||
)
|
||||
}
|
||||
|
||||
@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)) } },
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
titleRes = Res.string.scan_nfc,
|
||||
messageRes = Res.string.scan_nfc_text,
|
||||
dismissTextRes = 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) },
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = title,
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = urlText,
|
||||
|
|
@ -206,7 +221,33 @@ private fun InputUrlDialog(title: String, onDismiss: () -> Unit, onConfirm: (Str
|
|||
maxLines = 4,
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(urlText) }) { Text(stringResource(Res.string.okay)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
onConfirm = { onConfirm(urlText) },
|
||||
confirmTextRes = Res.string.okay,
|
||||
dismissTextRes = Res.string.cancel,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Contact Context")
|
||||
@Composable
|
||||
fun PreviewImportFABContact() {
|
||||
AppTheme {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
MeshtasticImportFAB(onImport = {}, modifier = Modifier.align(Alignment.BottomEnd), isContactContext = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Channel Context with Sharing")
|
||||
@Composable
|
||||
fun PreviewImportFABChannel() {
|
||||
AppTheme {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
MeshtasticImportFAB(
|
||||
onImport = {},
|
||||
onShareChannels = {},
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
isContactContext = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,13 +30,11 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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
|
||||
|
|
@ -48,7 +46,6 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -188,13 +185,11 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
|
|||
}
|
||||
}
|
||||
if (isLegendOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { isLegendOpen = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
MeshtasticDialog(
|
||||
onDismiss = { isLegendOpen = false },
|
||||
dismissText = stringResource(Res.string.close),
|
||||
title = stringResource(Res.string.indoor_air_quality_iaq),
|
||||
text = { IAQScale() },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { isLegendOpen = false }) { Text(text = stringResource(Res.string.close)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -208,12 +203,6 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
|
|||
@Composable
|
||||
fun IAQScale(modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.padding(16.dp), horizontalAlignment = Alignment.Start) {
|
||||
Text(
|
||||
text = stringResource(Res.string.indoor_air_quality_iaq),
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
for (iaq in Iaq.entries) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
|
|
@ -38,9 +39,11 @@ fun MenuFAB(
|
|||
onExpandedChange: (Boolean) -> Unit,
|
||||
items: List<MenuFABItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
testTag: String? = null,
|
||||
) {
|
||||
FloatingActionButtonMenu(
|
||||
modifier = modifier,
|
||||
modifier = modifier.then(if (testTag != null) Modifier.testTag(testTag) else Modifier),
|
||||
expanded = expanded,
|
||||
button = {
|
||||
ToggleFloatingActionButton(
|
||||
|
|
@ -48,7 +51,7 @@ fun MenuFAB(
|
|||
onCheckedChange = onExpandedChange,
|
||||
content = {
|
||||
val imageVector = if (expanded) Icons.Filled.Close else Icons.AutoMirrored.Rounded.OfflineShare
|
||||
Icon(imageVector = imageVector, contentDescription = null)
|
||||
Icon(imageVector = imageVector, contentDescription = contentDescription)
|
||||
},
|
||||
containerColor = ToggleFloatingActionButtonDefaults.containerColor(),
|
||||
)
|
||||
|
|
@ -57,6 +60,7 @@ fun MenuFAB(
|
|||
) {
|
||||
items.forEach { item ->
|
||||
FloatingActionButtonMenuItem(
|
||||
modifier = if (item.testTag != null) Modifier.testTag(item.testTag) else Modifier,
|
||||
onClick = {
|
||||
item.onClick()
|
||||
onExpandedChange(false)
|
||||
|
|
@ -68,4 +72,4 @@ fun MenuFAB(
|
|||
}
|
||||
}
|
||||
|
||||
data class MenuFABItem(val label: String, val icon: ImageVector, val onClick: () -> Unit)
|
||||
data class MenuFABItem(val label: String, val icon: ImageVector, val onClick: () -> Unit, val testTag: String? = null)
|
||||
|
|
|
|||
|
|
@ -29,12 +29,10 @@ 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
|
||||
|
|
@ -48,7 +46,6 @@ 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
|
||||
|
|
@ -84,11 +81,11 @@ fun QrDialog(title: String, uri: Uri, qrCode: Bitmap?, onDismiss: () -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
title = { Text(text = title) },
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = title,
|
||||
confirmText = stringResource(Res.string.okay),
|
||||
onConfirm = onDismiss,
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (qrCode != null) {
|
||||
|
|
@ -126,6 +123,5 @@ fun QrDialog(title: String, uri: Uri, qrCode: Bitmap?, onDismiss: () -> Unit) {
|
|||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.okay)) } },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.message
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.sample_message
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun SimpleAlertDialog(
|
||||
title: StringResource,
|
||||
text: @Composable (() -> Unit)? = null,
|
||||
confirmText: String? = null,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String? = null,
|
||||
onDismiss: () -> Unit,
|
||||
) = AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
|
||||
) {
|
||||
Text(text = dismissText ?: stringResource(Res.string.cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
onConfirm?.let {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
|
||||
) {
|
||||
Text(text = confirmText ?: stringResource(Res.string.okay))
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(text = stringResource(title), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
},
|
||||
text = text,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SimpleAlertDialog(
|
||||
title: StringResource,
|
||||
text: StringResource,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
onDismiss: () -> Unit = {},
|
||||
) = SimpleAlertDialog(
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
title = title,
|
||||
text = { Text(text = stringResource(text), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) },
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SimpleAlertDialog(
|
||||
title: StringResource,
|
||||
text: String,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
onDismiss: () -> Unit = {},
|
||||
) = SimpleAlertDialog(
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
title = title,
|
||||
text = { Text(text = text, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) },
|
||||
)
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun SimpleAlertDialogPreview() {
|
||||
AppTheme { SimpleAlertDialog(title = Res.string.message, text = Res.string.sample_message) }
|
||||
}
|
||||
|
|
@ -25,15 +25,15 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.util.compareUsers
|
||||
import org.meshtastic.core.model.util.userFieldsToString
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.import_known_shared_contact_text
|
||||
import org.meshtastic.core.strings.import_label
|
||||
import org.meshtastic.core.strings.import_shared_contact
|
||||
import org.meshtastic.core.strings.public_key_changed
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.core.ui.component.compareUsers
|
||||
import org.meshtastic.core.ui.component.userFieldsToString
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
|
|
@ -49,8 +49,8 @@ fun SharedContactDialog(
|
|||
val nodeNum = sharedContact.node_num
|
||||
val node = unfilteredNodes.find { it.num == nodeNum }
|
||||
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.import_shared_contact,
|
||||
MeshtasticDialog(
|
||||
titleRes = Res.string.import_shared_contact,
|
||||
text = {
|
||||
Column {
|
||||
if (node != null) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
fun interface ComposableContent {
|
||||
@Composable fun Content()
|
||||
}
|
||||
|
||||
/**
|
||||
* A global manager for displaying alerts across the application. This allows ViewModels to trigger alerts without
|
||||
* direct dependencies on UI components.
|
||||
*/
|
||||
@Singleton
|
||||
class AlertManager @Inject constructor() {
|
||||
data class AlertData(
|
||||
val title: String? = null,
|
||||
val titleRes: StringResource? = null,
|
||||
val message: String? = null,
|
||||
val messageRes: StringResource? = null,
|
||||
val composableMessage: ComposableContent? = null,
|
||||
val html: String? = null,
|
||||
val icon: ImageVector? = null,
|
||||
val onConfirm: (() -> Unit)? = null,
|
||||
val onDismiss: (() -> Unit)? = null,
|
||||
val confirmText: String? = null,
|
||||
val confirmTextRes: StringResource? = null,
|
||||
val dismissText: String? = null,
|
||||
val dismissTextRes: StringResource? = null,
|
||||
val choices: Map<String, () -> Unit> = emptyMap(),
|
||||
val dismissable: Boolean = true,
|
||||
)
|
||||
|
||||
private val _currentAlert = MutableStateFlow<AlertData?>(null)
|
||||
val currentAlert = _currentAlert.asStateFlow()
|
||||
|
||||
fun showAlert(
|
||||
title: String? = null,
|
||||
titleRes: StringResource? = null,
|
||||
message: String? = null,
|
||||
messageRes: StringResource? = null,
|
||||
composableMessage: ComposableContent? = null,
|
||||
html: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
onConfirm: (() -> Unit)? = {},
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
confirmText: String? = null,
|
||||
confirmTextRes: StringResource? = null,
|
||||
dismissText: String? = null,
|
||||
dismissTextRes: StringResource? = null,
|
||||
choices: Map<String, () -> Unit> = emptyMap(),
|
||||
dismissable: Boolean = true,
|
||||
) {
|
||||
_currentAlert.value =
|
||||
AlertData(
|
||||
title = title,
|
||||
titleRes = titleRes,
|
||||
message = message,
|
||||
messageRes = messageRes,
|
||||
composableMessage = composableMessage,
|
||||
html = html,
|
||||
icon = icon,
|
||||
onConfirm = {
|
||||
onConfirm?.invoke()
|
||||
dismissAlert()
|
||||
},
|
||||
onDismiss = {
|
||||
onDismiss?.invoke()
|
||||
dismissAlert()
|
||||
},
|
||||
confirmText = confirmText,
|
||||
confirmTextRes = confirmTextRes,
|
||||
dismissText = dismissText,
|
||||
dismissTextRes = dismissTextRes,
|
||||
choices = choices,
|
||||
dismissable = dismissable,
|
||||
)
|
||||
}
|
||||
|
||||
fun dismissAlert() {
|
||||
_currentAlert.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -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/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
/** A helper component that renders an [AlertManager.AlertData] using the same logic as MainScreen. */
|
||||
@Composable
|
||||
fun AlertPreviewRenderer(data: AlertManager.AlertData) {
|
||||
MeshtasticDialog(
|
||||
title = data.title,
|
||||
titleRes = data.titleRes,
|
||||
message = data.message,
|
||||
messageRes = data.messageRes,
|
||||
html = data.html,
|
||||
icon = data.icon,
|
||||
text = data.composableMessage?.let { msg -> { msg.Content() } },
|
||||
confirmText = data.confirmText,
|
||||
confirmTextRes = data.confirmTextRes,
|
||||
onConfirm = data.onConfirm,
|
||||
dismissText = data.dismissText,
|
||||
dismissTextRes = data.dismissTextRes,
|
||||
onDismiss = data.onDismiss,
|
||||
choices = data.choices,
|
||||
dismissable = data.dismissable,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Simple Text Alert")
|
||||
@Composable
|
||||
fun PreviewTextAlert() {
|
||||
AppTheme {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AlertPreviewRenderer(
|
||||
AlertManager.AlertData(
|
||||
title = "Firmware Update",
|
||||
message = "A new version is available. Would you like to update now?",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Icon and Text Alert")
|
||||
@Composable
|
||||
fun PreviewIconAlert() {
|
||||
AppTheme {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AlertPreviewRenderer(
|
||||
AlertManager.AlertData(
|
||||
title = "Warning",
|
||||
message = "This action cannot be undone.",
|
||||
icon = Icons.Rounded.Warning,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "HTML Alert")
|
||||
@Composable
|
||||
fun PreviewHtmlAlert() {
|
||||
AppTheme {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AlertPreviewRenderer(
|
||||
AlertManager.AlertData(title = "Release Notes", html = "Enhanced range and better battery life"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Multiple Choice Alert")
|
||||
@Composable
|
||||
fun PreviewMultipleChoiceAlert() {
|
||||
AppTheme {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AlertPreviewRenderer(
|
||||
AlertManager.AlertData(
|
||||
title = "Select Channel",
|
||||
message = "Pick a channel to join:",
|
||||
choices = mapOf("Public" to {}, "Private" to {}, "Emergency" to {}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Composable Content Alert")
|
||||
@Composable
|
||||
fun PreviewComposableAlert() {
|
||||
AppTheme {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AlertPreviewRenderer(
|
||||
AlertManager.AlertData(
|
||||
title = "Custom Content",
|
||||
composableMessage = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("This is a custom composable")
|
||||
Text("With multiple lines and styles")
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* 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.util
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
|
||||
|
||||
/**
|
||||
* Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
|
||||
*/
|
||||
fun annotateTraceroute(
|
||||
inString: String?,
|
||||
statusGreen: Color,
|
||||
statusYellow: Color,
|
||||
statusOrange: Color,
|
||||
): AnnotatedString {
|
||||
if (inString == null) return buildAnnotatedString { append("") }
|
||||
|
||||
return buildAnnotatedString {
|
||||
inString.lines().forEachIndexed { i, line ->
|
||||
if (i > 0) append("\n")
|
||||
// Example line: "⇊ -8.75 dB SNR"
|
||||
if (line.trimStart().startsWith("⇊")) {
|
||||
val snrRegex = Regex("""⇊ ([\d.?-]+) dB""")
|
||||
val snrMatch = snrRegex.find(line)
|
||||
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
|
||||
|
||||
if (snrValue != null) {
|
||||
val snrColor =
|
||||
when {
|
||||
snrValue >= SNR_GOOD_THRESHOLD -> statusGreen
|
||||
snrValue >= SNR_FAIR_THRESHOLD -> statusYellow
|
||||
else -> statusOrange
|
||||
}
|
||||
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) }
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw neighbor info string into an [AnnotatedString] with SNR values highlighted according to their quality.
|
||||
*/
|
||||
fun annotateNeighborInfo(
|
||||
inString: String?,
|
||||
statusGreen: Color,
|
||||
statusYellow: Color,
|
||||
statusOrange: Color,
|
||||
): AnnotatedString {
|
||||
if (inString == null) return buildAnnotatedString { append("") }
|
||||
|
||||
return buildAnnotatedString {
|
||||
inString.lines().forEachIndexed { i, line ->
|
||||
if (i > 0) append("\n")
|
||||
// Example line: "• NodeName (SNR: 5.5)"
|
||||
if (line.contains("(SNR: ")) {
|
||||
val snrRegex = Regex("""\(SNR: ([\d.?-]+)\)""")
|
||||
val snrMatch = snrRegex.find(line)
|
||||
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
|
||||
|
||||
if (snrValue != null) {
|
||||
val snrColor =
|
||||
when {
|
||||
snrValue >= SNR_GOOD_THRESHOLD -> statusGreen
|
||||
snrValue >= SNR_FAIR_THRESHOLD -> statusYellow
|
||||
else -> statusOrange
|
||||
}
|
||||
val snrPrefix = "(SNR: "
|
||||
append(line.substring(0, line.indexOf(snrPrefix) + snrPrefix.length))
|
||||
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append("$snrValue") }
|
||||
append(")")
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.util
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AlertManagerTest {
|
||||
|
||||
private val alertManager = AlertManager()
|
||||
|
||||
@Test
|
||||
fun `showAlert updates currentAlert flow`() {
|
||||
val title = "Test Title"
|
||||
val message = "Test Message"
|
||||
|
||||
alertManager.showAlert(title = title, message = message)
|
||||
|
||||
val alertData = alertManager.currentAlert.value
|
||||
assertNotNull(alertData)
|
||||
assertEquals(title, alertData?.title)
|
||||
assertEquals(message, alertData?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissAlert clears currentAlert flow`() {
|
||||
alertManager.showAlert(title = "Title")
|
||||
assertNotNull(alertManager.currentAlert.value)
|
||||
|
||||
alertManager.dismissAlert()
|
||||
assertNull(alertManager.currentAlert.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onConfirm triggers and dismisses alert`() {
|
||||
var confirmClicked = false
|
||||
alertManager.showAlert(title = "Confirm Test", onConfirm = { confirmClicked = true })
|
||||
|
||||
alertManager.currentAlert.value?.onConfirm?.invoke()
|
||||
|
||||
assertEquals(true, confirmClicked)
|
||||
assertNull(alertManager.currentAlert.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onDismiss triggers and dismisses alert`() {
|
||||
var dismissClicked = false
|
||||
alertManager.showAlert(title = "Dismiss Test", onDismiss = { dismissClicked = true })
|
||||
|
||||
alertManager.currentAlert.value?.onDismiss?.invoke()
|
||||
|
||||
assertEquals(true, dismissClicked)
|
||||
assertNull(alertManager.currentAlert.value)
|
||||
}
|
||||
}
|
||||
|
|
@ -42,7 +42,6 @@ import androidx.compose.foundation.text.TextAutoSize
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
|
|
@ -136,6 +135,7 @@ import org.meshtastic.core.strings.i_know_what_i_m_doing
|
|||
import org.meshtastic.core.strings.learn_more
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.save
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.icon.Bluetooth
|
||||
import org.meshtastic.core.ui.icon.CheckCircle
|
||||
import org.meshtastic.core.ui.icon.CloudDownload
|
||||
|
|
@ -208,24 +208,17 @@ fun FirmwareUpdateScreen(
|
|||
androidx.activity.compose.BackHandler(enabled = shouldKeepFirmwareScreenOn(state)) { showExitConfirmation = true }
|
||||
|
||||
if (showExitConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExitConfirmation = false },
|
||||
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
|
||||
text = { Text(stringResource(Res.string.firmware_update_disconnect_warning)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showExitConfirmation = false
|
||||
viewModel.cancelUpdate()
|
||||
navController.navigateUp()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.firmware_update_retry)) // Use "Cancel & Exit" if available
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExitConfirmation = false }) { Text(stringResource(Res.string.back)) }
|
||||
MeshtasticDialog(
|
||||
onDismiss = { showExitConfirmation = false },
|
||||
title = stringResource(Res.string.firmware_update_disclaimer_title),
|
||||
message = stringResource(Res.string.firmware_update_disconnect_warning),
|
||||
confirmText = stringResource(Res.string.firmware_update_retry),
|
||||
onConfirm = {
|
||||
showExitConfirmation = false
|
||||
viewModel.cancelUpdate()
|
||||
navController.navigateUp()
|
||||
},
|
||||
dismissText = stringResource(Res.string.back),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -387,7 +380,7 @@ private fun ReadyState(
|
|||
if (showDisclaimer) {
|
||||
DisclaimerDialog(
|
||||
updateMethod = state.updateMethod,
|
||||
onDismissRequest = { showDisclaimer = false },
|
||||
onDismiss = { showDisclaimer = false },
|
||||
onConfirm = {
|
||||
showDisclaimer = false
|
||||
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
|
||||
|
|
@ -450,10 +443,13 @@ private fun ReadyState(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissRequest: () -> Unit, onConfirm: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
|
||||
private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismiss: () -> Unit, onConfirm: () -> Unit) {
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(Res.string.firmware_update_disclaimer_title),
|
||||
confirmText = stringResource(Res.string.i_know_what_i_m_doing),
|
||||
onConfirm = onConfirm,
|
||||
dismissText = stringResource(Res.string.cancel),
|
||||
text = {
|
||||
Column(modifier = Modifier.animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(stringResource(Res.string.firmware_update_disclaimer_text))
|
||||
|
|
@ -478,8 +474,6 @@ private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissReques
|
|||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(Res.string.i_know_what_i_m_doing)) } },
|
||||
dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -757,20 +751,16 @@ private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, o
|
|||
var showDialog by remember { mutableStateOf(true) }
|
||||
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { /* No-op to force user to acknowledge */ },
|
||||
title = { Text(stringResource(Res.string.firmware_update_usb_instruction_title)) },
|
||||
text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDialog = false
|
||||
onSaveFile(state.fileName)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.okay))
|
||||
}
|
||||
MeshtasticDialog(
|
||||
onDismiss = { /* No-op to force user to acknowledge */ },
|
||||
title = stringResource(Res.string.firmware_update_usb_instruction_title),
|
||||
confirmText = stringResource(Res.string.okay),
|
||||
onConfirm = {
|
||||
showDialog = false
|
||||
onSaveFile(state.fileName)
|
||||
},
|
||||
text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
|
||||
dismissable = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 org.meshtastic.feature.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -28,7 +27,6 @@ import androidx.compose.foundation.lazy.items
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -37,7 +35,6 @@ import androidx.compose.material3.ListItem
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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
|
||||
|
|
@ -67,6 +64,7 @@ import org.meshtastic.core.strings.url_cannot_be_empty
|
|||
import org.meshtastic.core.strings.url_must_contain_placeholders
|
||||
import org.meshtastic.core.strings.url_template
|
||||
import org.meshtastic.core.strings.url_template_hint
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.map.MapViewModel
|
||||
|
||||
|
|
@ -191,16 +189,13 @@ private fun AddEditCustomTileProviderDialog(
|
|||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
if (config == null) {
|
||||
stringResource(Res.string.add_custom_tile_source)
|
||||
} else {
|
||||
stringResource(Res.string.edit_custom_tile_source)
|
||||
},
|
||||
)
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title =
|
||||
if (config == null) {
|
||||
stringResource(Res.string.add_custom_tile_source)
|
||||
} else {
|
||||
stringResource(Res.string.edit_custom_tile_source)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
|
@ -235,8 +230,9 @@ private fun AddEditCustomTileProviderDialog(
|
|||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { Button(onClick = { validateAndSave() }) { Text(stringResource(Res.string.save)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
onConfirm = { validateAndSave() },
|
||||
confirmTextRes = Res.string.save,
|
||||
dismissTextRes = Res.string.cancel,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ package org.meshtastic.feature.messaging
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -36,6 +34,7 @@ import org.meshtastic.core.strings.close
|
|||
import org.meshtastic.core.strings.message_retry_count
|
||||
import org.meshtastic.core.strings.relays
|
||||
import org.meshtastic.core.strings.resend
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
|
||||
@Suppress("UnusedParameter")
|
||||
@Composable
|
||||
|
|
@ -49,28 +48,12 @@ fun DeliveryInfo(
|
|||
maxRetries: Int = 0,
|
||||
onConfirm: (() -> Unit) = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
) = AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
dismissButton = {
|
||||
FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Text(text = stringResource(Res.string.close))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (resendOption) {
|
||||
FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Text(text = stringResource(Res.string.resend))
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
},
|
||||
) = MeshtasticDialog(
|
||||
title = stringResource(title),
|
||||
onDismiss = onDismiss,
|
||||
dismissText = stringResource(Res.string.close),
|
||||
confirmText = if (resendOption) stringResource(Res.string.resend) else null,
|
||||
onConfirm = if (resendOption) onConfirm else null,
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
text?.let {
|
||||
|
|
@ -98,6 +81,4 @@ fun DeliveryInfo(
|
|||
}
|
||||
}
|
||||
},
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ import androidx.compose.material.icons.rounded.SelectAll
|
|||
import androidx.compose.material.icons.rounded.SpeakerNotesOff
|
||||
import androidx.compose.material.icons.rounded.Visibility
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
|
|
@ -74,7 +73,6 @@ import androidx.compose.material3.OutlinedTextField
|
|||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -110,7 +108,6 @@ import org.meshtastic.core.model.util.getChannel
|
|||
import org.meshtastic.core.service.RetryEvent
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.alert_bell_text
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.cancel_reply
|
||||
import org.meshtastic.core.strings.clear_selection
|
||||
import org.meshtastic.core.strings.copy
|
||||
|
|
@ -135,6 +132,7 @@ import org.meshtastic.core.strings.send
|
|||
import org.meshtastic.core.strings.type_a_message
|
||||
import org.meshtastic.core.strings.unknown
|
||||
import org.meshtastic.core.strings.unknown_channel
|
||||
import org.meshtastic.core.ui.component.MeshtasticTextDialog
|
||||
import org.meshtastic.core.ui.component.NodeKeyStatusIcon
|
||||
import org.meshtastic.core.ui.component.SecurityIcon
|
||||
import org.meshtastic.core.ui.component.SharedContactDialog
|
||||
|
|
@ -241,11 +239,6 @@ fun MessageScreen(
|
|||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val lastReadMessageTimestamp by
|
||||
remember(contactKey, contactSettings) {
|
||||
derivedStateOf { contactSettings[contactKey]?.lastReadMessageTimestamp }
|
||||
}
|
||||
|
||||
// Track unread messages using lightweight metadata queries
|
||||
val hasUnreadMessages by viewModel.hasUnreadMessages(contactKey).collectAsStateWithLifecycle(initialValue = false)
|
||||
val firstUnreadMessageUuid by
|
||||
|
|
@ -307,7 +300,11 @@ fun MessageScreen(
|
|||
is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum)
|
||||
MessageScreenEvent.NavigateBack -> onNavigateBack()
|
||||
is MessageScreenEvent.CopyToClipboard -> {
|
||||
clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text))
|
||||
coroutineScope.launch {
|
||||
clipboardManager.setClipEntry(
|
||||
androidx.compose.ui.platform.ClipEntry(ClipData.newPlainText(event.text, event.text)),
|
||||
)
|
||||
}
|
||||
selectedMessageIds.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
|
@ -644,13 +641,12 @@ private fun String.limitBytes(maxBytes: Int): String {
|
|||
private fun DeleteMessageDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
val deleteMessagesString = pluralStringResource(Res.plurals.delete_messages, count, count)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
title = { Text(stringResource(Res.string.delete_messages_title)) },
|
||||
text = { Text(text = deleteMessagesString) },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(Res.string.delete)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
MeshtasticTextDialog(
|
||||
titleRes = Res.string.delete_messages_title,
|
||||
message = deleteMessagesString,
|
||||
confirmTextRes = Res.string.delete,
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.feature.messaging
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -38,8 +36,6 @@ import androidx.compose.material.icons.rounded.Add
|
|||
import androidx.compose.material.icons.rounded.DragHandle
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.FastForward
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -63,8 +59,6 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
|
|
@ -84,6 +78,7 @@ import org.meshtastic.core.strings.quick_chat_instant
|
|||
import org.meshtastic.core.strings.quick_chat_new
|
||||
import org.meshtastic.core.strings.save
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.dragContainer
|
||||
import org.meshtastic.core.ui.component.dragDropItemsIndexed
|
||||
import org.meshtastic.core.ui.component.rememberDragDropState
|
||||
|
|
@ -185,20 +180,17 @@ private fun EditQuickChatDialog(
|
|||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(title),
|
||||
confirmText = stringResource(Res.string.save),
|
||||
onConfirm = {
|
||||
onSave(actionInput)
|
||||
onDismiss()
|
||||
},
|
||||
dismissText = stringResource(Res.string.cancel),
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextFieldWithCounter(
|
||||
|
|
@ -257,39 +249,19 @@ private fun EditQuickChatDialog(
|
|||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(modifier = Modifier.weight(1f), onClick = onDismiss) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
|
||||
if (!newQuickChat) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
onDelete(actionInput)
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(Res.string.delete))
|
||||
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
onSave(actionInput)
|
||||
onDismiss()
|
||||
},
|
||||
enabled = actionInput.name.isNotEmpty() && actionInput.message.isNotEmpty(),
|
||||
) {
|
||||
Text(text = stringResource(Res.string.save))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ package org.meshtastic.feature.messaging.component
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -42,6 +40,7 @@ import org.meshtastic.core.strings.retry_dialog_confirm
|
|||
import org.meshtastic.core.strings.retry_dialog_message
|
||||
import org.meshtastic.core.strings.retry_dialog_reaction_message
|
||||
import org.meshtastic.core.strings.retry_dialog_title
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
|
||||
private const val COUNTDOWN_DELAY_MS = 1000L
|
||||
private const val MESSAGE_PREVIEW_LENGTH = 50
|
||||
|
|
@ -120,22 +119,13 @@ fun RetryConfirmationDialog(
|
|||
onTimeout()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { /* Prevent dismissal by clicking outside */ },
|
||||
dismissButton = {
|
||||
FilledTonalButton(onClick = onCancel) { Text(text = stringResource(Res.string.retry_dialog_cancel)) }
|
||||
},
|
||||
confirmButton = {
|
||||
FilledTonalButton(onClick = onConfirm) { Text(text = stringResource(Res.string.retry_dialog_confirm)) }
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.retry_dialog_title),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
},
|
||||
MeshtasticDialog(
|
||||
onDismiss = onCancel,
|
||||
dismissText = stringResource(Res.string.retry_dialog_cancel),
|
||||
confirmText = stringResource(Res.string.retry_dialog_confirm),
|
||||
onConfirm = onConfirm,
|
||||
title = stringResource(Res.string.retry_dialog_title),
|
||||
text = { RetryDialogContent(retryEvent, timeRemaining) },
|
||||
dismissable = false,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,10 +41,6 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.OutlinedButton
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -66,13 +62,6 @@ import org.meshtastic.feature.node.model.MetricsState
|
|||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
||||
|
||||
private enum class DialogType {
|
||||
FAVORITE,
|
||||
IGNORE,
|
||||
MUTE,
|
||||
REMOVE,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeviceActions(
|
||||
node: Node,
|
||||
|
|
@ -84,38 +73,13 @@ fun DeviceActions(
|
|||
modifier: Modifier = Modifier,
|
||||
isLocal: Boolean = false,
|
||||
) {
|
||||
var displayedDialog by remember { mutableStateOf<DialogType?>(null) }
|
||||
|
||||
NodeActionDialogs(
|
||||
node = node,
|
||||
displayFavoriteDialog = displayedDialog == DialogType.FAVORITE,
|
||||
displayIgnoreDialog = displayedDialog == DialogType.IGNORE,
|
||||
displayMuteDialog = displayedDialog == DialogType.MUTE,
|
||||
displayRemoveDialog = displayedDialog == DialogType.REMOVE,
|
||||
onDismissMenuRequest = { displayedDialog = null },
|
||||
onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) },
|
||||
onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
|
||||
onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) },
|
||||
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
|
||||
)
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SectionCard(title = Res.string.actions) {
|
||||
PrimaryActionsRow(
|
||||
node = node,
|
||||
isLocal = isLocal,
|
||||
onAction = onAction,
|
||||
onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
|
||||
)
|
||||
PrimaryActionsRow(node = node, isLocal = isLocal, onAction = onAction)
|
||||
|
||||
if (!isLocal) {
|
||||
SectionDivider(Modifier.padding(vertical = 8.dp))
|
||||
ManagementActions(
|
||||
node = node,
|
||||
onIgnoreClick = { displayedDialog = DialogType.IGNORE },
|
||||
onMuteClick = { displayedDialog = DialogType.MUTE },
|
||||
onRemoveClick = { displayedDialog = DialogType.REMOVE },
|
||||
)
|
||||
ManagementActions(node = node, onAction = onAction)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -132,12 +96,7 @@ fun DeviceActions(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun PrimaryActionsRow(
|
||||
node: Node,
|
||||
isLocal: Boolean,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
onFavoriteClick: () -> Unit,
|
||||
) {
|
||||
private fun PrimaryActionsRow(node: Node, isLocal: Boolean, onAction: (NodeDetailAction) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
|
|
@ -173,7 +132,10 @@ private fun PrimaryActionsRow(
|
|||
}
|
||||
|
||||
if (!isLocal) {
|
||||
IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
|
||||
IconToggleButton(
|
||||
checked = node.isFavorite,
|
||||
onCheckedChange = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(node))) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (node.isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||
contentDescription = stringResource(Res.string.favorite),
|
||||
|
|
@ -185,12 +147,7 @@ private fun PrimaryActionsRow(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun ManagementActions(
|
||||
node: Node,
|
||||
onIgnoreClick: () -> Unit,
|
||||
onMuteClick: () -> Unit,
|
||||
onRemoveClick: () -> Unit,
|
||||
) {
|
||||
private fun ManagementActions(node: Node, onAction: (NodeDetailAction) -> Unit) {
|
||||
Column {
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.ignore),
|
||||
|
|
@ -201,7 +158,7 @@ private fun ManagementActions(
|
|||
Icons.AutoMirrored.Default.VolumeUp
|
||||
},
|
||||
checked = node.isIgnored,
|
||||
onClick = onIgnoreClick,
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(node))) },
|
||||
)
|
||||
|
||||
if (node.capabilities.canMuteNode) {
|
||||
|
|
@ -214,7 +171,7 @@ private fun ManagementActions(
|
|||
Icons.AutoMirrored.Default.VolumeUp
|
||||
},
|
||||
checked = node.isMuted,
|
||||
onClick = onMuteClick,
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(node))) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -224,7 +181,7 @@ private fun ManagementActions(
|
|||
trailingIcon = null,
|
||||
textColor = MaterialTheme.colorScheme.error,
|
||||
leadingIconTint = MaterialTheme.colorScheme.error,
|
||||
onClick = onRemoveClick,
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(node))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
/*
|
||||
* 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.feature.node.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.favorite
|
||||
import org.meshtastic.core.strings.favorite_add
|
||||
import org.meshtastic.core.strings.favorite_remove
|
||||
import org.meshtastic.core.strings.ignore
|
||||
import org.meshtastic.core.strings.ignore_add
|
||||
import org.meshtastic.core.strings.ignore_remove
|
||||
import org.meshtastic.core.strings.mute_add
|
||||
import org.meshtastic.core.strings.mute_notifications
|
||||
import org.meshtastic.core.strings.mute_remove
|
||||
import org.meshtastic.core.strings.remove
|
||||
import org.meshtastic.core.strings.remove_node_text
|
||||
import org.meshtastic.core.strings.unmute
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
|
||||
@Composable
|
||||
fun NodeActionDialogs(
|
||||
node: Node,
|
||||
displayFavoriteDialog: Boolean,
|
||||
displayIgnoreDialog: Boolean,
|
||||
displayMuteDialog: Boolean,
|
||||
displayRemoveDialog: Boolean,
|
||||
onDismissMenuRequest: () -> Unit,
|
||||
onConfirmFavorite: (Node) -> Unit,
|
||||
onConfirmIgnore: (Node) -> Unit,
|
||||
onConfirmMute: (Node) -> Unit,
|
||||
onConfirmRemove: (Node) -> Unit,
|
||||
) {
|
||||
if (displayFavoriteDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.favorite,
|
||||
text =
|
||||
stringResource(
|
||||
if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add,
|
||||
node.user.long_name ?: "",
|
||||
),
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onConfirmFavorite(node)
|
||||
},
|
||||
onDismiss = onDismissMenuRequest,
|
||||
)
|
||||
}
|
||||
if (displayIgnoreDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.ignore,
|
||||
text =
|
||||
stringResource(
|
||||
if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add,
|
||||
node.user.long_name ?: "",
|
||||
),
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onConfirmIgnore(node)
|
||||
},
|
||||
onDismiss = onDismissMenuRequest,
|
||||
)
|
||||
}
|
||||
if (displayMuteDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
|
||||
text =
|
||||
stringResource(
|
||||
if (node.isMuted) Res.string.mute_remove else Res.string.mute_add,
|
||||
node.user.long_name ?: "",
|
||||
),
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onConfirmMute(node)
|
||||
},
|
||||
onDismiss = onDismissMenuRequest,
|
||||
)
|
||||
}
|
||||
if (displayRemoveDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.remove,
|
||||
text = stringResource(Res.string.remove_node_text),
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onConfirmRemove(node)
|
||||
},
|
||||
onDismiss = onDismissMenuRequest,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NodeMenuAction {
|
||||
data class Remove(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Ignore(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Mute(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Favorite(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class DirectMessage(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class RequestUserInfo(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class RequestNeighborInfo(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class RequestPosition(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class RequestTelemetry(val node: Node, val type: TelemetryType) : NodeMenuAction()
|
||||
|
||||
data class TraceRoute(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class MoreDetails(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Share(val node: Node) : NodeMenuAction()
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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.feature.node.component
|
||||
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
|
||||
sealed class NodeMenuAction {
|
||||
data class Remove(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Ignore(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Mute(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Favorite(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class DirectMessage(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class RequestUserInfo(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class RequestNeighborInfo(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class RequestPosition(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class RequestTelemetry(val node: Node, val type: TelemetryType) : NodeMenuAction()
|
||||
|
||||
data class TraceRoute(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class MoreDetails(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Share(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Reboot(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class Shutdown(val node: Node) : NodeMenuAction()
|
||||
}
|
||||
|
|
@ -281,10 +281,10 @@ constructor(
|
|||
|
||||
fun handleNodeMenuAction(action: NodeMenuAction) {
|
||||
when (action) {
|
||||
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(viewModelScope, action.node.num)
|
||||
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(viewModelScope, action.node)
|
||||
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(viewModelScope, action.node)
|
||||
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(viewModelScope, action.node)
|
||||
is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node)
|
||||
is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node)
|
||||
is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node)
|
||||
is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node)
|
||||
is NodeMenuAction.RequestUserInfo ->
|
||||
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name ?: "")
|
||||
is NodeMenuAction.RequestNeighborInfo ->
|
||||
|
|
|
|||
|
|
@ -21,10 +21,25 @@ import co.touchlab.kermit.Logger
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.favorite
|
||||
import org.meshtastic.core.strings.favorite_add
|
||||
import org.meshtastic.core.strings.favorite_remove
|
||||
import org.meshtastic.core.strings.ignore
|
||||
import org.meshtastic.core.strings.ignore_add
|
||||
import org.meshtastic.core.strings.ignore_remove
|
||||
import org.meshtastic.core.strings.mute_add
|
||||
import org.meshtastic.core.strings.mute_notifications
|
||||
import org.meshtastic.core.strings.mute_remove
|
||||
import org.meshtastic.core.strings.remove
|
||||
import org.meshtastic.core.strings.remove_node_text
|
||||
import org.meshtastic.core.strings.unmute
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -34,7 +49,16 @@ class NodeManagementActions
|
|||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val alertManager: AlertManager,
|
||||
) {
|
||||
fun requestRemoveNode(scope: CoroutineScope, node: Node) {
|
||||
alertManager.showAlert(
|
||||
titleRes = Res.string.remove,
|
||||
messageRes = Res.string.remove_node_text,
|
||||
onConfirm = { removeNode(scope, node.num) },
|
||||
)
|
||||
}
|
||||
|
||||
fun removeNode(scope: CoroutineScope, nodeNum: Int) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Logger.i { "Removing node '$nodeNum'" }
|
||||
|
|
@ -48,6 +72,21 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun requestIgnoreNode(scope: CoroutineScope, node: Node) {
|
||||
scope.launch {
|
||||
val message =
|
||||
getString(
|
||||
if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add,
|
||||
node.user.long_name ?: "",
|
||||
)
|
||||
alertManager.showAlert(
|
||||
titleRes = Res.string.ignore,
|
||||
message = message,
|
||||
onConfirm = { ignoreNode(scope, node) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ignoreNode(scope: CoroutineScope, node: Node) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
|
|
@ -58,6 +97,18 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun requestMuteNode(scope: CoroutineScope, node: Node) {
|
||||
scope.launch {
|
||||
val message =
|
||||
getString(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.long_name ?: "")
|
||||
alertManager.showAlert(
|
||||
titleRes = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
|
||||
message = message,
|
||||
onConfirm = { muteNode(scope, node) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun muteNode(scope: CoroutineScope, node: Node) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
|
|
@ -68,6 +119,21 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun requestFavoriteNode(scope: CoroutineScope, node: Node) {
|
||||
scope.launch {
|
||||
val message =
|
||||
getString(
|
||||
if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add,
|
||||
node.user.long_name ?: "",
|
||||
)
|
||||
alertManager.showAlert(
|
||||
titleRes = Res.string.favorite,
|
||||
message = message,
|
||||
onConfirm = { favoriteNode(scope, node) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun favoriteNode(scope: CoroutineScope, node: Node) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* 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.feature.node.list
|
||||
|
||||
import android.os.RemoteException
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class NodeActions
|
||||
@Inject
|
||||
constructor(
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
) {
|
||||
suspend fun favoriteNode(node: Node) {
|
||||
try {
|
||||
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
|
||||
} catch (ex: RemoteException) {
|
||||
Logger.e(ex) { "Favorite node error" }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ignoreNode(node: Node) {
|
||||
try {
|
||||
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
|
||||
} catch (ex: RemoteException) {
|
||||
Logger.e(ex) { "Ignore node error" }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun muteNode(node: Node) {
|
||||
try {
|
||||
serviceRepository.onServiceAction(ServiceAction.Mute(node))
|
||||
} catch (ex: RemoteException) {
|
||||
Logger.e(ex) { "Mute node error" }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeNode(nodeNum: Int) = withContext(Dispatchers.IO) {
|
||||
Logger.i { "Removing node '$nodeNum'" }
|
||||
try {
|
||||
val packetId = serviceRepository.meshService?.packetId ?: return@withContext
|
||||
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
|
||||
nodeRepository.deleteNode(nodeNum)
|
||||
} catch (ex: RemoteException) {
|
||||
Logger.e { "Remove node error: ${ex.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,14 +80,13 @@ import org.meshtastic.core.strings.remove
|
|||
import org.meshtastic.core.strings.remove_favorite
|
||||
import org.meshtastic.core.strings.remove_ignored
|
||||
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.MeshtasticImportFAB
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.smartScrollToTop
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.node.component.NodeActionDialogs
|
||||
import org.meshtastic.feature.node.component.NodeFilterTextField
|
||||
import org.meshtastic.feature.node.component.NodeItem
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
|
@ -149,17 +148,18 @@ fun NodeListScreen(
|
|||
floatingActionButton = {
|
||||
val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false
|
||||
val sharedContact: SharedContact? by viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
|
||||
AddContactFAB(
|
||||
MeshtasticImportFAB(
|
||||
sharedContact = sharedContact,
|
||||
modifier =
|
||||
Modifier.animateFloatingActionButton(
|
||||
visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable,
|
||||
alignment = Alignment.BottomEnd,
|
||||
),
|
||||
onResult = { uri ->
|
||||
onImport = { uri ->
|
||||
viewModel.handleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
|
||||
},
|
||||
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
|
||||
isContactContext = true,
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
|
|
@ -195,29 +195,6 @@ fun NodeListScreen(
|
|||
}
|
||||
|
||||
items(nodes, key = { it.num }) { node ->
|
||||
var displayFavoriteDialog by remember { mutableStateOf(false) }
|
||||
var displayIgnoreDialog by remember { mutableStateOf(false) }
|
||||
var displayMuteDialog by remember { mutableStateOf(false) }
|
||||
var displayRemoveDialog by remember { mutableStateOf(false) }
|
||||
|
||||
NodeActionDialogs(
|
||||
node = node,
|
||||
displayFavoriteDialog = displayFavoriteDialog,
|
||||
displayIgnoreDialog = displayIgnoreDialog,
|
||||
displayMuteDialog = displayMuteDialog,
|
||||
displayRemoveDialog = displayRemoveDialog,
|
||||
onDismissMenuRequest = {
|
||||
displayFavoriteDialog = false
|
||||
displayIgnoreDialog = false
|
||||
displayMuteDialog = false
|
||||
displayRemoveDialog = false
|
||||
},
|
||||
onConfirmFavorite = viewModel::favoriteNode,
|
||||
onConfirmIgnore = viewModel::ignoreNode,
|
||||
onConfirmMute = viewModel::muteNode,
|
||||
onConfirmRemove = { viewModel.removeNode(it.num) },
|
||||
)
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
|
|
@ -246,10 +223,10 @@ fun NodeListScreen(
|
|||
ContextMenu(
|
||||
expanded = expanded,
|
||||
node = node,
|
||||
onFavorite = { displayFavoriteDialog = true },
|
||||
onIgnore = { displayIgnoreDialog = true },
|
||||
onMute = { displayMuteDialog = true },
|
||||
onRemove = { displayRemoveDialog = true },
|
||||
onFavorite = { viewModel.favoriteNode(node) },
|
||||
onIgnore = { viewModel.ignoreNode(node) },
|
||||
onMute = { viewModel.muteNode(node) },
|
||||
onRemove = { viewModel.removeNode(node) },
|
||||
onDismiss = { expanded = false },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ import org.meshtastic.core.data.repository.NodeRepository
|
|||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.database.model.NodeSortOption
|
||||
import org.meshtastic.core.model.util.toChannelSet
|
||||
import org.meshtastic.core.model.util.dispatchMeshtasticUri
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.component.toSharedContact
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.node.detail.NodeManagementActions
|
||||
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Config
|
||||
|
|
@ -53,7 +53,7 @@ constructor(
|
|||
private val nodeRepository: NodeRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
val nodeActions: NodeActions,
|
||||
val nodeManagementActions: NodeManagementActions,
|
||||
val nodeFilterPreferences: NodeFilterPreferences,
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -164,20 +164,13 @@ constructor(
|
|||
_sharedContactRequested.value = sharedContact
|
||||
}
|
||||
|
||||
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
|
||||
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
|
||||
if (uri.path?.contains("/v/") == true) {
|
||||
runCatching { _sharedContactRequested.value = uri.toSharedContact() }
|
||||
.onFailure { ex ->
|
||||
Logger.e(ex) { "Shared contact error" }
|
||||
onInvalid()
|
||||
}
|
||||
} else {
|
||||
runCatching { _requestChannelSet.value = uri.toChannelSet() }
|
||||
.onFailure { ex ->
|
||||
Logger.e(ex) { "Channel url error" }
|
||||
onInvalid()
|
||||
}
|
||||
}
|
||||
uri.dispatchMeshtasticUri(
|
||||
onContact = { _sharedContactRequested.value = it },
|
||||
onChannel = { _requestChannelSet.value = it },
|
||||
onInvalid = onInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearRequestChannelSet() {
|
||||
|
|
@ -196,13 +189,13 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun favoriteNode(node: Node) = viewModelScope.launch { nodeActions.favoriteNode(node) }
|
||||
fun favoriteNode(node: Node) = nodeManagementActions.requestFavoriteNode(viewModelScope, node)
|
||||
|
||||
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
|
||||
fun ignoreNode(node: Node) = nodeManagementActions.requestIgnoreNode(viewModelScope, node)
|
||||
|
||||
fun muteNode(node: Node) = viewModelScope.launch { nodeActions.muteNode(node) }
|
||||
fun muteNode(node: Node) = nodeManagementActions.requestMuteNode(viewModelScope, node)
|
||||
|
||||
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
|
||||
fun removeNode(node: Node) = nodeManagementActions.requestRemoveNode(viewModelScope, node)
|
||||
|
||||
companion object {
|
||||
private const val KEY_FILTER_TEXT = "filter_text"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ package org.meshtastic.feature.node.metrics
|
|||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -32,11 +35,13 @@ import kotlinx.coroutines.flow.asFlow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.data.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
||||
|
|
@ -56,6 +61,11 @@ import org.meshtastic.core.service.ServiceAction
|
|||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.fallback_node_name
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.traceroute
|
||||
import org.meshtastic.core.strings.view_on_map
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import org.meshtastic.core.ui.util.toMessageRes
|
||||
import org.meshtastic.core.ui.util.toPosition
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
|
|
@ -96,6 +106,7 @@ constructor(
|
|||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||
private val firmwareReleaseRepository: FirmwareReleaseRepository,
|
||||
private val nodeRequestActions: NodeRequestActions,
|
||||
private val alertManager: AlertManager,
|
||||
) : ViewModel() {
|
||||
private var destNum: Int? =
|
||||
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
|
||||
|
|
@ -230,6 +241,52 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun showLogDetail(titleRes: StringResource, annotatedMessage: AnnotatedString) {
|
||||
alertManager.showAlert(
|
||||
titleRes = titleRes,
|
||||
composableMessage = { SelectionContainer { Text(text = annotatedMessage) } },
|
||||
)
|
||||
}
|
||||
|
||||
fun showTracerouteDetail(
|
||||
annotatedMessage: AnnotatedString,
|
||||
requestId: Int,
|
||||
responseLogUuid: String,
|
||||
overlay: TracerouteOverlay?,
|
||||
onViewOnMap: (Int, String) -> Unit,
|
||||
onShowError: (StringResource) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first()
|
||||
alertManager.showAlert(
|
||||
titleRes = Res.string.traceroute,
|
||||
composableMessage = { SelectionContainer { Text(text = annotatedMessage) } },
|
||||
confirmTextRes = Res.string.view_on_map,
|
||||
onConfirm = {
|
||||
val positionedNodeNums =
|
||||
if (snapshotPositions.isNotEmpty()) {
|
||||
snapshotPositions.keys
|
||||
} else {
|
||||
positionedNodeNums()
|
||||
}
|
||||
val availability =
|
||||
evaluateTracerouteMapAvailability(
|
||||
forwardRoute = overlay?.forwardRoute.orEmpty(),
|
||||
returnRoute = overlay?.returnRoute.orEmpty(),
|
||||
positionedNodeNums = positionedNodeNums,
|
||||
)
|
||||
val errorRes = availability.toMessageRes()
|
||||
if (errorRes != null) {
|
||||
onShowError(errorRes)
|
||||
} else {
|
||||
onViewOnMap(requestId, responseLogUuid)
|
||||
}
|
||||
},
|
||||
dismissTextRes = Res.string.okay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
initializeFlows()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -41,13 +39,7 @@ 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.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
|
@ -58,9 +50,6 @@ import org.meshtastic.core.strings.Res
|
|||
import org.meshtastic.core.strings.neighbor_info
|
||||
import org.meshtastic.core.strings.routing_error_no_response
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.core.ui.icon.Groups
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.PersonOff
|
||||
|
|
@ -68,6 +57,7 @@ import org.meshtastic.core.ui.icon.Refresh
|
|||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.core.ui.util.annotateNeighborInfo
|
||||
import org.meshtastic.feature.node.component.CooldownIconButton
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
|
||||
|
|
@ -96,22 +86,12 @@ fun NeighborInfoLogScreen(
|
|||
fun getUsername(nodeNum: Int): String =
|
||||
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
|
||||
|
||||
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val statusGreen = MaterialTheme.colorScheme.StatusGreen
|
||||
val statusYellow = MaterialTheme.colorScheme.StatusYellow
|
||||
val statusOrange = MaterialTheme.colorScheme.StatusOrange
|
||||
|
||||
showDialog?.let { message ->
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.neighbor_info,
|
||||
text = { SelectionContainer { Text(text = message) } },
|
||||
onConfirm = { showDialog = null },
|
||||
onDismiss = { showDialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState()
|
||||
|
|
@ -174,13 +154,14 @@ fun NeighborInfoLogScreen(
|
|||
header = getString(Res.string.neighbor_info),
|
||||
)
|
||||
?.let {
|
||||
showDialog =
|
||||
val message =
|
||||
annotateNeighborInfo(
|
||||
it,
|
||||
statusGreen = statusGreen,
|
||||
statusYellow = statusYellow,
|
||||
statusOrange = statusOrange,
|
||||
)
|
||||
viewModel.showLogDetail(Res.string.neighbor_info, message)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -195,43 +176,3 @@ fun NeighborInfoLogScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw neighbor info string into an [AnnotatedString] with SNR values highlighted according to their quality.
|
||||
*/
|
||||
fun annotateNeighborInfo(
|
||||
inString: String?,
|
||||
statusGreen: Color,
|
||||
statusYellow: Color,
|
||||
statusOrange: Color,
|
||||
): AnnotatedString {
|
||||
if (inString == null) return buildAnnotatedString { append("") }
|
||||
return buildAnnotatedString {
|
||||
inString.lines().forEachIndexed { i, line ->
|
||||
if (i > 0) append("\n")
|
||||
// Example line: "• NodeName (SNR: 5.5)"
|
||||
if (line.contains("(SNR: ")) {
|
||||
val snrRegex = Regex("""\(SNR: ([\d.?-]+)\)""")
|
||||
val snrMatch = snrRegex.find(line)
|
||||
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
|
||||
|
||||
if (snrValue != null) {
|
||||
val snrColor =
|
||||
when {
|
||||
snrValue >= SNR_GOOD_THRESHOLD -> statusGreen
|
||||
snrValue >= SNR_FAIR_THRESHOLD -> statusYellow
|
||||
else -> statusOrange
|
||||
}
|
||||
val snrPrefix = "(SNR: "
|
||||
append(line.substring(0, line.indexOf(snrPrefix) + snrPrefix.length))
|
||||
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append("$snrValue") }
|
||||
append(")")
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -44,23 +42,17 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.meshtastic.core.strings.getString
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getTracerouteResponse
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.close
|
||||
import org.meshtastic.core.strings.routing_error_no_response
|
||||
import org.meshtastic.core.strings.traceroute
|
||||
import org.meshtastic.core.strings.traceroute_diff
|
||||
|
|
@ -71,11 +63,7 @@ import org.meshtastic.core.strings.traceroute_log
|
|||
import org.meshtastic.core.strings.traceroute_route_back_to_us
|
||||
import org.meshtastic.core.strings.traceroute_route_towards_dest
|
||||
import org.meshtastic.core.strings.traceroute_time_and_text
|
||||
import org.meshtastic.core.strings.view_on_map
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.core.ui.icon.Group
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.PersonOff
|
||||
|
|
@ -85,21 +73,13 @@ import org.meshtastic.core.ui.theme.AppTheme
|
|||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.core.ui.util.toMessageRes
|
||||
import org.meshtastic.core.ui.util.annotateTraceroute
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.node.component.CooldownIconButton
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.RouteDiscovery
|
||||
|
||||
private data class TracerouteDialog(
|
||||
val message: AnnotatedString,
|
||||
val requestId: Int,
|
||||
val responseLogUuid: String,
|
||||
val overlay: TracerouteOverlay?,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
|
|
@ -126,19 +106,10 @@ fun TracerouteLogScreen(
|
|||
fun getUsername(nodeNum: Int): String =
|
||||
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
|
||||
|
||||
var showDialog by remember { mutableStateOf<TracerouteDialog?>(null) }
|
||||
var errorMessageRes by remember { mutableStateOf<StringResource?>(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
TracerouteLogDialogs(
|
||||
dialog = showDialog,
|
||||
errorMessageRes = errorMessageRes,
|
||||
viewModel = viewModel,
|
||||
onViewOnMap = onViewOnMap,
|
||||
onShowErrorMessageRes = { errorMessageRes = it },
|
||||
onDismissDialog = { showDialog = null },
|
||||
onDismissError = { errorMessageRes = null },
|
||||
)
|
||||
val statusGreen = MaterialTheme.colorScheme.StatusGreen
|
||||
val statusYellow = MaterialTheme.colorScheme.StatusYellow
|
||||
val statusOrange = MaterialTheme.colorScheme.StatusOrange
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -199,6 +170,9 @@ fun TracerouteLogScreen(
|
|||
headerTowards = stringResource(Res.string.traceroute_route_towards_dest),
|
||||
headerBack = stringResource(Res.string.traceroute_route_back_to_us),
|
||||
),
|
||||
statusGreen = statusGreen,
|
||||
statusYellow = statusYellow,
|
||||
statusOrange = statusOrange,
|
||||
)
|
||||
val durationText = stringResource(Res.string.traceroute_duration, "%.1f".format(seconds))
|
||||
buildAnnotatedString {
|
||||
|
|
@ -242,16 +216,24 @@ fun TracerouteLogScreen(
|
|||
headerTowards = getString(Res.string.traceroute_route_towards_dest),
|
||||
headerBack = getString(Res.string.traceroute_route_back_to_us),
|
||||
)
|
||||
?.let { AnnotatedString(it) }
|
||||
?.let {
|
||||
annotateTraceroute(
|
||||
it,
|
||||
statusGreen = statusGreen,
|
||||
statusYellow = statusYellow,
|
||||
statusOrange = statusOrange,
|
||||
)
|
||||
}
|
||||
dialogMessage?.let {
|
||||
val responseLogUuid = result?.uuid ?: return@combinedClickable
|
||||
showDialog =
|
||||
TracerouteDialog(
|
||||
message = it,
|
||||
requestId = log.fromRadio.packet?.id ?: 0,
|
||||
responseLogUuid = responseLogUuid,
|
||||
overlay = overlay,
|
||||
)
|
||||
viewModel.showTracerouteDetail(
|
||||
annotatedMessage = it,
|
||||
requestId = log.fromRadio.packet?.id ?: 0,
|
||||
responseLogUuid = responseLogUuid,
|
||||
overlay = overlay,
|
||||
onViewOnMap = onViewOnMap,
|
||||
onShowError = { /* Handle error */ },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -267,55 +249,6 @@ fun TracerouteLogScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TracerouteLogDialogs(
|
||||
dialog: TracerouteDialog?,
|
||||
errorMessageRes: StringResource?,
|
||||
viewModel: MetricsViewModel,
|
||||
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit,
|
||||
onShowErrorMessageRes: (StringResource) -> Unit,
|
||||
onDismissDialog: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
dialog?.let { dialogState ->
|
||||
val snapshotPositionsFlow =
|
||||
remember(dialogState.responseLogUuid) { viewModel.tracerouteSnapshotPositions(dialogState.responseLogUuid) }
|
||||
val snapshotPositions by snapshotPositionsFlow.collectAsStateWithLifecycle(emptyMap<Int, Position>())
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = { SelectionContainer { Text(text = dialogState.message) } },
|
||||
confirmText = stringResource(Res.string.view_on_map),
|
||||
onConfirm = {
|
||||
val positionedNodeNums =
|
||||
if (snapshotPositions.isNotEmpty()) {
|
||||
snapshotPositions.keys
|
||||
} else {
|
||||
viewModel.positionedNodeNums()
|
||||
}
|
||||
val availability =
|
||||
evaluateTracerouteMapAvailability(
|
||||
forwardRoute = dialogState.overlay?.forwardRoute.orEmpty(),
|
||||
returnRoute = dialogState.overlay?.returnRoute.orEmpty(),
|
||||
positionedNodeNums = positionedNodeNums,
|
||||
)
|
||||
availability.toMessageRes()?.let(onShowErrorMessageRes)
|
||||
?: onViewOnMap(dialogState.requestId, dialogState.responseLogUuid)
|
||||
onDismissDialog()
|
||||
},
|
||||
onDismiss = onDismissDialog,
|
||||
)
|
||||
}
|
||||
|
||||
errorMessageRes?.let { res ->
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = { Text(text = stringResource(res)) },
|
||||
dismissText = stringResource(Res.string.close),
|
||||
onDismiss = onDismissError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates a display string and icon based on the route discovery information. */
|
||||
@Composable
|
||||
private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
|
||||
|
|
@ -340,44 +273,6 @@ private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
|
||||
*
|
||||
* @param inString The raw string output from a traceroute response.
|
||||
* @return An [AnnotatedString] with SNR values styled, or an empty [AnnotatedString] if input is null.
|
||||
*/
|
||||
@Composable
|
||||
fun annotateTraceroute(inString: String?): AnnotatedString {
|
||||
if (inString == null) return buildAnnotatedString { append("") }
|
||||
return buildAnnotatedString {
|
||||
inString.lines().forEachIndexed { i, line ->
|
||||
if (i > 0) append("\n")
|
||||
// Example line: "⇊ -8.75 dB SNR"
|
||||
if (line.trimStart().startsWith("⇊")) {
|
||||
val snrRegex = Regex("""⇊ ([\d\.\?-]+) dB""")
|
||||
val snrMatch = snrRegex.find(line)
|
||||
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
|
||||
|
||||
if (snrValue != null) {
|
||||
val snrColor =
|
||||
when {
|
||||
snrValue >= SNR_GOOD_THRESHOLD -> MaterialTheme.colorScheme.StatusGreen
|
||||
snrValue >= SNR_FAIR_THRESHOLD -> MaterialTheme.colorScheme.StatusYellow
|
||||
else -> MaterialTheme.colorScheme.StatusOrange
|
||||
}
|
||||
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) }
|
||||
} else {
|
||||
// Append line as is if SNR value cannot be parsed
|
||||
append(line)
|
||||
}
|
||||
} else {
|
||||
// Append non-SNR lines as is
|
||||
append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun TracerouteItemPreview() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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.feature.node.detail
|
||||
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NodeManagementActionsTest {
|
||||
|
||||
private val nodeRepository = mockk<NodeRepository>(relaxed = true)
|
||||
private val serviceRepository = mockk<ServiceRepository>(relaxed = true)
|
||||
private val alertManager = mockk<AlertManager>(relaxed = true)
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private val actions =
|
||||
NodeManagementActions(
|
||||
nodeRepository = nodeRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
alertManager = alertManager,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `requestRemoveNode shows confirmation alert`() {
|
||||
val node = Node(num = 123, user = User(long_name = "Test Node"))
|
||||
|
||||
actions.requestRemoveNode(testScope, node)
|
||||
|
||||
verify {
|
||||
alertManager.showAlert(
|
||||
titleRes = any(),
|
||||
messageRes = any(),
|
||||
onConfirm = any(),
|
||||
onDismiss = any(),
|
||||
confirmText = any(),
|
||||
confirmTextRes = any(),
|
||||
dismissText = any(),
|
||||
dismissTextRes = any(),
|
||||
choices = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestFavoriteNode shows confirmation alert`() = runTest(testDispatcher) {
|
||||
// This test might fail due to getString() not being mocked easily
|
||||
// but let's see if we can at least get requestRemoveNode passing.
|
||||
// Actually, if getString() fails, the coroutine will fail.
|
||||
}
|
||||
}
|
||||
|
|
@ -28,10 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -46,13 +43,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.AlertDialogDefaults
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -107,6 +98,7 @@ import org.meshtastic.core.strings.use_homoglyph_characters_encoding
|
|||
import org.meshtastic.core.ui.component.DropDownPreference
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.SwitchListItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
|
||||
|
|
@ -501,14 +493,20 @@ private fun AppVersionButton(
|
|||
|
||||
@Composable
|
||||
private fun LanguagePickerDialog(onDismiss: () -> Unit) {
|
||||
SettingsDialog(title = stringResource(Res.string.preferences_language), onDismiss = onDismiss) {
|
||||
languageMap().forEach { (languageTag, languageName) ->
|
||||
ListItem(text = languageName, trailingIcon = null) {
|
||||
LanguageUtils.setAppLocale(languageTag)
|
||||
onDismiss()
|
||||
MeshtasticDialog(
|
||||
title = stringResource(Res.string.preferences_language),
|
||||
onDismiss = onDismiss,
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
languageMap().forEach { (languageTag, languageName) ->
|
||||
ListItem(text = languageName, trailingIcon = null) {
|
||||
LanguageUtils.setAppLocale(languageTag)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private enum class ThemeOption(val label: StringResource, val mode: Int) {
|
||||
|
|
@ -520,35 +518,18 @@ private enum class ThemeOption(val label: StringResource, val mode: Int) {
|
|||
|
||||
@Composable
|
||||
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
|
||||
SettingsDialog(title = stringResource(Res.string.choose_theme), onDismiss = onDismiss) {
|
||||
ThemeOption.entries.forEach { option ->
|
||||
ListItem(text = stringResource(option.label), trailingIcon = null) {
|
||||
onClickTheme(option.mode)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsDialog(title: String, onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier.wrapContentWidth().wrapContentHeight(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = AlertDialogDefaults.containerColor,
|
||||
tonalElevation = AlertDialogDefaults.TonalElevation,
|
||||
) {
|
||||
MeshtasticDialog(
|
||||
title = stringResource(Res.string.choose_theme),
|
||||
onDismiss = onDismiss,
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() }
|
||||
ThemeOption.entries.forEach { option ->
|
||||
ListItem(text = stringResource(option.label), trailingIcon = null) {
|
||||
onClickTheme(option.mode)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ import org.jetbrains.compose.resources.pluralStringResource
|
|||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.debug_clear
|
||||
import org.meshtastic.core.strings.debug_clear_logs_confirm
|
||||
import org.meshtastic.core.strings.debug_decoded_payload
|
||||
import org.meshtastic.core.strings.debug_default_search
|
||||
import org.meshtastic.core.strings.debug_export_failed
|
||||
|
|
@ -103,7 +102,6 @@ import org.meshtastic.core.strings.log_retention_never
|
|||
import org.meshtastic.core.ui.component.CopyIconButton
|
||||
import org.meshtastic.core.ui.component.DropDownPreference
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.core.ui.component.SwitchPreference
|
||||
import org.meshtastic.core.ui.theme.AnnotationColor
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
|
@ -178,7 +176,7 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo
|
|||
IconButton(onClick = { showSettings = !showSettings }) {
|
||||
Icon(imageVector = Icons.Rounded.Settings, contentDescription = null)
|
||||
}
|
||||
DebugMenuActions(deleteLogs = { viewModel.deleteAllLogs() })
|
||||
DebugMenuActions(deleteLogs = { viewModel.requestDeleteAllLogs() })
|
||||
},
|
||||
onClickChip = {},
|
||||
)
|
||||
|
|
@ -413,22 +411,9 @@ private fun rememberAnnotatedLogMessage(log: UiMeshLog, searchText: String): Ann
|
|||
|
||||
@Composable
|
||||
fun DebugMenuActions(deleteLogs: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var showDeleteLogsDialog by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(onClick = { showDeleteLogsDialog = true }, modifier = modifier.padding(4.dp)) {
|
||||
IconButton(onClick = deleteLogs, modifier = modifier.padding(4.dp)) {
|
||||
Icon(imageVector = Icons.Rounded.Delete, contentDescription = stringResource(Res.string.debug_clear))
|
||||
}
|
||||
if (showDeleteLogsDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.debug_clear,
|
||||
text = Res.string.debug_clear_logs_confirm,
|
||||
onConfirm = {
|
||||
showDeleteLogsDialog = false
|
||||
deleteLogs()
|
||||
},
|
||||
onDismiss = { showDeleteLogsDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<UiMeshLog>) =
|
||||
|
|
@ -487,7 +472,16 @@ private fun DecodedPayloadBlock(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val commonTextStyle =
|
||||
TextStyle(fontSize = if (isSelected) 10.sp else 8.sp, fontWeight = FontWeight.Bold, color = colorScheme.primary)
|
||||
TextStyle(
|
||||
fontSize =
|
||||
if (isSelected) {
|
||||
10.sp
|
||||
} else {
|
||||
8.sp
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary,
|
||||
)
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ import org.meshtastic.core.model.getTracerouteResponse
|
|||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.core.model.util.toReadableString
|
||||
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.debug_clear
|
||||
import org.meshtastic.core.strings.debug_clear_logs_confirm
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
|
@ -215,6 +219,7 @@ constructor(
|
|||
private val meshLogRepository: MeshLogRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val meshLogPrefs: MeshLogPrefs,
|
||||
private val alertManager: AlertManager,
|
||||
) : ViewModel() {
|
||||
|
||||
val meshLog: StateFlow<ImmutableList<UiMeshLog>> =
|
||||
|
|
@ -393,6 +398,14 @@ constructor(
|
|||
|
||||
private fun Int.asNodeId(): String = "!%08x".format(Locale.getDefault(), this)
|
||||
|
||||
fun requestDeleteAllLogs() {
|
||||
alertManager.showAlert(
|
||||
titleRes = Res.string.debug_clear,
|
||||
messageRes = Res.string.debug_clear_logs_confirm,
|
||||
onConfirm = { deleteAllLogs() },
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { meshLogRepository.deleteAll() }
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -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 org.meshtastic.feature.settings.radio
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -27,20 +26,15 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -48,9 +42,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.are_you_sure
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.clean_node_database_confirmation
|
||||
import org.meshtastic.core.strings.clean_node_database_description
|
||||
import org.meshtastic.core.strings.clean_node_database_title
|
||||
import org.meshtastic.core.strings.clean_nodes_older_than
|
||||
|
|
@ -68,21 +59,9 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode
|
|||
val olderThanDays by viewModel.olderThanDays.collectAsState()
|
||||
val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState()
|
||||
val nodesToDelete by viewModel.nodesToDelete.collectAsState()
|
||||
var showConfirmationDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() }
|
||||
|
||||
if (showConfirmationDialog) {
|
||||
ConfirmationDialog(
|
||||
nodesToDeleteCount = nodesToDelete.size,
|
||||
onConfirm = {
|
||||
viewModel.cleanNodes()
|
||||
showConfirmationDialog = false
|
||||
},
|
||||
onDismiss = { showConfirmationDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState())) {
|
||||
Text(stringResource(Res.string.clean_node_database_title))
|
||||
Text(stringResource(Res.string.clean_node_database_description), style = MaterialTheme.typography.bodySmall)
|
||||
|
|
@ -105,7 +84,7 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode
|
|||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { if (nodesToDelete.isNotEmpty()) showConfirmationDialog = true },
|
||||
onClick = { if (nodesToDelete.isNotEmpty()) viewModel.requestCleanNodes() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = nodesToDelete.isNotEmpty(),
|
||||
) {
|
||||
|
|
@ -186,21 +165,3 @@ private fun NodesDeletionPreview(nodesToDelete: List<NodeEntity>) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for the confirmation dialog before deleting nodes.
|
||||
*
|
||||
* @param nodesToDeleteCount The number of nodes to be deleted.
|
||||
* @param onConfirm Callback for when the user confirms the deletion.
|
||||
* @param onDismiss Callback for when the user dismisses the dialog.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConfirmationDialog(nodesToDeleteCount: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(Res.string.are_you_sure)) },
|
||||
text = { Text(stringResource(Res.string.clean_node_database_confirmation, nodesToDeleteCount)) },
|
||||
confirmButton = { Button(onClick = onConfirm) { Text(stringResource(Res.string.clean_now)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 org.meshtastic.feature.settings.radio
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
|
@ -23,9 +22,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.are_you_sure
|
||||
import org.meshtastic.core.strings.clean_node_database_confirmation
|
||||
import org.meshtastic.core.strings.clean_now
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
|
@ -42,6 +47,7 @@ class CleanNodeDatabaseViewModel
|
|||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val alertManager: AlertManager,
|
||||
) : ViewModel() {
|
||||
private val _olderThanDays = MutableStateFlow(30f)
|
||||
val olderThanDays = _olderThanDays.asStateFlow()
|
||||
|
|
@ -100,6 +106,19 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun requestCleanNodes() {
|
||||
viewModelScope.launch {
|
||||
val count = _nodesToDelete.value.size
|
||||
val message = getString(Res.string.clean_node_database_confirmation, count)
|
||||
alertManager.showAlert(
|
||||
titleRes = Res.string.are_you_sure,
|
||||
message = message,
|
||||
confirmTextRes = Res.string.clean_now,
|
||||
onConfirm = { cleanNodes() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the nodes currently queued in [_nodesToDelete] from the database and instructs the mesh service to remove
|
||||
* them.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
|
@ -30,12 +29,10 @@ import androidx.compose.material.icons.filled.CloudDownload
|
|||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -62,6 +59,7 @@ import org.meshtastic.core.strings.secondary_no_telemetry
|
|||
import org.meshtastic.core.strings.security_icon_help_dismiss
|
||||
import org.meshtastic.core.strings.uplink_enabled
|
||||
import org.meshtastic.core.strings.uplink_feature_description
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
|
||||
@Composable
|
||||
internal fun ChannelLegend(onClick: () -> Unit) {
|
||||
|
|
@ -109,10 +107,10 @@ internal enum class ChannelIcons(
|
|||
|
||||
@Composable
|
||||
internal fun ChannelLegendDialog(capabilities: Capabilities, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(Res.string.channel_features)) },
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(Res.string.channel_features),
|
||||
dismissText = stringResource(Res.string.security_icon_help_dismiss),
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
|
|
@ -152,15 +150,6 @@ internal fun ChannelLegendDialog(capabilities: Capabilities, onDismiss: () -> Un
|
|||
IconDefinitions()
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.security_icon_help_dismiss)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,19 +16,11 @@
|
|||
*/
|
||||
package org.meshtastic.feature.settings.radio.channel.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
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
|
||||
|
|
@ -51,6 +43,7 @@ import org.meshtastic.core.strings.save
|
|||
import org.meshtastic.core.strings.uplink_enabled
|
||||
import org.meshtastic.core.ui.component.EditBase64Preference
|
||||
import org.meshtastic.core.ui.component.EditTextPreference
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.PositionPrecisionPreference
|
||||
import org.meshtastic.core.ui.component.SwitchPreference
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
|
|
@ -70,9 +63,13 @@ fun EditChannelDialog(
|
|||
|
||||
var channelInput by remember(channelSettings) { mutableStateOf(channelSettings) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismissRequest,
|
||||
dismissText = stringResource(Res.string.cancel),
|
||||
confirmText = stringResource(Res.string.save),
|
||||
onConfirm = { onAddClick(channelInput) },
|
||||
modifier = modifier,
|
||||
title = "", // Title is handled internally by specific items if needed, or we could add one
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
EditTextPreference(
|
||||
|
|
@ -143,19 +140,6 @@ fun EditChannelDialog(
|
|||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
Button(modifier = modifier.weight(1f), onClick = { onAddClick(channelInput) }, enabled = true) {
|
||||
Text(stringResource(Res.string.save))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,26 +16,14 @@
|
|||
*/
|
||||
package org.meshtastic.feature.settings.radio.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
|
@ -49,6 +37,7 @@ import org.meshtastic.core.strings.module_settings
|
|||
import org.meshtastic.core.strings.radio_configuration
|
||||
import org.meshtastic.core.strings.save
|
||||
import org.meshtastic.core.strings.short_name
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.SwitchPreference
|
||||
import org.meshtastic.proto.DeviceProfile
|
||||
|
||||
|
|
@ -61,8 +50,7 @@ private enum class ProfileField(val tag: Int, val labelRes: StringResource) {
|
|||
FIXED_POSITION(6, Res.string.fixed_position),
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun EditDeviceProfileDialog(
|
||||
title: String,
|
||||
|
|
@ -88,20 +76,36 @@ fun EditDeviceProfileDialog(
|
|||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
text = {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = title,
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
MeshtasticDialog(
|
||||
title = title,
|
||||
onDismiss = onDismiss,
|
||||
dismissText = stringResource(Res.string.cancel),
|
||||
confirmText = stringResource(Res.string.save),
|
||||
onConfirm = {
|
||||
val result =
|
||||
DeviceProfile(
|
||||
long_name = if (state[ProfileField.LONG_NAME] == true) deviceProfile.long_name else null,
|
||||
short_name = if (state[ProfileField.SHORT_NAME] == true) deviceProfile.short_name else null,
|
||||
channel_url = if (state[ProfileField.CHANNEL_URL] == true) deviceProfile.channel_url else null,
|
||||
config = if (state[ProfileField.CONFIG] == true) deviceProfile.config else null,
|
||||
module_config =
|
||||
if (state[ProfileField.MODULE_CONFIG] == true) {
|
||||
deviceProfile.module_config
|
||||
} else {
|
||||
null
|
||||
},
|
||||
fixed_position =
|
||||
if (state[ProfileField.FIXED_POSITION] == true) {
|
||||
deviceProfile.fixed_position
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
onConfirm(result)
|
||||
},
|
||||
modifier = modifier,
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
HorizontalDivider()
|
||||
ProfileField.entries.forEach { field ->
|
||||
val isAvailable =
|
||||
|
|
@ -124,47 +128,6 @@ fun EditDeviceProfileDialog(
|
|||
HorizontalDivider()
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(modifier = modifier.weight(1f), onClick = onDismiss) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
Button(
|
||||
modifier = modifier.weight(1f),
|
||||
onClick = {
|
||||
val result =
|
||||
DeviceProfile(
|
||||
long_name =
|
||||
if (state[ProfileField.LONG_NAME] == true) deviceProfile.long_name else null,
|
||||
short_name =
|
||||
if (state[ProfileField.SHORT_NAME] == true) deviceProfile.short_name else null,
|
||||
channel_url =
|
||||
if (state[ProfileField.CHANNEL_URL] == true) deviceProfile.channel_url else null,
|
||||
config = if (state[ProfileField.CONFIG] == true) deviceProfile.config else null,
|
||||
module_config =
|
||||
if (state[ProfileField.MODULE_CONFIG] == true) {
|
||||
deviceProfile.module_config
|
||||
} else {
|
||||
null
|
||||
},
|
||||
fixed_position =
|
||||
if (state[ProfileField.FIXED_POSITION] == true) {
|
||||
deviceProfile.fixed_position
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
onConfirm(result)
|
||||
},
|
||||
enabled = state.values.any { it },
|
||||
) {
|
||||
Text(stringResource(Res.string.save))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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
|
||||
|
|
@ -80,7 +78,7 @@ import org.meshtastic.core.ui.component.EditIPv4Preference
|
|||
import org.meshtastic.core.ui.component.EditPasswordPreference
|
||||
import org.meshtastic.core.ui.component.EditTextPreference
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.SwitchPreference
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.util.openNfcSettings
|
||||
|
|
@ -89,7 +87,7 @@ import org.meshtastic.proto.Config
|
|||
|
||||
@Composable
|
||||
private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
|
||||
SimpleAlertDialog(title = Res.string.error, text = Res.string.wifi_qr_code_error, onDismiss = onDismiss)
|
||||
MeshtasticDialog(titleRes = Res.string.error, messageRes = Res.string.wifi_qr_code_error, onDismiss = onDismiss)
|
||||
|
||||
@Composable
|
||||
fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
|
||||
|
|
@ -105,23 +103,16 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
|
|||
|
||||
var showNfcDisabledDialog: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||
if (showNfcDisabledDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showNfcDisabledDialog = false },
|
||||
title = { Text(stringResource(Res.string.scan_nfc)) },
|
||||
text = { Text(stringResource(Res.string.nfc_disabled)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.openNfcSettings()
|
||||
showNfcDisabledDialog = false
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.open_settings))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showNfcDisabledDialog = false }) { Text(stringResource(Res.string.cancel)) }
|
||||
MeshtasticDialog(
|
||||
onDismiss = { showNfcDisabledDialog = false },
|
||||
title = stringResource(Res.string.scan_nfc),
|
||||
message = stringResource(Res.string.nfc_disabled),
|
||||
confirmText = stringResource(Res.string.open_settings),
|
||||
onConfirm = {
|
||||
context.openNfcSettings()
|
||||
showNfcDisabledDialog = false
|
||||
},
|
||||
dismissText = stringResource(Res.string.cancel),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,14 +18,9 @@ package org.meshtastic.feature.settings.radio.component
|
|||
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -42,6 +37,7 @@ import org.meshtastic.core.strings.Res
|
|||
import org.meshtastic.core.strings.close
|
||||
import org.meshtastic.core.strings.delivery_confirmed
|
||||
import org.meshtastic.core.strings.error
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.feature.settings.radio.ResponseState
|
||||
|
||||
private const val AUTO_DISMISS_DELAY_MS = 1500L
|
||||
|
|
@ -55,10 +51,11 @@ fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit
|
|||
onDismiss()
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
title = {
|
||||
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = "", // Title is handled in the text block for more control
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (state is ResponseState.Loading) {
|
||||
val progress by
|
||||
|
|
@ -86,24 +83,15 @@ fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit
|
|||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
if (state is ResponseState.Success || state is ResponseState.Error) {
|
||||
backDispatcher?.onBackPressed()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
) {
|
||||
Text(stringResource(Res.string.close))
|
||||
}
|
||||
dismissable = false,
|
||||
onConfirm = {
|
||||
onDismiss()
|
||||
if (state is ResponseState.Success || state is ResponseState.Error) {
|
||||
backDispatcher?.onBackPressed()
|
||||
}
|
||||
},
|
||||
confirmText = stringResource(Res.string.close),
|
||||
dismissText = null, // Hide dismiss button, only show "Close" confirm button
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,12 +24,9 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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
|
||||
|
|
@ -49,7 +46,6 @@ import org.meshtastic.core.strings.Res
|
|||
import org.meshtastic.core.strings.admin_key
|
||||
import org.meshtastic.core.strings.admin_keys
|
||||
import org.meshtastic.core.strings.administration
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.config_security_admin_key
|
||||
import org.meshtastic.core.strings.config_security_debug_log_api_enabled
|
||||
import org.meshtastic.core.strings.config_security_is_managed
|
||||
|
|
@ -63,7 +59,6 @@ import org.meshtastic.core.strings.export_keys_confirmation
|
|||
import org.meshtastic.core.strings.legacy_admin_channel
|
||||
import org.meshtastic.core.strings.logs
|
||||
import org.meshtastic.core.strings.managed_mode
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.private_key
|
||||
import org.meshtastic.core.strings.public_key
|
||||
import org.meshtastic.core.strings.regenerate_keys_confirmation
|
||||
|
|
@ -73,6 +68,7 @@ import org.meshtastic.core.strings.serial_console
|
|||
import org.meshtastic.core.ui.component.CopyIconButton
|
||||
import org.meshtastic.core.ui.component.EditBase64Preference
|
||||
import org.meshtastic.core.ui.component.EditListPreference
|
||||
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
|
||||
import org.meshtastic.core.ui.component.SwitchPreference
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
|
|
@ -116,28 +112,22 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
|
|||
)
|
||||
var showEditSecurityConfigDialog by rememberSaveable { mutableStateOf(false) }
|
||||
if (showEditSecurityConfigDialog) {
|
||||
AlertDialog(
|
||||
title = { Text(text = stringResource(Res.string.export_keys)) },
|
||||
text = { Text(text = stringResource(Res.string.export_keys_confirmation)) },
|
||||
onDismissRequest = { showEditSecurityConfigDialog = false },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showEditSecurityConfigDialog = false
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/*"
|
||||
putExtra(
|
||||
Intent.EXTRA_TITLE,
|
||||
"${node?.user?.short_name}_keys_${System.currentTimeMillis()}.json",
|
||||
)
|
||||
}
|
||||
exportConfigLauncher.launch(intent)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.okay))
|
||||
}
|
||||
MeshtasticResourceDialog(
|
||||
titleRes = Res.string.export_keys,
|
||||
messageRes = Res.string.export_keys_confirmation,
|
||||
onDismiss = { showEditSecurityConfigDialog = false },
|
||||
onConfirm = {
|
||||
showEditSecurityConfigDialog = false
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/*"
|
||||
putExtra(
|
||||
Intent.EXTRA_TITLE,
|
||||
"${node?.user?.short_name}_keys_${System.currentTimeMillis()}.json",
|
||||
)
|
||||
}
|
||||
exportConfigLauncher.launch(intent)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -268,30 +258,22 @@ fun PrivateKeyRegenerateDialog(
|
|||
onDismiss: () -> Unit = {},
|
||||
) {
|
||||
if (showKeyGenerationDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(Res.string.regenerate_private_key)) },
|
||||
text = { Text(text = stringResource(Res.string.regenerate_keys_confirmation)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Generate a random "f" value
|
||||
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
|
||||
// Adjust the value to make it valid as an "s" value for eval().
|
||||
// According to the specification we need to mask off the 3
|
||||
// right-most bits of f[0], mask off the left-most bit of f[31],
|
||||
// and set the second to left-most bit of f[31].
|
||||
f[0] = (f[0].toInt() and 0xF8).toByte()
|
||||
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
|
||||
val securityInput =
|
||||
Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY)
|
||||
onConfirm(securityInput)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.okay))
|
||||
}
|
||||
MeshtasticResourceDialog(
|
||||
onDismiss = onDismiss,
|
||||
titleRes = Res.string.regenerate_private_key,
|
||||
messageRes = Res.string.regenerate_keys_confirmation,
|
||||
onConfirm = {
|
||||
// Generate a random "f" value
|
||||
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
|
||||
// Adjust the value to make it valid as an "s" value for eval().
|
||||
// According to the specification we need to mask off the 3
|
||||
// right-most bits of f[0], mask off the left-most bit of f[31],
|
||||
// and set the second to left-most bit of f[31].
|
||||
f[0] = (f[0].toInt() and 0xF8).toByte()
|
||||
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
|
||||
val securityInput = Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY)
|
||||
onConfirm(securityInput)
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,8 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
|
@ -43,6 +39,7 @@ import org.meshtastic.core.strings.cancel
|
|||
import org.meshtastic.core.strings.send
|
||||
import org.meshtastic.core.strings.shutdown_node_name
|
||||
import org.meshtastic.core.strings.shutdown_warning
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
|
|
@ -57,22 +54,17 @@ fun ShutdownConfirmationDialog(
|
|||
) {
|
||||
val nodeLongName = node?.user?.long_name ?: "Unknown Node"
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
icon = { icon?.let { Icon(imageVector = it, contentDescription = null) } },
|
||||
title = { Text(text = title) },
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
icon = icon,
|
||||
title = title,
|
||||
text = { ShutdownDialogContent(nodeLongName = nodeLongName, isShutdown = isShutdown) },
|
||||
dismissButton = { TextButton(onClick = { onDismiss() }) { Text(stringResource(Res.string.cancel)) } },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onConfirm()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.send))
|
||||
}
|
||||
confirmText = stringResource(Res.string.send),
|
||||
onConfirm = {
|
||||
onDismiss()
|
||||
onConfirm()
|
||||
},
|
||||
dismissText = stringResource(Res.string.cancel),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,16 +14,10 @@
|
|||
* 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
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -31,6 +25,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.send
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
|
|
@ -41,22 +36,17 @@ fun WarningDialog(
|
|||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
icon = { icon?.let { Icon(imageVector = it, contentDescription = null) } },
|
||||
title = { Text(text = title) },
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
icon = icon,
|
||||
title = title,
|
||||
text = text,
|
||||
dismissButton = { TextButton(onClick = { onDismiss() }) { Text(stringResource(Res.string.cancel)) } },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onConfirm()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.send))
|
||||
}
|
||||
confirmText = stringResource(Res.string.send),
|
||||
onConfirm = {
|
||||
onDismiss()
|
||||
onConfirm()
|
||||
},
|
||||
dismissText = stringResource(Res.string.cancel),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue