mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: implement global SnackbarManager and consolidate common UI setup (#4909)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
9b8ac6a460
commit
553ca2f8ed
38 changed files with 705 additions and 515 deletions
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* 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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.firmware_old
|
||||
import org.meshtastic.core.resources.firmware_too_old
|
||||
import org.meshtastic.core.resources.should_update
|
||||
import org.meshtastic.core.resources.should_update_firmware
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
* Common component to check the connected device's firmware version against the minimum required version. Will display
|
||||
* a dismissable alert if the firmware is old, or a blocking alert if it is too old.
|
||||
*/
|
||||
@Composable
|
||||
fun FirmwareVersionCheck(viewModel: UIViewModel) {
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
|
||||
|
||||
val myFirmwareVersion = myNodeInfo?.firmwareVersion
|
||||
|
||||
val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
|
||||
|
||||
val latestStableFirmwareRelease by
|
||||
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
|
||||
|
||||
LaunchedEffect(connectionState, firmwareEdition) {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
firmwareEdition?.let { edition -> Logger.d { "FirmwareEdition: ${edition.name}" } }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(connectionState, myNodeInfo) {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
myNodeInfo?.let { info ->
|
||||
myFirmwareVersion
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { fwVersion ->
|
||||
val curVer = DeviceVersion(fwVersion)
|
||||
Logger.i {
|
||||
"[FW_CHECK] Firmware version comparison - " +
|
||||
"device: $curVer (raw: $fwVersion), " +
|
||||
"absoluteMin: ${DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)}, " +
|
||||
"min: ${DeviceVersion(DeviceVersion.MIN_FW_VERSION)}"
|
||||
}
|
||||
|
||||
if (curVer < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware too old - " +
|
||||
"device: $curVer < absoluteMin: ${DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)}"
|
||||
}
|
||||
val title = getString(Res.string.firmware_too_old)
|
||||
val message = getString(Res.string.firmware_old)
|
||||
viewModel.showAlert(
|
||||
title = title,
|
||||
html = message,
|
||||
onConfirm = { viewModel.setDeviceAddress("n") },
|
||||
)
|
||||
} else if (curVer < DeviceVersion(DeviceVersion.MIN_FW_VERSION)) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware should update - " +
|
||||
"device: $curVer < min: ${DeviceVersion(DeviceVersion.MIN_FW_VERSION)}"
|
||||
}
|
||||
val title = getString(Res.string.should_update_firmware)
|
||||
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
|
||||
viewModel.showAlert(title = title, message = message, onConfirm = {})
|
||||
} else {
|
||||
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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.runtime.Composable
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
* Encapsulates the headless, global UI components (dialogs, version checks, traceroute alerts) that need to be active
|
||||
* across all platforms at the root of the application hierarchy.
|
||||
*
|
||||
* This deduplicates the setup boilerplate from Android's MainScreen and DesktopMainScreen.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshtasticCommonAppSetup(
|
||||
uiViewModel: UIViewModel,
|
||||
onNavigateToTracerouteMap: (destinationNodeNum: Int, requestId: Int, logUuid: String?) -> Unit,
|
||||
) {
|
||||
SharedDialogs(uiViewModel = uiViewModel)
|
||||
FirmwareVersionCheck(viewModel = uiViewModel)
|
||||
AlertHost(alertManager = uiViewModel.alertManager)
|
||||
TracerouteAlertHandler(uiViewModel = uiViewModel, onNavigateToMap = onNavigateToTracerouteMap)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.meshtastic.core.ui.util.SnackbarManager
|
||||
|
||||
/**
|
||||
* Shared composable that observes [SnackbarManager.events] and provides a global [SnackbarHostState].
|
||||
*
|
||||
* It renders a [SnackbarHost] using the provided [hostModifier] over the provided [content].
|
||||
*/
|
||||
@Composable
|
||||
fun MeshtasticSnackbarProvider(
|
||||
snackbarManager: SnackbarManager,
|
||||
modifier: Modifier = Modifier,
|
||||
hostModifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(snackbarManager) {
|
||||
snackbarManager.events.collect { event ->
|
||||
val result =
|
||||
snackbarHostState.showSnackbar(
|
||||
message = event.message,
|
||||
actionLabel = event.actionLabel,
|
||||
withDismissAction = event.withDismissAction,
|
||||
duration = event.duration,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
event.onAction?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
content()
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).then(hostModifier),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,11 +17,12 @@
|
|||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.share.SharedContactDialog
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
* Shared composable that conditionally renders [SharedContactDialog] and [ScannedQrCodeDialog] when the device is
|
||||
|
|
@ -30,16 +31,18 @@ import org.meshtastic.proto.SharedContact
|
|||
* This eliminates identical boilerplate from Android `MainScreen` and Desktop `DesktopMainScreen`.
|
||||
*/
|
||||
@Composable
|
||||
fun SharedDialogs(
|
||||
connectionState: ConnectionState,
|
||||
sharedContactRequested: SharedContact?,
|
||||
requestChannelSet: ChannelSet?,
|
||||
onDismissSharedContact: () -> Unit,
|
||||
onDismissChannelSet: () -> Unit,
|
||||
) {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
sharedContactRequested?.let { SharedContactDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
|
||||
fun SharedDialogs(uiViewModel: UIViewModel) {
|
||||
val connectionState by uiViewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
|
||||
requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(newChannelSet, onDismiss = onDismissChannelSet) }
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
sharedContactRequested?.let {
|
||||
SharedContactDialog(sharedContact = it, onDismiss = { uiViewModel.clearSharedContactRequested() })
|
||||
}
|
||||
|
||||
requestChannelSet?.let { newChannelSet ->
|
||||
ScannedQrCodeDialog(newChannelSet, onDismiss = { uiViewModel.clearRequestChannelUrl() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* 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.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.okay
|
||||
import org.meshtastic.core.resources.traceroute
|
||||
import org.meshtastic.core.resources.view_on_map
|
||||
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.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
* Handles the display of the traceroute alert when a response is received. Consolidates the side effect logic from the
|
||||
* main application screens into common code.
|
||||
*/
|
||||
@Composable
|
||||
fun TracerouteAlertHandler(
|
||||
uiViewModel: UIViewModel,
|
||||
onNavigateToMap: (destinationNodeNum: Int, requestId: Int, logUuid: String?) -> Unit,
|
||||
) {
|
||||
val traceRouteResponse by uiViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
|
||||
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
LaunchedEffect(traceRouteResponse, dismissedTracerouteRequestId) {
|
||||
val response = traceRouteResponse
|
||||
if (response != null && response.requestId != dismissedTracerouteRequestId) {
|
||||
uiViewModel.showAlert(
|
||||
titleRes = Res.string.traceroute,
|
||||
composableMessage = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
text =
|
||||
annotateTraceroute(
|
||||
response.message,
|
||||
statusGreen = colorScheme.StatusGreen,
|
||||
statusYellow = colorScheme.StatusYellow,
|
||||
statusOrange = colorScheme.StatusOrange,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmTextRes = Res.string.view_on_map,
|
||||
onConfirm = {
|
||||
val availability =
|
||||
uiViewModel.tracerouteMapAvailability(
|
||||
forwardRoute = response.forwardRoute,
|
||||
returnRoute = response.returnRoute,
|
||||
)
|
||||
val errorRes = availability.toMessageRes()
|
||||
if (errorRes == null) {
|
||||
dismissedTracerouteRequestId = response.requestId
|
||||
onNavigateToMap(response.destinationNodeNum, response.requestId, response.logUuid)
|
||||
} else {
|
||||
uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
|
||||
uiViewModel.clearTracerouteResponse()
|
||||
}
|
||||
},
|
||||
dismissTextRes = Res.string.okay,
|
||||
onDismiss = {
|
||||
uiViewModel.clearTracerouteResponse()
|
||||
dismissedTracerouteRequestId = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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.material3.SnackbarDuration
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
/**
|
||||
* A global manager for displaying snackbars across the application. This allows ViewModels to trigger transient
|
||||
* feedback messages without direct dependencies on UI components or `SnackbarHostState`.
|
||||
*
|
||||
* Events are buffered in a [Channel] and consumed exactly once by the host composable via `MeshtasticSnackbarHost`.
|
||||
*
|
||||
* @see AlertManager for the modal dialog equivalent.
|
||||
*/
|
||||
@Single
|
||||
open class SnackbarManager {
|
||||
data class SnackbarEvent(
|
||||
val message: String,
|
||||
val actionLabel: String? = null,
|
||||
val withDismissAction: Boolean = false,
|
||||
val duration: SnackbarDuration = SnackbarDuration.Short,
|
||||
val onAction: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
private val _events = Channel<SnackbarEvent>(Channel.BUFFERED)
|
||||
open val events: Flow<SnackbarEvent> = _events.receiveAsFlow()
|
||||
|
||||
open fun showSnackbar(
|
||||
message: String,
|
||||
actionLabel: String? = null,
|
||||
withDismissAction: Boolean = false,
|
||||
duration: SnackbarDuration = if (actionLabel != null) SnackbarDuration.Indefinite else SnackbarDuration.Short,
|
||||
onAction: (() -> Unit)? = null,
|
||||
) {
|
||||
_events.trySend(
|
||||
SnackbarEvent(
|
||||
message = message,
|
||||
actionLabel = actionLabel,
|
||||
withDismissAction = withDismissAction,
|
||||
duration = duration,
|
||||
onAction = onAction,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ import org.meshtastic.core.resources.compromised_keys
|
|||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import org.meshtastic.core.ui.util.ComposableContent
|
||||
import org.meshtastic.core.ui.util.SnackbarManager
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
|
@ -80,6 +81,7 @@ class UIViewModel(
|
|||
private val notificationManager: NotificationManager,
|
||||
packetRepository: PacketRepository,
|
||||
val alertManager: AlertManager,
|
||||
val snackbarManager: SnackbarManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
|
||||
|
|
@ -165,6 +167,10 @@ class UIViewModel(
|
|||
alertManager.dismissAlert()
|
||||
}
|
||||
|
||||
fun showSnackbar(message: String, actionLabel: String? = null, onAction: (() -> Unit)? = null) {
|
||||
snackbarManager.showSnackbar(message = message, actionLabel = actionLabel, onAction = onAction)
|
||||
}
|
||||
|
||||
fun setDeviceAddress(address: String) {
|
||||
radioController.setDeviceAddress(address)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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.material3.SnackbarDuration
|
||||
import app.cash.turbine.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SnackbarManagerTest {
|
||||
|
||||
private val snackbarManager = SnackbarManager()
|
||||
|
||||
@Test
|
||||
fun showSnackbar_emits_event_with_message() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "Hello")
|
||||
|
||||
val event = awaitItem()
|
||||
assertEquals("Hello", event.message)
|
||||
assertNull(event.actionLabel)
|
||||
assertEquals(SnackbarDuration.Short, event.duration)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showSnackbar_with_action_defaults_to_indefinite_duration() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "Deleted", actionLabel = "Undo")
|
||||
|
||||
val event = awaitItem()
|
||||
assertEquals("Deleted", event.message)
|
||||
assertEquals("Undo", event.actionLabel)
|
||||
assertEquals(SnackbarDuration.Indefinite, event.duration)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showSnackbar_with_explicit_duration_overrides_default() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "Saved", actionLabel = "View", duration = SnackbarDuration.Long)
|
||||
|
||||
val event = awaitItem()
|
||||
assertEquals(SnackbarDuration.Long, event.duration)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiple_events_are_queued_and_consumed_in_order() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "First")
|
||||
snackbarManager.showSnackbar(message = "Second")
|
||||
snackbarManager.showSnackbar(message = "Third")
|
||||
|
||||
assertEquals("First", awaitItem().message)
|
||||
assertEquals("Second", awaitItem().message)
|
||||
assertEquals("Third", awaitItem().message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onAction_callback_is_preserved_in_event() = runTest {
|
||||
var actionTriggered = false
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(
|
||||
message = "Item removed",
|
||||
actionLabel = "Undo",
|
||||
onAction = { actionTriggered = true },
|
||||
)
|
||||
|
||||
val event = awaitItem()
|
||||
event.onAction?.invoke()
|
||||
assertTrue(actionTriggered)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withDismissAction_is_passed_through() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "Notice", withDismissAction = true)
|
||||
|
||||
val event = awaitItem()
|
||||
assertTrue(event.withDismissAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue