Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions app/src/main/java/in/artistant/app/designsystem/component/Toast.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 —
Expand Down
24 changes: 20 additions & 4 deletions app/src/main/java/in/artistant/app/feature/epk/EpkScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
34 changes: 31 additions & 3 deletions app/src/main/java/in/artistant/app/navigation/ArtistantNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 },
)
}
}
}
Expand Down Expand Up @@ -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
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down