Skip to content

Commit 9b6a961

Browse files
committed
Refactor: Introduce SnackbarCoordinator for managing snackbar display
This commit introduces a `SnackbarCoordinator` to manage snackbar messages across different screens within the dashboard. Key changes: - Added `SnackbarCoordinator` to handle snackbar ownership and prevent conflicts between screens. - Implemented `rememberScreenSnackbarController` to allow screens to claim ownership and display snackbars. - Integrated `ShowErrorAsSnackbar` composable to display error messages using the new snackbar system. - Updated `DocumentsScreen` and `TransactionsScreen` to utilize the `ScreenSnackbar` controller for error display. - Modified `DashboardScreen` to use `SnackbarCoordinator` and provide snackbar controllers to child screens. - Added `WrapSnackbar` composable for custom and default snackbar styles.
1 parent dee4ce9 commit 9b6a961

File tree

6 files changed

+381
-8
lines changed

6 files changed

+381
-8
lines changed

dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/dashboard/DashboardScreen.kt

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,14 @@ import eu.europa.ec.dashboardfeature.ui.transactions.list.TransactionsScreen
5555
import eu.europa.ec.dashboardfeature.ui.transactions.list.TransactionsViewModel
5656
import eu.europa.ec.resourceslogic.R
5757
import eu.europa.ec.uilogic.component.SystemBroadcastReceiver
58+
import eu.europa.ec.uilogic.component.snackbar.rememberScreenSnackbarController
59+
import eu.europa.ec.uilogic.component.snackbar.rememberSnackbarCoordinator
5860
import eu.europa.ec.uilogic.component.utils.LifecycleEffect
5961
import eu.europa.ec.uilogic.component.wrap.BottomSheetTextDataUi
6062
import eu.europa.ec.uilogic.component.wrap.BottomSheetWithOptionsList
63+
import eu.europa.ec.uilogic.component.wrap.SnackbarType
6164
import eu.europa.ec.uilogic.component.wrap.WrapModalBottomSheet
65+
import eu.europa.ec.uilogic.component.wrap.WrapSnackbar
6266
import eu.europa.ec.uilogic.extension.finish
6367
import eu.europa.ec.uilogic.extension.getPendingDeepLink
6468
import eu.europa.ec.uilogic.extension.openAppSettings
@@ -89,7 +93,15 @@ internal fun DashboardScreen(
8993
skipPartiallyExpanded = true
9094
)
9195

