feature: show onboarding as a modal on large screens, add close functionality

This commit is contained in:
Antoine Jaury
2026-09-30 11:18:54 +02:00
parent 46dfe64493
commit 4ec151dda5
6 changed files with 189 additions and 67 deletions
@@ -1,20 +1,27 @@
package bzh.ajaury.chombev.onboarding.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -23,6 +30,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage
@@ -31,6 +40,7 @@ import bzh.ajaury.chombev.onboarding.ui.components.OnboardingPageIndicator
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingUiState
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.onboarding_close
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_next
@@ -46,93 +56,156 @@ import org.koin.compose.viewmodel.koinViewModel
private const val PAGE_COUNT = 4
private const val LEVEL_PAGE_INDEX = PAGE_COUNT - 1
private val DIALOG_MAX_WIDTH = 560.dp
private val DIALOG_MAX_HEIGHT = 680.dp
private const val DIALOG_WINDOW_FRACTION = 0.9f
@Composable
fun OnboardingScreen(viewModel: OnboardingViewModel = koinViewModel()) {
fun OnboardingScreen(
showAsDialog: Boolean = false,
viewModel: OnboardingViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
OnboardingScreen(
uiState = uiState,
showAsDialog = showAsDialog,
onLevelSelected = viewModel::selectLevel,
onFinish = viewModel::completeOnboarding,
onDismiss = viewModel::skipOnboarding,
)
}
@Composable
fun OnboardingScreen(
uiState: OnboardingUiState,
showAsDialog: Boolean = false,
onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
if (showAsDialog) {
Dialog(
onDismissRequest = onDismiss,
// The content sizes itself against the window rather than the platform default width.
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT)
.fillMaxSize(fraction = DIALOG_WINDOW_FRACTION),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
OnboardingContent(
uiState = uiState,
onLevelSelected = onLevelSelected,
onFinish = onFinish,
onClose = onDismiss,
)
}
}
} else {
Scaffold { innerPadding ->
OnboardingContent(
uiState = uiState,
modifier = Modifier.padding(innerPadding),
onLevelSelected = onLevelSelected,
onFinish = onFinish,
)
}
}
}
@Composable
private fun OnboardingContent(
uiState: OnboardingUiState,
modifier: Modifier = Modifier,
onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> Unit = {},
onClose: (() -> Unit)? = null,
) {
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
val scope = rememberCoroutineScope()
val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX
Scaffold { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) { page ->
when (page) {
0 -> OnboardingInfoPage(
icon = Icons.Filled.Hearing,
title = stringResource(Res.string.onboarding_goal_title),
description = stringResource(Res.string.onboarding_goal_description),
)
1 -> OnboardingInfoPage(
icon = Icons.Filled.Subtitles,
title = stringResource(Res.string.onboarding_subtitles_title),
description = stringResource(Res.string.onboarding_subtitles_description),
)
2 -> OnboardingInfoPage(
icon = Icons.Filled.Speed,
title = stringResource(Res.string.onboarding_playback_title),
description = stringResource(Res.string.onboarding_playback_description),
)
else -> OnboardingLevelPage(
selectedLevel = uiState.selectedLevel,
onLevelSelected = onLevelSelected,
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (onClose != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(Res.string.onboarding_close),
)
}
}
}
OnboardingPageIndicator(
pageCount = PAGE_COUNT,
currentPage = pagerState.currentPage,
)
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) { page ->
when (page) {
0 -> OnboardingInfoPage(
icon = Icons.Filled.Hearing,
title = stringResource(Res.string.onboarding_goal_title),
description = stringResource(Res.string.onboarding_goal_description),
)
Spacer(modifier = Modifier.height(24.dp))
1 -> OnboardingInfoPage(
icon = Icons.Filled.Subtitles,
title = stringResource(Res.string.onboarding_subtitles_title),
description = stringResource(Res.string.onboarding_subtitles_description),
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
if (isLastPage) {
onFinish()
} else {
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
},
) {
Text(
text = stringResource(
if (isLastPage) Res.string.onboarding_start else Res.string.onboarding_next,
),
2 -> OnboardingInfoPage(
icon = Icons.Filled.Speed,
title = stringResource(Res.string.onboarding_playback_title),
description = stringResource(Res.string.onboarding_playback_description),
)
else -> OnboardingLevelPage(
selectedLevel = uiState.selectedLevel,
onLevelSelected = onLevelSelected,
)
}
}
OnboardingPageIndicator(
pageCount = PAGE_COUNT,
currentPage = pagerState.currentPage,
)
Spacer(modifier = Modifier.height(24.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
if (isLastPage) {
onFinish()
} else {
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
},
) {
Text(
text = stringResource(
if (isLastPage) Res.string.onboarding_start else Res.string.onboarding_next,
),
)
}
}
}
@@ -148,3 +221,23 @@ private fun OnboardingScreenPreview() {
)
}
}
@Preview
@Composable
private fun OnboardingContentAsDialogPreview() {
MaterialTheme {
Surface(
modifier = Modifier.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
OnboardingContent(
uiState = OnboardingUiState(
isCompleted = false,
selectedLevel = OnboardingLevel.BEGINNER,
),
onClose = {},
)
}
}
}
@@ -36,6 +36,12 @@ class OnboardingViewModel(
selectedLevel.value = level
}
fun skipOnboarding() {
viewModelScope.launch {
preferencesRepository.setOnboardingCompleted()
}
}
fun completeOnboarding() {
val level = selectedLevel.value
viewModelScope.launch {