feat: consolidate dialogs (#4506)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-08 16:45:52 -06:00 committed by GitHub
parent 7bcc51863f
commit ea6d1ffa32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2042 additions and 1659 deletions

View file

@ -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) } },
)
}

View file

@ -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()

View file

@ -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" }
}

View file

@ -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)) } },
)
}

View file

@ -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)

View file

@ -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,
)
}