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
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed
- show on boarding as a modal on large screens
### Fixed ### Fixed
- Desktop: audio playback failed with *Failed to play audio* on every record, - Desktop: audio playback failed with *Failed to play audio* on every record,
@@ -42,6 +42,7 @@
<string name="onboarding_level_advanced_description">Tizh boas, hep ehan etre ar frazennoù</string> <string name="onboarding_level_advanced_description">Tizh boas, hep ehan etre ar frazennoù</string>
<string name="onboarding_next">War-lerc\'h</string> <string name="onboarding_next">War-lerc\'h</string>
<string name="onboarding_start">Kregiñ</string> <string name="onboarding_start">Kregiñ</string>
<string name="onboarding_close">Serriñ</string>
<string name="player_back">Distreiñ</string> <string name="player_back">Distreiñ</string>
<string name="player_previous_sentence">Frazenn a-raok</string> <string name="player_previous_sentence">Frazenn a-raok</string>
@@ -42,6 +42,7 @@
<string name="onboarding_level_advanced_description">Vitesse normale, sans pause</string> <string name="onboarding_level_advanced_description">Vitesse normale, sans pause</string>
<string name="onboarding_next">Suivant</string> <string name="onboarding_next">Suivant</string>
<string name="onboarding_start">Commencer</string> <string name="onboarding_start">Commencer</string>
<string name="onboarding_close">Fermer</string>
<string name="player_back">Retour</string> <string name="player_back">Retour</string>
<string name="player_previous_sentence">Phrase précédente</string> <string name="player_previous_sentence">Phrase précédente</string>
@@ -1,20 +1,27 @@
package bzh.ajaury.chombev.onboarding.ui package bzh.ajaury.chombev.onboarding.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons 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.Hearing
import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -23,6 +30,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage 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.OnboardingUiState
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
import bzh.ajaury.chombev.resources.generated.resources.Res 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_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_title import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_next import bzh.ajaury.chombev.resources.generated.resources.onboarding_next
@@ -46,35 +56,99 @@ import org.koin.compose.viewmodel.koinViewModel
private const val PAGE_COUNT = 4 private const val PAGE_COUNT = 4
private const val LEVEL_PAGE_INDEX = PAGE_COUNT - 1 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 @Composable
fun OnboardingScreen(viewModel: OnboardingViewModel = koinViewModel()) { fun OnboardingScreen(
showAsDialog: Boolean = false,
viewModel: OnboardingViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
OnboardingScreen( OnboardingScreen(
uiState = uiState, uiState = uiState,
showAsDialog = showAsDialog,
onLevelSelected = viewModel::selectLevel, onLevelSelected = viewModel::selectLevel,
onFinish = viewModel::completeOnboarding, onFinish = viewModel::completeOnboarding,
onDismiss = viewModel::skipOnboarding,
) )
} }
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
uiState: OnboardingUiState, uiState: OnboardingUiState,
showAsDialog: Boolean = false,
onLevelSelected: (OnboardingLevel) -> Unit = {}, onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> 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 pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX
Scaffold { innerPadding ->
Column( Column(
modifier = Modifier modifier = modifier
.padding(innerPadding)
.fillMaxSize() .fillMaxSize()
.padding(24.dp), .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, 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),
)
}
}
}
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier modifier = Modifier
@@ -133,7 +207,6 @@ fun OnboardingScreen(
) )
} }
} }
}
} }
@Preview @Preview
@@ -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 selectedLevel.value = level
} }
fun skipOnboarding() {
viewModelScope.launch {
preferencesRepository.setOnboardingCompleted()
}
}
fun completeOnboarding() { fun completeOnboarding() {
val level = selectedLevel.value val level = selectedLevel.value
viewModelScope.launch { viewModelScope.launch {
@@ -10,7 +10,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.metadata import androidx.navigation3.runtime.metadata
@@ -35,6 +38,9 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.dsl.koinConfiguration import org.koin.dsl.koinConfiguration
import org.koin.dsl.module import org.koin.dsl.module
private val WIDE_WINDOW_MIN_WIDTH = 840.dp
private val WIDE_WINDOW_MIN_HEIGHT = 600.dp
val appModule = module { val appModule = module {
includes( includes(
loggingModule, loggingModule,
@@ -59,21 +65,32 @@ fun App() {
val onboardingViewModel: OnboardingViewModel = koinViewModel() val onboardingViewModel: OnboardingViewModel = koinViewModel()
val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle() val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle()
when (onboardingUiState.isCompleted) { val showOnboardingAsDialog = isWideWindow()
// The persisted flag is still being read: render nothing rather than flashing
// either screen.
null -> Unit
// First launch: onboarding replaces the whole UI. Completing it flips the persisted val isOnboardingVisible = onboardingUiState.isCompleted == false
// flag, which switches to the main content automatically. val isMainContentVisible = onboardingUiState.isCompleted == true ||
false -> OnboardingScreen() (isOnboardingVisible && showOnboardingAsDialog)
true -> MainNavigation(darkTheme = darkTheme) if (isMainContentVisible) {
MainNavigation(darkTheme = darkTheme)
}
if (isOnboardingVisible) {
OnboardingScreen(showAsDialog = showOnboardingAsDialog)
} }
} }
} }
} }
@Composable
private fun isWideWindow(): Boolean {
val containerSize = LocalWindowInfo.current.containerSize
return with(LocalDensity.current) {
containerSize.width.toDp() >= WIDE_WINDOW_MIN_WIDTH &&
containerSize.height.toDp() >= WIDE_WINDOW_MIN_HEIGHT
}
}
@Composable @Composable
private fun MainNavigation(darkTheme: Boolean) { private fun MainNavigation(darkTheme: Boolean) {
val serializer = SnapshotStateListSerializer<Route>() val serializer = SnapshotStateListSerializer<Route>()