diff --git a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/data/InMemoryPreferencesRepository.kt b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/data/InMemoryPreferencesRepository.kt index 6064e3d..2170b83 100644 --- a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/data/InMemoryPreferencesRepository.kt +++ b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/data/InMemoryPreferencesRepository.kt @@ -20,6 +20,9 @@ internal class InMemoryPreferencesRepository : PreferencesRepository { private val playbackState = MutableStateFlow(PlaybackPreferences()) override val playbackPreferences: StateFlow = playbackState.asStateFlow() + private val onboardingCompletedState = MutableStateFlow(false) + override val onboardingCompleted: StateFlow = onboardingCompletedState.asStateFlow() + override suspend fun setShowTranscription(enabled: Boolean) { subtitleState.update { it.copy(showTranscription = enabled) } } @@ -47,4 +50,8 @@ internal class InMemoryPreferencesRepository : PreferencesRepository { override suspend fun setAutoPause(enabled: Boolean) { playbackState.update { it.copy(autoPause = enabled) } } + + override suspend fun setOnboardingCompleted() { + onboardingCompletedState.value = true + } } diff --git a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/domain/PreferencesRepository.kt b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/domain/PreferencesRepository.kt index 439bdc5..0e3f37b 100644 --- a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/domain/PreferencesRepository.kt +++ b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/domain/PreferencesRepository.kt @@ -10,6 +10,8 @@ interface PreferencesRepository { val playbackPreferences: Flow + val onboardingCompleted: Flow + suspend fun setShowTranscription(enabled: Boolean) suspend fun setShowTranslation(enabled: Boolean) @@ -23,4 +25,6 @@ interface PreferencesRepository { suspend fun setBreakBetweenPhrases(duration: Duration) suspend fun setAutoPause(enabled: Boolean) + + suspend fun setOnboardingCompleted() } diff --git a/data/preferences/src/dataStoreMain/kotlin/bzh/ajaury/chombev/preferences/data/DataStorePreferencesRepository.kt b/data/preferences/src/dataStoreMain/kotlin/bzh/ajaury/chombev/preferences/data/DataStorePreferencesRepository.kt index 37a8084..8b2f6c2 100644 --- a/data/preferences/src/dataStoreMain/kotlin/bzh/ajaury/chombev/preferences/data/DataStorePreferencesRepository.kt +++ b/data/preferences/src/dataStoreMain/kotlin/bzh/ajaury/chombev/preferences/data/DataStorePreferencesRepository.kt @@ -31,6 +31,10 @@ internal class DataStorePreferencesRepository( dataStore.data .map { it.toPlaybackPreferences() } + override val onboardingCompleted: Flow = + dataStore.data + .map { it[OnboardingCompletedKey] ?: false } + override suspend fun setShowTranscription(enabled: Boolean) = edit { it[ShowTranscriptionKey] = enabled } override suspend fun setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled } @@ -46,6 +50,8 @@ internal class DataStorePreferencesRepository( override suspend fun setAutoPause(enabled: Boolean) = edit { it[AutoPauseKey] = enabled } + override suspend fun setOnboardingCompleted() = edit { it[OnboardingCompletedKey] = true } + private suspend fun edit(transform: (MutablePreferences) -> Unit) { dataStore.edit(transform) } @@ -76,5 +82,6 @@ internal class DataStorePreferencesRepository( val PlaybackSpeedKey = floatPreferencesKey("playback_speed") val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds") val AutoPauseKey = booleanPreferencesKey("auto_pause") + val OnboardingCompletedKey = booleanPreferencesKey("onboarding_completed") } } diff --git a/data/resources/src/commonMain/composeResources/values-br/strings.xml b/data/resources/src/commonMain/composeResources/values-br/strings.xml index b9000f3..2a7a3d3 100644 --- a/data/resources/src/commonMain/composeResources/values-br/strings.xml +++ b/data/resources/src/commonMain/composeResources/values-br/strings.xml @@ -28,6 +28,21 @@ Ehan emgefreek Ehanañ goude pep frazenn + Degemer mat e Chom bev + Deskit brezhoneg en ur selaou enrolladennoù, frazenn ha frazenn, evit gwellaat ho komprenidigezh hag ho tistagadur. + Istitloù diouzh ho tro + Adalek al lenner, diskouezit pe kuzhit an treuzskrivadur brezhonek hag an droidigezh c\'hallek, cheñchit ment an destenn hag ar merkoù distagañ. + Un tizh diouzh ho live + Gorrekait tizh al lenn hag ouzhpennit un ehan etre ar frazennoù evit kaout amzer da adlavaret. + Dibabit ho live + Gallout a reot cheñch an arventennoù-se pa garot adalek al lenner. + Deraouad + Lenn gorrekaet (x0.9) gant un ehan 3 s etre ar frazennoù + Boas / araokaet + Tizh boas, hep ehan etre ar frazennoù + War-lerc\'h + Kregiñ + Distreiñ Frazenn a-raok Frazenn war-lerc\'h diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index 24e15c9..f8a3fb6 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -28,6 +28,21 @@ Pause automatique Mettre en pause après chaque phrase + Bienvenue dans\n Chom bev e Brezhoneg + Apprenez le breton en écoutant des enregistrements, phrase par phrase, pour améliorer votre compréhension et votre prononciation du Breton Vannetais. + Des sous-titres à votre rythme + Depuis le lecteur, affichez ou masquez la transcription bretonne et la traduction française, ajustez la taille du texte et les marques de prononciation. + Un rythme adapté + Ralentissez la vitesse de lecture et ajoutez une pause entre les phrases pour vous laisser le temps de répéter. + Choisissez votre niveau + Vous pourrez modifier ces réglages à tout moment depuis le lecteur. + Je suis débutant·e + Lecture ralentie (x0.9) avec une pause de 3 secondes entre les phrases + Je parle un peu, beaucoup ... + Vitesse normale, sans pause + Suivant + Commencer + Retour Phrase précédente Phrase suivante diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts new file mode 100644 index 0000000..64e03cf --- /dev/null +++ b/feature/onboarding/build.gradle.kts @@ -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) +} diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/di/OnboardingFeatureModule.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/di/OnboardingFeatureModule.kt new file mode 100644 index 0000000..855bc3d --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/di/OnboardingFeatureModule.kt @@ -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) +} diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/model/OnboardingLevel.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/model/OnboardingLevel.kt new file mode 100644 index 0000000..1d1ae35 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/model/OnboardingLevel.kt @@ -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, + ), +} diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt new file mode 100644 index 0000000..836fe15 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt @@ -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, + ), + ) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingInfoPage.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingInfoPage.kt new file mode 100644 index 0000000..5545b1b --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingInfoPage.kt @@ -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.", + ) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingLevelPage.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingLevelPage.kt new file mode 100644 index 0000000..6cb3a97 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingLevelPage.kt @@ -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, + ) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingPageIndicator.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingPageIndicator.kt new file mode 100644 index 0000000..1a9da3f --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingPageIndicator.kt @@ -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, + ) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingUiState.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingUiState.kt new file mode 100644 index 0000000..1597e88 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingUiState.kt @@ -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, +) diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt new file mode 100644 index 0000000..eaee4f0 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt @@ -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 = 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() + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 043fc84..e25a9e3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,5 +52,6 @@ include(":data:resources") include(":data:subtitle") // Feature modules +include(":feature:onboarding") include(":feature:records") include(":feature:webview") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 658798d..ad48720 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -41,6 +41,7 @@ kotlin { implementation(projects.data.resources) // Feature modules + implementation(projects.feature.onboarding) implementation(projects.feature.records) implementation(projects.feature.webview) diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt index ef04c07..e112594 100644 --- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt @@ -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() - val backStack: MutableList = 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() + val backStack: MutableList = 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.selectMainTab(page: WebPage?) { this.clear() diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/di/Modules.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/di/Modules.kt index 9d0e178..72225cb 100644 --- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/di/Modules.kt +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/di/Modules.kt @@ -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) }