diff --git a/app/src/main/java/in/artistant/app/designsystem/component/Toast.kt b/app/src/main/java/in/artistant/app/designsystem/component/Toast.kt index c3bcb70b..7c8041fb 100644 --- a/app/src/main/java/in/artistant/app/designsystem/component/Toast.kt +++ b/app/src/main/java/in/artistant/app/designsystem/component/Toast.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion @@ -57,7 +56,10 @@ import `in`.artistant.app.designsystem.theme.motion * Dark on a light page, deliberately. A toast is the one piece of UI that has to * be readable against whatever is behind it without knowing what that is, and * the palette's dark surfaces exist for exactly these moments (the splash and - * this). + * this). `dark` is that surface; `ink` is the text colour, and painting a + * surface with it was the capsule's own private black. Flat, too — the light + * design casts no shadows, and a near-black capsule on an off-white page needs + * none to separate. * * The copy states the fact — "Venue address copied", not "Success!". That is a * house rule, not a suggestion. @@ -98,9 +100,14 @@ fun BoxScope.ToastHost( visible = message != null, modifier = modifier .align(Alignment.BottomCenter) + // Bottom only. `vertical` padded the TOP of the host's bounds by + // the same amount, so on a tab shell the node was up to two tab + // bars taller than the capsule inside it. Nothing visible moved, + // but the semantics node — a live region — claimed that whole band. .padding( - horizontal = AppTheme.dimens.component.gutter, - vertical = bottomPadding, + start = AppTheme.dimens.component.gutter, + end = AppTheme.dimens.component.gutter, + bottom = bottomPadding, ), enter = fadeIn(androidx.compose.animation.core.tween(AppTheme.motion.tabSwitch)) + slideInVertically( @@ -130,9 +137,8 @@ fun Toast( Row( modifier = modifier .fillMaxWidth() - .shadow(dimens.space.md, RoundedCornerShape(dimens.radii.buttonLg)) .clip(RoundedCornerShape(dimens.radii.buttonLg)) - .background(colors.ink) + .background(colors.dark) .padding(horizontal = dimens.space.lg, vertical = dimens.space.md) .semantics { // Announced when it appears rather than when focus reaches it — diff --git a/app/src/main/java/in/artistant/app/feature/epk/EpkScreen.kt b/app/src/main/java/in/artistant/app/feature/epk/EpkScreen.kt index cca025d7..37b4ff9f 100644 --- a/app/src/main/java/in/artistant/app/feature/epk/EpkScreen.kt +++ b/app/src/main/java/in/artistant/app/feature/epk/EpkScreen.kt @@ -56,10 +56,10 @@ import `in`.artistant.app.designsystem.component.RevealOnAppear import `in`.artistant.app.designsystem.component.ScreenHeader import `in`.artistant.app.designsystem.component.SectionHeader import `in`.artistant.app.designsystem.component.SheetScaffold -import `in`.artistant.app.designsystem.component.ToastHost import `in`.artistant.app.designsystem.theme.AppTheme import `in`.artistant.app.domain.artist.ArtistPrompts import `in`.artistant.app.domain.artist.PackagePricing +import `in`.artistant.app.feature.system.ToastViewModel import `in`.artistant.app.platform.media.WizardMediaCache import `in`.artistant.app.platform.media.rememberSamplePlayer import java.io.File @@ -103,6 +103,25 @@ fun EpkScreen( viewModel: EpkViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + + // Transient confirmations for writes with no visible result ("Pricing + // saved.") go through the app's ONE toast host, in `ArtistantNavHost`. This + // screen used to mount a second one of its own, which knew nothing about the + // tab bar and drew every press-kit confirmation underneath it (#179). + // + // `ToastViewModel` holds no state — it is a handle on the `@Singleton` + // `ToastController` — so resolving one at this back-stack entry costs + // nothing and reaches the same controller the root host observes. + val toasts: ToastViewModel = hiltViewModel() + LaunchedEffect(state.statusNote) { + val note = state.statusNote ?: return@LaunchedEffect + toasts.show(note) + // The controller owns the display window now, so the note is spent the + // moment it is handed over. Two identical notes in a row still show + // twice: this clears to null between them, so the key really changes. + viewModel.consumeStatusNote() + } + val colors = AppTheme.colors val dimens = AppTheme.dimens val context = LocalContext.current @@ -262,9 +281,6 @@ fun EpkScreen( ) } } - // Transient confirmations for writes with no visible result ("Pricing - // saved."). The host clears itself, so nothing here has to remember to. - ToastHost(message = state.statusNote, onDismiss = viewModel::consumeStatusNote) } // ── Sheets ─────────────────────────────────────────────────────────────── diff --git a/app/src/main/java/in/artistant/app/navigation/ArtistTabsScaffold.kt b/app/src/main/java/in/artistant/app/navigation/ArtistTabsScaffold.kt index 8501426e..818d23e8 100644 --- a/app/src/main/java/in/artistant/app/navigation/ArtistTabsScaffold.kt +++ b/app/src/main/java/in/artistant/app/navigation/ArtistTabsScaffold.kt @@ -91,7 +91,9 @@ private enum class ArtistTab(val route: String, val label: String, val icon: Ima } @Composable -fun ArtistTabsScaffold() { +fun ArtistTabsScaffold( + onTabBarVisibilityChange: (Boolean) -> Unit = {}, +) { val nav = rememberNavController() val accessibilityViewModel: AccessibilityViewModel = hiltViewModel() // Accessibility -> "Always show labels" (design 129). Read here rather than inside @@ -101,6 +103,13 @@ fun ArtistTabsScaffold() { val current by nav.currentBackStackEntryAsState() val route = current?.destination?.route val showBottomBar = ArtistTab.entries.any { it.route == route } + + // The root toast host (screen 77) sits ABOVE this scaffold, so it cannot read + // `showBottomBar` itself and cannot be reached by a CompositionLocal provided + // in here — it is a sibling, not a descendant. Report the fact up instead. + // Nothing resets it on the way out: the host also checks the gate, and this + // fires on the new scaffold's first composition after a role switch. + LaunchedEffect(showBottomBar) { onTabBarVisibilityChange(showBottomBar) } val tabRouter = rememberTabRouter() val pendingThread by tabRouter.pendingThreadId.collectAsStateWithLifecycle() val pendingGig by tabRouter.pendingGigRequestId.collectAsStateWithLifecycle() diff --git a/app/src/main/java/in/artistant/app/navigation/ArtistantNavHost.kt b/app/src/main/java/in/artistant/app/navigation/ArtistantNavHost.kt index a7595370..2603eff6 100644 --- a/app/src/main/java/in/artistant/app/navigation/ArtistantNavHost.kt +++ b/app/src/main/java/in/artistant/app/navigation/ArtistantNavHost.kt @@ -9,6 +9,9 @@ import androidx.compose.material.icons.outlined.Info import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -67,6 +70,20 @@ fun ArtistantNavHost() { val toastVm: ToastViewModel = hiltViewModel() val toast by toastVm.current.collectAsStateWithLifecycle() + // Whether the tab bar is CURRENTLY DRAWN — not whether we are inside the tab + // shell. The two are different facts: every pushed screen (chat, a booking, + // the press kit) lives under `RootGate.Tabs` with no bar on screen, and the + // toast host used to clear a bar that wasn't there and float 88dp up the + // page. Only the scaffolds know, so they report it here (#179). + // + // `rememberSaveable`, not `remember`: the controller is a `@Singleton`, so a + // toast that is up when the activity is recreated (a rotation, a theme + // switch, "don't keep activities") comes straight back — while a plain + // `remember` came back false and drew that first restored frame OVER the tab + // bar before the scaffold's effect corrected it. The saved value is whatever + // was true at recreation, so it restores correctly on a pushed screen too. + var tabBarVisible by rememberSaveable { mutableStateOf(false) } + // Hoisted above the gate `when` so the same instance is shared across the NotSignedIn → // Onboarding swap (a VM created inside a `when` branch dies when the branch changes). val signupVm: SignupViewModel = hiltViewModel() @@ -170,8 +187,12 @@ fun ArtistantNavHost() { WizardScreen(onFinished = viewModel::markWizardComplete) is RootGate.Tabs -> when (g.role) { - AppRole.Client -> ClientTabsScaffold() - AppRole.Artist -> ArtistTabsScaffold() + AppRole.Client -> ClientTabsScaffold( + onTabBarVisibilityChange = { tabBarVisible = it }, + ) + AppRole.Artist -> ArtistTabsScaffold( + onTabBarVisibilityChange = { tabBarVisible = it }, + ) } } } @@ -206,7 +227,14 @@ fun ArtistantNavHost() { ToastIcon.Info -> Icons.Outlined.Info else -> Icons.Filled.Check }, - bottomPadding = if (gate is RootGate.Tabs) gap + lightTabBarHeight() else gap, + // The gate is belt to `tabBarVisible`'s braces: leaving the tab + // shell entirely stops clearing the bar even for the frame + // before the scaffold reports itself gone. + bottomPadding = if (gate is RootGate.Tabs && tabBarVisible) { + gap + lightTabBarHeight() + } else { + gap + }, ) } } diff --git a/app/src/main/java/in/artistant/app/navigation/ClientTabsScaffold.kt b/app/src/main/java/in/artistant/app/navigation/ClientTabsScaffold.kt index 90b74971..3250af1d 100644 --- a/app/src/main/java/in/artistant/app/navigation/ClientTabsScaffold.kt +++ b/app/src/main/java/in/artistant/app/navigation/ClientTabsScaffold.kt @@ -125,7 +125,9 @@ private val BAR_TABS = listOf( private const val ARTIST_PROFILE_ROUTE = "artist/{artistId}" @Composable -fun ClientTabsScaffold() { +fun ClientTabsScaffold( + onTabBarVisibilityChange: (Boolean) -> Unit = {}, +) { val nav = rememberNavController() // The ACTIVITY's RootViewModel, not a new one: this composable is called directly from // `ArtistantNavHost`, above any NavHost, so `LocalViewModelStoreOwner` here is still the @@ -140,6 +142,13 @@ fun ClientTabsScaffold() { val current by nav.currentBackStackEntryAsState() val route = current?.destination?.route val showBottomBar = ClientTab.entries.any { it.route == route } + + // The root toast host (screen 77) sits ABOVE this scaffold, so it cannot read + // `showBottomBar` itself and cannot be reached by a CompositionLocal provided + // in here — it is a sibling, not a descendant. Report the fact up instead. + // Nothing resets it on the way out: the host also checks the gate, and this + // fires on the new scaffold's first composition after a role switch. + LaunchedEffect(showBottomBar) { onTabBarVisibilityChange(showBottomBar) } val tabRouter = rememberTabRouter() val pendingThread by tabRouter.pendingThreadId.collectAsStateWithLifecycle() val pendingBooking by tabRouter.pendingBookingDetail.collectAsStateWithLifecycle()