From 918574a56c462157bb4b37cdb2bad44becbcf4e4 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Thu, 10 Sep 2026 11:02:37 +0000 Subject: [PATCH 1/2] fix(toast): one host, painted on `dark`, clearing only the bar that is drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the Sep-2026 design QA, all in issue #179, all about the same capsule. F-SH-03 (P1) — the root host cleared the tab bar whenever the gate was `RootGate.Tabs`. That is "inside the tab shell", not "the bar is on screen": every pushed destination — chat, a booking, the press kit — lives under that gate with `showBottomBar` false, so their toasts floated 88dp above nothing. Only the scaffolds know the answer, so they now report `showBottomBar` up to the host, which keeps the gate check as the outer guard. The host's padding also moves from `vertical` to `bottom`: the top half padded an invisible node up to two tab bars taller than the capsule, and that node is a live region. F-PK-01 (P1) — the press kit mounted a SECOND `ToastHost` inside its own Box. It took the default bottom gap, so every "Pricing saved." on screens 23/87/76 drew underneath the tab bar. `statusNote` now goes through the same `ToastController` the root host observes and the local host is gone; there is one `ToastHost` call site in the app again. The state field stays where it is, so nothing in `EpkViewModel` or its callers moves — pushing the controller into that ViewModel is the tidier end state and a separate change. F-CC-06 (P3) — the capsule painted `colors.ink`, the TEXT colour, and cast a 12dp shadow on a design that has no shadows anywhere else. Now `colors.dark`, the palette's actual dark surface, flat. Verified: kotlinc 2.1.0 parses all five files clean (no syntax or structural diagnostics; every remaining message is an unresolved reference from compiling without the Android classpath). NOT compiled and NOT device-walked — the Gradle build needs `dl.google.com` for AGP, androidx and the SDK, and this environment's network policy denies that host, which also rules out the emulator. The green-tree gate still has to run before this merges. Addresses #179. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SQHtArE39dDg2TUDzwfBAW --- .../app/designsystem/component/Toast.kt | 18 ++++++++----- .../in/artistant/app/feature/epk/EpkScreen.kt | 24 ++++++++++++++--- .../app/navigation/ArtistTabsScaffold.kt | 11 +++++++- .../app/navigation/ArtistantNavHost.kt | 27 ++++++++++++++++--- .../app/navigation/ClientTabsScaffold.kt | 11 +++++++- 5 files changed, 76 insertions(+), 15 deletions(-) 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..34ed14b7 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.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -67,6 +70,13 @@ 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). + var tabBarVisible by remember { 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 +180,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 +220,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() From 942aee20ad8c6afe78e7556dd78f1a70485147dc Mon Sep 17 00:00:00 2001 From: Cestercian Date: Thu, 10 Sep 2026 11:09:04 +0000 Subject: [PATCH 2/2] fix(toast): survive an activity recreation with the bar state intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch on the first commit. `ToastController` 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 with the composition. But `tabBarVisible` was a plain `remember`, so it came back false, and the first restored frame drew the capsule OVER the tab bar before the scaffold's `LaunchedEffect` reported true and it jumped. `rememberSaveable` restores whatever was true at recreation, so it is right in both directions: true on a tab root, false on a pushed screen. `remember` has no other user in the file, so its import goes with it. Verified: kotlinc 2.1.0 parses the file clean — the only non-unresolved- reference diagnostic is the pre-existing "'when' expression must be exhaustive" on `themeRole`, which is `RootGate`'s sealed hierarchy being invisible without the Android classpath (it appears identically on the unmodified baseline). Still NOT compiled and NOT device-walked; see #185. Addresses #179. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SQHtArE39dDg2TUDzwfBAW --- .../in/artistant/app/navigation/ArtistantNavHost.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 34ed14b7..2603eff6 100644 --- a/app/src/main/java/in/artistant/app/navigation/ArtistantNavHost.kt +++ b/app/src/main/java/in/artistant/app/navigation/ArtistantNavHost.kt @@ -10,7 +10,7 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel @@ -75,7 +75,14 @@ fun ArtistantNavHost() { // 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). - var tabBarVisible by remember { mutableStateOf(false) } + // + // `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).