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
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("gwenedeg.kmp.library")
id("gwenedeg.compose")
}
kotlin {
androidLibrary {
namespace = "bzh.ajaury.chombev.onboarding"
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
}
commonMain.dependencies {
// Data modules
implementation(projects.data.preferences)
implementation(projects.data.resources)
// UI - Compose
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.ui)
implementation(libs.compose.uiToolingPreview)
implementation(libs.compose.components.resources)
// Lifecycle
implementation(libs.lifecycle.viewmodelCompose)
implementation(libs.lifecycle.runtimeCompose)
// DI
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.core.viewmodel)
implementation(libs.koin.compose.viewmodel)
}
}
}
dependencies {
androidRuntimeClasspath(libs.compose.uiTooling)
}
@@ -0,0 +1,15 @@
package bzh.ajaury.chombev.onboarding.di
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
import bzh.ajaury.chombev.preferences.di.preferencesModule
import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
/**
* DI for the onboarding feature: the first-launch introduction flow.
*/
val onboardingFeatureModule: Module = module {
includes(preferencesModule)
viewModelOf(::OnboardingViewModel)
}
@@ -0,0 +1,26 @@
package bzh.ajaury.chombev.onboarding.model
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Starting configuration proposed at the end of onboarding. Each level maps to the playback
* preferences applied when the user confirms it.
*/
enum class OnboardingLevel(
val playbackSpeed: Float,
val breakBetweenPhrases: Duration,
) {
/** Slowed-down playback with a generous break, to give beginners time to repeat. */
BEGINNER(
playbackSpeed = 0.9f,
breakBetweenPhrases = 3.seconds,
),
/** The app defaults: normal speed, no break between phrases. */
ADVANCED(
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
breakBetweenPhrases = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
),
}
@@ -0,0 +1,150 @@
package bzh.ajaury.chombev.onboarding.ui
import androidx.compose.foundation.layout.Column
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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
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.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
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.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingLevelPage
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_goal_description
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_playback_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_playback_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_start
import bzh.ajaury.chombev.resources.generated.resources.onboarding_subtitles_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_subtitles_title
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
private const val PAGE_COUNT = 4
private const val LEVEL_PAGE_INDEX = PAGE_COUNT - 1
@Composable
fun OnboardingScreen(viewModel: OnboardingViewModel = koinViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
OnboardingScreen(
uiState = uiState,
onLevelSelected = viewModel::selectLevel,
onFinish = viewModel::completeOnboarding,
)
}
@Composable
fun OnboardingScreen(
uiState: OnboardingUiState,
onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> Unit = {},
) {
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,
)
}
}
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,
),
)
}
}
}
}
@Preview
@Composable
private fun OnboardingScreenPreview() {
MaterialTheme {
OnboardingScreen(
uiState = OnboardingUiState(
isCompleted = false,
selectedLevel = OnboardingLevel.BEGINNER,
),
)
}
}
@@ -0,0 +1,65 @@
package bzh.ajaury.chombev.onboarding.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun OnboardingInfoPage(
icon: ImageVector,
title: String,
description: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(96.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Preview
@Composable
private fun OnboardingInfoPagePreview() {
MaterialTheme {
OnboardingInfoPage(
icon = Icons.Filled.Hearing,
title = "Bienvenue dans Chom bev",
description = "Apprenez le breton en écoutant des enregistrements, phrase par phrase.",
)
}
}
@@ -0,0 +1,128 @@
package bzh.ajaury.chombev.onboarding.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_advanced_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_advanced_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_beginner_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_beginner_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_title
import org.jetbrains.compose.resources.stringResource
/** Last onboarding page: pick the starting configuration between beginner and normal/advanced. */
@Composable
fun OnboardingLevelPage(
selectedLevel: OnboardingLevel,
onLevelSelected: (OnboardingLevel) -> Unit = {},
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
Text(
text = stringResource(Res.string.onboarding_level_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(Res.string.onboarding_level_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
LevelOption(
title = stringResource(Res.string.onboarding_level_beginner_title),
description = stringResource(Res.string.onboarding_level_beginner_description),
selected = selectedLevel == OnboardingLevel.BEGINNER,
onClick = { onLevelSelected(OnboardingLevel.BEGINNER) },
)
LevelOption(
title = stringResource(Res.string.onboarding_level_advanced_title),
description = stringResource(Res.string.onboarding_level_advanced_description),
selected = selectedLevel == OnboardingLevel.ADVANCED,
onClick = { onLevelSelected(OnboardingLevel.ADVANCED) },
)
}
}
@Composable
private fun LevelOption(
title: String,
description: String,
selected: Boolean,
onClick: () -> Unit = {},
) {
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selected,
role = Role.RadioButton,
onClick = onClick,
),
border = BorderStroke(
width = if (selected) 2.dp else 1.dp,
color = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
},
),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = selected,
onClick = null,
)
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Preview
@Composable
private fun OnboardingLevelPagePreview() {
MaterialTheme {
OnboardingLevelPage(
selectedLevel = OnboardingLevel.BEGINNER,
)
}
}
@@ -0,0 +1,52 @@
package bzh.ajaury.chombev.onboarding.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun OnboardingPageIndicator(
pageCount: Int,
currentPage: Int,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(pageCount) { index ->
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(
if (index == currentPage) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
)
}
}
}
@Preview
@Composable
private fun OnboardingPageIndicatorPreview() {
MaterialTheme {
OnboardingPageIndicator(
pageCount = 4,
currentPage = 1,
)
}
}
@@ -0,0 +1,13 @@
package bzh.ajaury.chombev.onboarding.ui.viewmodel
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
/**
* @property isCompleted whether onboarding has already been completed; null while the persisted
* preference is still being read, so the app can avoid flashing either screen.
* @property selectedLevel the starting configuration currently picked on the last page.
*/
data class OnboardingUiState(
val isCompleted: Boolean? = null,
val selectedLevel: OnboardingLevel = OnboardingLevel.BEGINNER,
)
@@ -0,0 +1,47 @@
package bzh.ajaury.chombev.onboarding.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
class OnboardingViewModel(
private val preferencesRepository: PreferencesRepository,
) : ViewModel() {
private val selectedLevel = MutableStateFlow(OnboardingLevel.BEGINNER)
val uiState: StateFlow<OnboardingUiState> = combine(
preferencesRepository.onboardingCompleted,
selectedLevel,
) { isCompleted, selectedLevel ->
OnboardingUiState(
isCompleted = isCompleted,
selectedLevel = selectedLevel,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = OnboardingUiState(),
)
fun selectLevel(level: OnboardingLevel) {
selectedLevel.value = level
}
fun completeOnboarding() {
val level = selectedLevel.value
viewModelScope.launch {
preferencesRepository.setPlaybackSpeed(level.playbackSpeed)
preferencesRepository.setBreakBetweenPhrases(level.breakBetweenPhrases)
preferencesRepository.setOnboardingCompleted()
}
}
}