feature: implement onboarding flow for first-time users
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user