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
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue