feature: implement onboarding flow for first-time users

This commit is contained in:
Antoine Jaury
2026-08-04 21:29:27 +02:00
parent 23eb83490f
commit 7d32f06965
18 changed files with 686 additions and 71 deletions
+1
View File
@@ -41,6 +41,7 @@ kotlin {
implementation(projects.data.resources)
// Feature modules
implementation(projects.feature.onboarding)
implementation(projects.feature.records)
implementation(projects.feature.webview)
@@ -7,9 +7,11 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.metadata
import androidx.navigation3.ui.NavDisplay
@@ -20,12 +22,15 @@ import bzh.ajaury.chombev.di.sharedModule
import bzh.ajaury.chombev.navigation.MainScaffold
import bzh.ajaury.chombev.navigation.Route
import bzh.ajaury.chombev.navigation.WebPages
import bzh.ajaury.chombev.onboarding.ui.OnboardingScreen
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
import bzh.ajaury.chombev.player.ui.PlayerScreen
import bzh.ajaury.chombev.records.ui.RecordsScreen
import bzh.ajaury.chombev.theme.ChomBevTheme
import bzh.ajaury.chombev.web.model.WebPage
import bzh.ajaury.chombev.web.ui.WebViewScreen
import org.koin.compose.KoinApplication
import org.koin.compose.viewmodel.koinViewModel
import org.koin.dsl.koinConfiguration
import org.koin.dsl.module
@@ -45,86 +50,104 @@ fun App() {
modules(appModule)
},
) {
val serializer = SnapshotStateListSerializer<Route>()
val backStack: MutableList<Route> = rememberSerializable(serializer = serializer) {
mutableStateListOf(Route.Records)
}
val darkTheme = isSystemInDarkTheme()
ChomBevTheme(darkTheme = darkTheme) {
// Each destination owns its chrome (see MainScaffold) so navigating to the full-screen
// player animates as a whole scene without the main bars reflowing the outgoing screen.
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = { key ->
when (key) {
is Route.Records -> NavEntry(key) {
MainScaffold(
selectedPage = null,
onRecordsClick = { backStack.selectMainTab(null) },
onDeepenClick = { backStack.selectMainTab(WebPages.DEEPEN) },
onAboutClick = { backStack.selectMainTab(WebPages.ABOUT) },
) { innerPadding ->
RecordsScreen(
contentPadding = innerPadding,
onRecordClick = { record ->
backStack.add(Route.Player(recordId = record.id))
},
)
}
}
val onboardingViewModel: OnboardingViewModel = koinViewModel()
val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle()
is Route.Player -> NavEntry(
key,
metadata = metadata {
put(NavDisplay.TransitionKey) {
// Slide new content up, keeping the old content in place underneath
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(400),
) togetherWith ExitTransition.KeepUntilTransitionsFinished
}
put(NavDisplay.PopTransitionKey) {
// Slide in from left when navigating back
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
}
put(NavDisplay.PredictivePopTransitionKey) {
// Slide in from left when navigating back
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
}
},
) {
PlayerScreen(
recordId = key.recordId,
onBackClicked = { backStack.removeLastOrNull() },
)
}
when (onboardingUiState.isCompleted) {
// The persisted flag is still being read: render nothing rather than flashing
// either screen.
null -> Unit
is Route.Web -> NavEntry(key) {
MainScaffold(
selectedPage = key.page,
onRecordsClick = { backStack.selectMainTab(null) },
onDeepenClick = { backStack.selectMainTab(WebPages.DEEPEN) },
onAboutClick = { backStack.selectMainTab(WebPages.ABOUT) },
) { innerPadding ->
WebViewScreen(
page = key.page,
contentPadding = innerPadding,
darkTheme = darkTheme,
)
}
}
}
},
)
// First launch: onboarding replaces the whole UI. Completing it flips the persisted
// flag, which switches to the main content automatically.
false -> OnboardingScreen()
true -> MainNavigation(darkTheme = darkTheme)
}
}
}
}
@Composable
private fun MainNavigation(darkTheme: Boolean) {
val serializer = SnapshotStateListSerializer<Route>()
val backStack: MutableList<Route> = rememberSerializable(serializer = serializer) {
mutableStateListOf(Route.Records)
}
// Each destination owns its chrome (see MainScaffold) so navigating to the full-screen
// player animates as a whole scene without the main bars reflowing the outgoing screen.
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = { key ->
when (key) {
is Route.Records -> NavEntry(key) {
MainScaffold(
selectedPage = null,
onRecordsClick = { backStack.selectMainTab(null) },
onDeepenClick = { backStack.selectMainTab(WebPages.DEEPEN) },
onAboutClick = { backStack.selectMainTab(WebPages.ABOUT) },
) { innerPadding ->
RecordsScreen(
contentPadding = innerPadding,
onRecordClick = { record ->
backStack.add(Route.Player(recordId = record.id))
},
)
}
}
is Route.Player -> NavEntry(
key,
metadata = metadata {
put(NavDisplay.TransitionKey) {
// Slide new content up, keeping the old content in place underneath
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(400),
) togetherWith ExitTransition.KeepUntilTransitionsFinished
}
put(NavDisplay.PopTransitionKey) {
// Slide in from left when navigating back
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
}
put(NavDisplay.PredictivePopTransitionKey) {
// Slide in from left when navigating back
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
}
},
) {
PlayerScreen(
recordId = key.recordId,
onBackClicked = { backStack.removeLastOrNull() },
)
}
is Route.Web -> NavEntry(key) {
MainScaffold(
selectedPage = key.page,
onRecordsClick = { backStack.selectMainTab(null) },
onDeepenClick = { backStack.selectMainTab(WebPages.DEEPEN) },
onAboutClick = { backStack.selectMainTab(WebPages.ABOUT) },
) { innerPadding ->
WebViewScreen(
page = key.page,
contentPadding = innerPadding,
darkTheme = darkTheme,
)
}
}
}
},
)
}
// Selecting a main tab keeps Records as the root, so system Back always returns home.
private fun MutableList<Route>.selectMainTab(page: WebPage?) {
this.clear()
@@ -1,12 +1,14 @@
package bzh.ajaury.chombev.di
import bzh.ajaury.chombev.core.logging.di.loggingModule
import bzh.ajaury.chombev.onboarding.di.onboardingFeatureModule
import bzh.ajaury.chombev.records.ui.di.recordsFeatureModule
import bzh.ajaury.chombev.web.di.webviewModule
import org.koin.dsl.module
val sharedModule = module {
includes(loggingModule)
includes(onboardingFeatureModule)
includes(recordsFeatureModule)
includes(webviewModule)
}