96+
val snackbarCoordinator = rememberSnackbarCoordinator()
97+
9298
Scaffold(
99+
snackbarHost = {
100+
WrapSnackbar(
101+
hostState = snackbarCoordinator.hostState,
102+
type = SnackbarType.DEFAULT
103+
)
104+
},
93105
bottomBar = { BottomNavigationBar(bottomNavigationController) }
94106
) { padding ->
95107
NavHost(
@@ -101,26 +113,42 @@ internal fun DashboardScreen(
101113
) {
102114
composable(BottomNavigationItem.Home.route) {
103115
HomeScreen(
104-
hostNavController,
105-
homeViewModel,
116+
navHostController = hostNavController,
117+
viewModel = homeViewModel,
106118
onDashboardEventSent = { event ->
107119
viewModel.setEvent(event)
108120
}
109121
)
110122
}
111123
composable(BottomNavigationItem.Documents.route) {
124+
val snackbarController = rememberScreenSnackbarController(
125+
ownerKey = BottomNavigationItem.Documents.route,
126+
hostState = snackbarCoordinator.hostState,
127+
activeOwnerState = { snackbarCoordinator.activeOwner },
128+
setActiveOwner = { snackbarCoordinator.activeOwner = it }
129+
)
130+
112131
DocumentsScreen(
113-
hostNavController,
114-
documentsViewModel,
132+
navHostController = hostNavController,
133+
snackbar = snackbarController,
134+
viewModel = documentsViewModel,
115135
onDashboardEventSent = { event ->
116136
viewModel.setEvent(event)
117137
}
118138
)
119139
}
120140
composable(BottomNavigationItem.Transactions.route) {
141+
val snackbarController = rememberScreenSnackbarController(
142+
ownerKey = BottomNavigationItem.Transactions.route,
143+
hostState = snackbarCoordinator.hostState,
144+
activeOwnerState = { snackbarCoordinator.activeOwner },
145+
setActiveOwner = { snackbarCoordinator.activeOwner = it }
146+
)
147+
121148
TransactionsScreen(
122-
hostNavController,
123-
transactionsViewModel,
149+
navHostController = hostNavController,
150+
snackbar = snackbarController,
151+
viewModel = transactionsViewModel,
124152
onDashboardEventSent = { event ->
125153
viewModel.setEvent(event)
126154
}

dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/documents/list/DocumentsScreen.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ import eu.europa.ec.uilogic.component.content.ContentScreen
7878
import eu.europa.ec.uilogic.component.content.ScreenNavigateAction
7979
import eu.europa.ec.uilogic.component.preview.PreviewTheme
8080
import eu.europa.ec.uilogic.component.preview.ThemeModePreviews
81+
import eu.europa.ec.uilogic.component.snackbar.ScreenSnackbar
82+
import eu.europa.ec.uilogic.component.snackbar.ShowErrorAsSnackbar
8183
import eu.europa.ec.uilogic.component.utils.HSpacer
8284
import eu.europa.ec.uilogic.component.utils.LifecycleEffect
8385
import eu.europa.ec.uilogic.component.utils.OneTimeLaunchedEffect
@@ -114,6 +116,7 @@ typealias OpenSideMenuEvent = eu.europa.ec.dashboardfeature.ui.dashboard.Event.S
114116
@Composable
115117
fun DocumentsScreen(
116118
navHostController: NavController,
119+
snackbar: ScreenSnackbar,
117120
viewModel: DocumentsViewModel,
118121
onDashboardEventSent: (DashboardEvent) -> Unit,
119122
) {
@@ -130,7 +133,7 @@ fun DocumentsScreen(
130133
isLoading = state.isLoading,
131134
navigatableAction = ScreenNavigateAction.NONE,
132135
onBack = { context.finish() },
133-
contentErrorConfig = state.error,
136+
contentErrorConfig = null,
134137
topBar = {
135138
TopBar(
136139
onEventSend = { viewModel.setEvent(it) },
@@ -179,6 +182,11 @@ fun DocumentsScreen(
179182
}
180183
}
181184
}
185+
186+
ShowErrorAsSnackbar(
187+
error = state.error,
188+
snackbar = snackbar
189+
)
182190
}
183191

184192
private fun handleNavigationEffect(

dashboard-feature/src/main/java/eu/europa/ec/dashboardfeature/ui/transactions/list/TransactionsScreen.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ import eu.europa.ec.uilogic.component.SectionTitle
8585
import eu.europa.ec.uilogic.component.content.ContentScreen
8686
import eu.europa.ec.uilogic.component.content.ScreenNavigateAction
8787
import eu.europa.ec.uilogic.component.preview.ThemeModePreviews
88+
import eu.europa.ec.uilogic.component.snackbar.ScreenSnackbar
89+
import eu.europa.ec.uilogic.component.snackbar.ShowErrorAsSnackbar
8890
import eu.europa.ec.uilogic.component.utils.HSpacer
8991
import eu.europa.ec.uilogic.component.utils.LifecycleEffect
9092
import eu.europa.ec.uilogic.component.utils.OneTimeLaunchedEffect
@@ -119,6 +121,7 @@ typealias OpenSideMenuEvent = eu.europa.ec.dashboardfeature.ui.dashboard.Event.S
119121
@Composable
120122
fun TransactionsScreen(
121123
navHostController: NavController,
124+
snackbar: ScreenSnackbar,
122125
viewModel: TransactionsViewModel,
123126
onDashboardEventSent: (DashboardEvent) -> Unit,
124127
) {
@@ -134,7 +137,7 @@ fun TransactionsScreen(
134137

135138
ContentScreen(
136139
isLoading = state.isLoading,
137-
contentErrorConfig = state.error,
140+
contentErrorConfig = null,
138141
navigatableAction = ScreenNavigateAction.NONE,
139142
onBack = { context.finish() },
140143
topBar = {
@@ -213,6 +216,11 @@ fun TransactionsScreen(
213216
)
214217
}
215218
}
219+
220+
ShowErrorAsSnackbar(
221+
error = state.error,
222+
snackbar = snackbar
223+
)
216224
}
217225

218226
@OptIn(ExperimentalMaterial3Api::class)
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
* Copyright (c) 2025 European Commission
3+
*
4+
* Licensed under the EUPL, Version 1.2 or - as soon they will be approved by the European
5+
* Commission - subsequent versions of the EUPL (the "Licence"); You may not use this work
6+
* except in compliance with the Licence.
7+
*
8+
* You may obtain a copy of the Licence at:
9+
* https://joinup.ec.europa.eu/software/page/eupl
10+
*
11+
* Unless required by applicable law or agreed to in writing, software distributed under
12+
* the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF
13+
* ANY KIND, either express or implied. See the Licence for the specific language
14+
* governing permissions and limitations under the Licence.
15+
*/
16+
17+
package eu.europa.ec.uilogic.component.snackbar
18+
19+
import androidx.compose.material3.SnackbarDuration
20+
import androidx.compose.material3.SnackbarHostState
21+
import androidx.compose.material3.SnackbarResult
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.DisposableEffect
24+
import androidx.compose.runtime.LaunchedEffect
25+
import androidx.compose.runtime.remember
26+
import androidx.compose.runtime.rememberCoroutineScope
27+
import androidx.compose.ui.res.stringResource
28+
import eu.europa.ec.resourceslogic.R
29+
import eu.europa.ec.uilogic.component.content.ContentErrorConfig
30+
import kotlinx.coroutines.launch
31+
32+
interface ScreenSnackbar {
33+
fun show(
34+
message: String,
35+
actionLabel: String? = null,
36+
duration: SnackbarDuration = SnackbarDuration.Indefinite,
37+
onAction: (() -> Unit)? = null,
38+
onDismiss: (() -> Unit)? = null,
39+
)
40+
41+
fun dismiss()
42+
}
43+
44+
/**
45+
* A small controller that lets a screen *coordinate* Snackbar usage when multiple siblings
46+
* share the same [SnackbarHostState] (e.g., tabs in a bottom nav).
47+
*
48+
* Ownership model:
49+
* - Each screen has a unique [ownerKey].
50+
* - Before showing a snackbar, the screen "claims" ownership via [setActiveOwner].
51+
* - When the snackbar completes (action/dismiss) the screen *releases* ownership,
52+
* but only if it's still the owner (another screen might have claimed it in the meantime).
53+
*
54+
* This prevents one screen from accidentally dismissing or overriding another screen’s snackbar,
55+
* and keeps callbacks (onAction/onDismiss) scoped to the screen that actually owned the snackbar.
56+
*
57+
* @param ownerKey Unique id for the caller (e.g., "DOCUMENTS", "TRANSACTIONS").
58+
* @param hostState The shared SnackbarHostState.
59+
* @param activeOwnerState Read the current owner (null if none).
60+
* @param setActiveOwner Write the current owner (set to null to release).
61+
*/
62+
@Composable
63+
fun rememberScreenSnackbarController(
64+
ownerKey: String,
65+
hostState: SnackbarHostState,
66+
activeOwnerState: () -> String?,
67+
setActiveOwner: (String?) -> Unit
68+
): ScreenSnackbar {
69+
val scope = rememberCoroutineScope()
70+
71+
// Recreate the controller only if the owner identity or host changes.
72+
return remember(ownerKey, hostState) {
73+
object : ScreenSnackbar {
74+
override fun show(
75+
message: String,
76+
actionLabel: String?,
77+
duration: SnackbarDuration,
78+
onAction: (() -> Unit)?,
79+
onDismiss: (() -> Unit)?
80+
) {
81+
scope.launch {
82+
// Claim ownership for *this* screen before showing.
83+
setActiveOwner(ownerKey)
84+
85+
// Suspends until the snackbar is acted upon or dismissed.
86+
val snackbarResult = hostState.showSnackbar(
87+
message = message,
88+
actionLabel = actionLabel,
89+
withDismissAction = true,
90+
duration = duration
91+
)
92+
93+
// Only fire callbacks if we *still* own it.
94+
// Another screen might have claimed ownership while this was showing.
95+
if (activeOwnerState() == ownerKey) {
96+
when (snackbarResult) {
97+
SnackbarResult.ActionPerformed -> onAction?.invoke()
98+
SnackbarResult.Dismissed -> onDismiss?.invoke()
99+
}
100+
101+
// Release the lock, but only if we are still the owner.
102+
// (Callbacks above could navigate and cause ownership changes.)
103+
if (activeOwnerState() == ownerKey) {
104+
setActiveOwner(null)
105+
}
106+
}
107+
}
108+
}
109+
110+
override fun dismiss() {
111+
// Only dismiss if we currently own the snackbar.
112+
if (activeOwnerState() == ownerKey) {
113+
hostState.currentSnackbarData?.dismiss()
114+
115+
// Release ownership if it still belongs to us after dismissal.
116+
if (activeOwnerState() == ownerKey) {
117+
setActiveOwner(null)
118+
}
119+
}
120+
}
121+
}
122+
}
123+
}
124+
125+
/**
126+
* Displays an error message as a snackbar on the current screen.
127+
*
128+
* This Composable function takes a [ContentErrorConfig] object and uses the provided
129+
* [ScreenSnackbar] controller to show an error message.
130+
*
131+
* Behavior:
132+
* - If an action is performed on the snackbar (e.g., tapping a "Retry" button),
133+
* the `error.onRetry` lambda will be invoked.
134+
* - If the snackbar is dismissed (either by user swipe, timeout, or programmatically),
135+
* the `error.onCancel` lambda will be invoked.
136+
* - The snackbar will be automatically dismissed when this Composable leaves the composition
137+
* (e.g., when the screen is navigated away from).
138+
* - If the [error] parameter is `null`, any currently shown snackbar by this controller
139+
* will be dismissed.
140+
*
141+
* @param error The configuration for the error to be displayed.
142+
* @param snackbar The [ScreenSnackbar] controller responsible for managing and displaying
143+
* the snackbar.
144+
* @param fallbackMessage The message to display if [error] is `null` or if both
145+
* `error.errorTitle` and `error.errorSubTitle` are `null`.
146+
* Defaults to the generic error message.
147+
* @param retryLabel The text to display for the snackbar's action button. This is only
148+
* shown if `error.onRetry` is not `null`. Defaults to the generic "Retry" label.
149+
* @param duration The duration for which the snackbar should be displayed. Defaults to
150+
* [SnackbarDuration.Indefinite], meaning it will stay until an action
151+
* is taken or it's dismissed.
152+
*/
153+
@Composable
154+
fun ShowErrorAsSnackbar(
155+
error: ContentErrorConfig?,
156+
snackbar: ScreenSnackbar,
157+
fallbackMessage: String = stringResource(R.string.generic_error_message),
158+
retryLabel: String = stringResource(R.string.generic_error_button_retry),
159+
duration: SnackbarDuration = SnackbarDuration.Indefinite,
160+
) {
161+
val message = remember(error, fallbackMessage) {
162+
error?.errorTitle ?: error?.errorSubTitle ?: fallbackMessage
163+
}
164+
val actionLabel = remember(error, retryLabel) {
165+
error?.onRetry?.let {
166+
retryLabel
167+
}
168+
}
169+
170+
LaunchedEffect(error, snackbar) {
171+
if (error != null) {
172+
snackbar.show(
173+
message = message,
174+
actionLabel = actionLabel,
175+
duration = duration,
176+
onAction = error.onRetry,
177+
onDismiss = error.onCancel
178+
)
179+
}
180+
}
181+
182+
DisposableEffect(snackbar) {
183+
onDispose { snackbar.dismiss() }
184+
}
185+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright (c) 2025 European Commission
3+
*
4+
* Licensed under the EUPL, Version 1.2 or - as soon they will be approved by the European
5+
* Commission - subsequent versions of the EUPL (the "Licence"); You may not use this work
6+
* except in compliance with the Licence.
7+
*
8+
* You may obtain a copy of the Licence at:
9+
* https://joinup.ec.europa.eu/software/page/eupl
10+
*
11+
* Unless required by applicable law or agreed to in writing, software distributed under
12+
* the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF
13+
* ANY KIND, either express or implied. See the Licence for the specific language
14+
* governing permissions and limitations under the Licence.
15+
*/
16+
17+
package eu.europa.ec.uilogic.component.snackbar
18+
19+
import androidx.compose.material3.SnackbarHostState
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.getValue
22+
import androidx.compose.runtime.mutableStateOf
23+
import androidx.compose.runtime.remember
24+
import androidx.compose.runtime.setValue
25+
26+
/**
27+
* Manages the display of Snackbar messages across multiple components (owners).
28+
*
29+
* This coordinator ensures that only one snackbar is shown at a time, even if multiple
30+
* components attempt to display a message simultaneously. It tracks the "active owner"
31+
* of the snackbar, allowing components to request to show a snackbar and become the
32+
* active owner.
33+
*
34+
* @property hostState The [SnackbarHostState] used to show snackbar messages. This is
35+
* typically provided by a `Scaffold` composable.
36+
* @property activeOwner The identifier of the component that currently "owns" the
37+
* snackbar. This is `null` if no snackbar is currently being shown
38+
* or if the active snackbar is not associated with a specific owner.
39+
* This property is observable using Compose's state mechanism.
40+
*/
41+
class SnackbarCoordinator(
42+
val hostState: SnackbarHostState
43+
) {
44+
var activeOwner by mutableStateOf<String?>(null)
45+
}
46+
47+
@Composable
48+
fun rememberSnackbarCoordinator(): SnackbarCoordinator {
49+
return remember { SnackbarCoordinator(hostState = SnackbarHostState()) }
50+
}

0 commit comments

Comments
 (0)