From 98900aa6dd6e22ddaacf58a0868f44969b0afe5d Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 4 Aug 2026 17:45:37 +0200 Subject: [PATCH 1/5] chore: upgrade Gradle wrapper to 9.6.1 --- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9a9777c..fcfe129 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME -- 2.54.0 From ff4ef71bfdf238e745adc995d9cbc43b01dd4338 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 4 Aug 2026 17:46:29 +0200 Subject: [PATCH 2/5] chore: align iOS versioning with Android --- iosApp/Configuration/Config.xcconfig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index dc0c15e..3e83366 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,7 @@ TEAM_ID= PRODUCT_NAME=Chom Bev PRODUCT_BUNDLE_IDENTIFIER=bzh.ajaury.chombev.Gwenedeg$(TEAM_ID) +// Keep in sync with androidApp versionName/versionCode and CHANGELOG.md. +// Apple only accepts numeric versions, so pre-release suffixes (e.g. -alpha01) are dropped here. CURRENT_PROJECT_VERSION=1 -MARKETING_VERSION=1.0 \ No newline at end of file +MARKETING_VERSION=1.0.0 \ No newline at end of file -- 2.54.0 From 23eb83490fb7025edbb44e3e156b33987b89ead8 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 4 Aug 2026 18:19:57 +0200 Subject: [PATCH 3/5] feature: add auto-pause playback feature --- .../player/data/PlaybackRepositoryImpl.kt | 18 +++++++--- .../data/InMemoryPreferencesRepository.kt | 4 +++ .../domain/PreferencesRepository.kt | 2 ++ .../preferences/model/PlaybackPreferences.kt | 5 +++ .../data/DataStorePreferencesRepository.kt | 4 +++ .../composeResources/values-br/strings.xml | 2 ++ .../composeResources/values/strings.xml | 2 ++ .../player/ui/PlaybackSpeedBottomSheet.kt | 35 +++++++++++++++++-- .../ajaury/chombev/player/ui/PlayerScreen.kt | 4 +++ .../player/ui/components/LabeledSlider.kt | 23 +++++++++++- .../player/ui/viewmodel/PlayerUiState.kt | 5 +-- .../player/ui/viewmodel/PlayerViewModel.kt | 7 ++++ .../preferences/ui/PreferenceToggle.kt | 2 +- .../ui/SubtitlePreferencesBottomSheet.kt | 2 +- 14 files changed, 104 insertions(+), 11 deletions(-) diff --git a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt index 4666592..9e40849 100644 --- a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt +++ b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt @@ -186,11 +186,16 @@ internal class PlaybackRepositoryImpl( } /** - * Inserts the configured silent break between spoken phrases: when continuous playback reaches - * the next phrase, playback pauses just before that phrase starts, then resumes after the break. + * Reacts to reaching the next spoken phrase during continuous playback, just before that phrase + * starts. Depending on preferences it either: + * - pauses and waits for the user to resume, when auto-pause is enabled, or + * - pauses for the configured silent break and then resumes on its own. * - * Breaks only happen on automatic advancement — manual seeking and previous/next navigation are - * ignored via [suppressBreaks]. + * Auto-pause takes precedence: while it is on, the break between phrases is ignored. + * + * This only happens on automatic advancement — manual seeking and previous/next navigation are + * ignored because they cancel the running break job and never re-enter [PlaybackState.PLAYING] + * through this path. */ private suspend fun observePhraseBreaks() { phraseBreakJob?.cancel() @@ -203,6 +208,11 @@ internal class PlaybackRepositoryImpl( if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach val playbackPreferences = preferencesRepository.playbackPreferences.first() + if (playbackPreferences.autoPause) { + audioPlayer.pause() + return@onEach + } + val breakDuration = playbackPreferences.breakBetweenPhrases if (breakDuration <= Duration.ZERO) return@onEach 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 60c3af3..6064e3d 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 @@ -43,4 +43,8 @@ internal class InMemoryPreferencesRepository : PreferencesRepository { override suspend fun setBreakBetweenPhrases(duration: Duration) { playbackState.update { it.copy(breakBetweenPhrases = duration) } } + + override suspend fun setAutoPause(enabled: Boolean) { + playbackState.update { it.copy(autoPause = enabled) } + } } 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 a996b06..439bdc5 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 @@ -21,4 +21,6 @@ interface PreferencesRepository { suspend fun setPlaybackSpeed(speed: Float) suspend fun setBreakBetweenPhrases(duration: Duration) + + suspend fun setAutoPause(enabled: Boolean) } diff --git a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/model/PlaybackPreferences.kt b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/model/PlaybackPreferences.kt index 2c2661d..cdbfdbb 100644 --- a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/model/PlaybackPreferences.kt +++ b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/model/PlaybackPreferences.kt @@ -10,10 +10,13 @@ import kotlin.time.Duration.Companion.seconds * [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed. * @property breakBetweenPhrases silent pause inserted when moving from one phrase to the next, * between [DEFAULT_BREAK_BETWEEN_PHRASES] and [MAX_BREAK_BETWEEN_PHRASES] in [BREAK_STEP] steps. + * @property autoPause when enabled, playback stops at the end of every phrase and waits for the + * user to resume. This takes precedence over [breakBetweenPhrases], which is then ignored. */ data class PlaybackPreferences( val speed: Float = DEFAULT_SPEED, val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES, + val autoPause: Boolean = DEFAULT_AUTO_PAUSE, ) { companion object { const val DEFAULT_SPEED = 1f @@ -24,5 +27,7 @@ data class PlaybackPreferences( val DEFAULT_BREAK_BETWEEN_PHRASES: Duration = Duration.ZERO val MAX_BREAK_BETWEEN_PHRASES: Duration = 5.seconds val BREAK_STEP: Duration = 1.seconds + + const val DEFAULT_AUTO_PAUSE = false } } 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 b415f52..37a8084 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 @@ -44,6 +44,8 @@ internal class DataStorePreferencesRepository( override suspend fun setBreakBetweenPhrases(duration: Duration) = edit { it[BreakBetweenPhrasesSecondsKey] = duration.inWholeSeconds.toInt() } + override suspend fun setAutoPause(enabled: Boolean) = edit { it[AutoPauseKey] = enabled } + private suspend fun edit(transform: (MutablePreferences) -> Unit) { dataStore.edit(transform) } @@ -63,6 +65,7 @@ internal class DataStorePreferencesRepository( speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED, breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds ?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES, + autoPause = this[AutoPauseKey] ?: PlaybackPreferences.DEFAULT_AUTO_PAUSE, ) private companion object { @@ -72,5 +75,6 @@ internal class DataStorePreferencesRepository( val ShowPhoneticMarkersKey = booleanPreferencesKey("show_phonetic_marker") val PlaybackSpeedKey = floatPreferencesKey("playback_speed") val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds") + val AutoPauseKey = booleanPreferencesKey("auto_pause") } } diff --git a/data/resources/src/commonMain/composeResources/values-br/strings.xml b/data/resources/src/commonMain/composeResources/values-br/strings.xml index dabe1cf..b9000f3 100644 --- a/data/resources/src/commonMain/composeResources/values-br/strings.xml +++ b/data/resources/src/commonMain/composeResources/values-br/strings.xml @@ -25,6 +25,8 @@ Arventennoù al lenn Ehan etre ar frazennoù %1$d s + Ehan emgefreek + Ehanañ goude pep frazenn Distreiñ Frazenn a-raok diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index 0f49516..24e15c9 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -25,6 +25,8 @@ Réglages de lecture Pause entre les phrases %1$d s + Pause automatique + Mettre en pause après chaque phrase Retour Phrase précédente diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt index 617ca5c..91078e8 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt @@ -15,7 +15,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import bzh.ajaury.chombev.player.ui.components.LabeledSlider import bzh.ajaury.chombev.preferences.model.PlaybackPreferences +import bzh.ajaury.chombev.preferences.ui.PreferenceToggle import bzh.ajaury.chombev.resources.generated.resources.Res +import bzh.ajaury.chombev.resources.generated.resources.auto_pause_description +import bzh.ajaury.chombev.resources.generated.resources.auto_pause_title import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_title import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value import bzh.ajaury.chombev.resources.generated.resources.playback_speed_title @@ -23,16 +26,19 @@ import org.jetbrains.compose.resources.stringResource import kotlin.math.roundToInt /** - * Bottom sheet letting the user adjust playback: the audio [speed] and the [breakSeconds] silent - * pause inserted between phrases. + * Bottom sheet letting the user adjust playback: the audio [speed], whether playback stops after + * each phrase ([autoPause]), and the [breakSeconds] silent pause inserted between phrases. When + * [autoPause] is on, the break setting is ignored and shown as disabled. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaybackSpeedBottomSheet( speed: Float, breakSeconds: Float, + autoPause: Boolean, onSpeedChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {}, + onAutoPauseChange: (Boolean) -> Unit = {}, onDismiss: () -> Unit = {}, sheetState: SheetState = rememberModalBottomSheetState(), ) { @@ -43,8 +49,10 @@ fun PlaybackSpeedBottomSheet( PlaybackSpeedContent( speed = speed, breakSeconds = breakSeconds, + autoPause = autoPause, onSpeedChange = onSpeedChange, onBreakChange = onBreakChange, + onAutoPauseChange = onAutoPauseChange, ) } } @@ -53,8 +61,10 @@ fun PlaybackSpeedBottomSheet( private fun PlaybackSpeedContent( speed: Float, breakSeconds: Float, + autoPause: Boolean, onSpeedChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {}, + onAutoPauseChange: (Boolean) -> Unit = {}, ) { // Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4). val speedSteps = with(PlaybackPreferences) { @@ -79,6 +89,12 @@ private fun PlaybackSpeedContent( steps = speedSteps, onValueChange = onSpeedChange, ) + PreferenceToggle( + title = stringResource(Res.string.auto_pause_title), + subtitle = stringResource(Res.string.auto_pause_description), + checked = autoPause, + onCheckedChange = onAutoPauseChange, + ) LabeledSlider( title = stringResource(Res.string.break_between_phrases_title), valueLabel = stringResource( @@ -89,6 +105,8 @@ private fun PlaybackSpeedContent( valueRange = 0f..maxBreakSeconds.toFloat(), steps = breakSteps, onValueChange = onBreakChange, + // Auto-pause supersedes the break between phrases, so disable it while on. + enabled = !autoPause, ) } } @@ -106,6 +124,19 @@ private fun PlaybackSpeedContentPreview() { PlaybackSpeedContent( speed = 0.7f, breakSeconds = 2f, + autoPause = false, + ) + } +} + +@Preview +@Composable +private fun PlaybackSpeedContentAutoPausePreview() { + MaterialTheme { + PlaybackSpeedContent( + speed = 0.7f, + breakSeconds = 2f, + autoPause = true, ) } } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt index f2f70f6..d3b4a40 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt @@ -82,6 +82,7 @@ fun PlayerScreen( onTextSizeIncrease = viewModel::increaseSubtitleTextSize, onSpeedChange = viewModel::setPlaybackSpeed, onBreakChange = viewModel::setBreakBetweenPhrases, + onAutoPauseChange = viewModel::setAutoPause, onBackClicked = onBackClicked, ) } @@ -102,6 +103,7 @@ fun PlayerScreen( onTextSizeIncrease: () -> Unit = {}, onSpeedChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {}, + onAutoPauseChange: (Boolean) -> Unit = {}, onBackClicked: () -> Unit = {}, ) { var showPreferences by remember { mutableStateOf(false) } @@ -166,8 +168,10 @@ fun PlayerScreen( PlaybackSpeedBottomSheet( speed = uiState.playbackSpeed, breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(), + autoPause = uiState.autoPause, onSpeedChange = onSpeedChange, onBreakChange = onBreakChange, + onAutoPauseChange = onAutoPauseChange, onDismiss = { showSpeed = false }, ) } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt index a064ba2..0a8bffd 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt @@ -21,7 +21,11 @@ fun LabeledSlider( valueRange: ClosedFloatingPointRange, steps: Int, onValueChange: (Float) -> Unit = {}, + enabled: Boolean = true, ) { + // Dim the labels to match the Slider's disabled appearance. + val contentAlpha = if (enabled) 1f else 0.38f + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -31,11 +35,12 @@ fun LabeledSlider( Text( text = title, style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), ) Text( text = valueLabel, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha), ) } Slider( @@ -43,6 +48,7 @@ fun LabeledSlider( onValueChange = onValueChange, valueRange = valueRange, steps = steps, + enabled = enabled, ) } } @@ -60,3 +66,18 @@ private fun LabeledSliderPreview() { ) } } + +@Preview +@Composable +private fun LabeledSliderDisabledPreview() { + MaterialTheme { + LabeledSlider( + title = "Playback speed", + valueLabel = "x0.7", + value = 0.7f, + valueRange = 0.5f..2f, + steps = 10, + enabled = false, + ) + } +} diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt index 6b122f1..c411806 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt @@ -14,6 +14,7 @@ data class PlayerUiState( val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED, val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES, + val autoPause: Boolean = PlaybackPreferences.DEFAULT_AUTO_PAUSE, ) { val canGoToPreviousSentence: Boolean get() = playerState.playbackTiming.position > 2.seconds @@ -21,8 +22,8 @@ data class PlayerUiState( val canGoToNextSentence: Boolean get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex - /** True when either playback setting differs from its default, used to highlight the button. */ + /** True when any playback setting differs from its default, used to highlight the button. */ val isPlaybackModified: Boolean get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED || - breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES + breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES || autoPause } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt index 59765d7..25b4a2d 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt @@ -46,6 +46,7 @@ class PlayerViewModel( subtitlePreferences = subtitlePreferences, playbackSpeed = playbackPreferences.speed, breakBetweenPhrases = playbackPreferences.breakBetweenPhrases, + autoPause = playbackPreferences.autoPause, ) }.stateIn( scope = viewModelScope, @@ -136,6 +137,12 @@ class PlayerViewModel( } } + fun setAutoPause(enabled: Boolean) { + viewModelScope.launch { + preferencesRepository.setAutoPause(enabled) + } + } + fun setShowTranscription(enabled: Boolean) { viewModelScope.launch { preferencesRepository.setShowTranscription(enabled) diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt index 8a891c1..68219dc 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt @@ -46,7 +46,7 @@ fun PreferenceToggle( Column(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) { Text( text = title, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.titleMedium, ) Text( text = subtitle, diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt index dc4909a..95283ed 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt @@ -85,7 +85,7 @@ private fun SubtitlePreferencesContent( ) { Text( text = stringResource(Res.string.subtitle_preferences_title), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleLarge, ) PreferenceToggle( title = stringResource(Res.string.subtitle_transcription_label), -- 2.54.0 From 7d32f06965dc98a1bed1833ff7402b3dd3d8d08d Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 4 Aug 2026 18:55:08 +0200 Subject: [PATCH 4/5] feature: implement onboarding flow for first-time users --- .../data/InMemoryPreferencesRepository.kt | 7 + .../domain/PreferencesRepository.kt | 4 + .../data/DataStorePreferencesRepository.kt | 7 + .../composeResources/values-br/strings.xml | 15 ++ .../composeResources/values/strings.xml | 15 ++ feature/onboarding/build.gradle.kts | 44 +++++ .../onboarding/di/OnboardingFeatureModule.kt | 15 ++ .../onboarding/model/OnboardingLevel.kt | 26 +++ .../chombev/onboarding/ui/OnboardingScreen.kt | 150 ++++++++++++++++ .../ui/components/OnboardingInfoPage.kt | 65 +++++++ .../ui/components/OnboardingLevelPage.kt | 128 ++++++++++++++ .../ui/components/OnboardingPageIndicator.kt | 52 ++++++ .../ui/viewmodel/OnboardingUiState.kt | 13 ++ .../ui/viewmodel/OnboardingViewModel.kt | 47 +++++ settings.gradle.kts | 1 + shared/build.gradle.kts | 1 + .../kotlin/bzh/ajaury/chombev/App.kt | 165 ++++++++++-------- .../kotlin/bzh/ajaury/chombev/di/Modules.kt | 2 + 18 files changed, 686 insertions(+), 71 deletions(-) create mode 100644 feature/onboarding/build.gradle.kts create mode 100644 feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/di/OnboardingFeatureModule.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/model/OnboardingLevel.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingInfoPage.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingLevelPage.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingPageIndicator.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingUiState.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt 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) } -- 2.54.0 From 2bbd595c23cba214094977d9e2036ff82ab353f9 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 4 Aug 2026 21:27:21 +0200 Subject: [PATCH 5/5] docs: update CHANGELOG for 1.0.0-alpha02 release --- CHANGELOG.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f4c6f..c6e8730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-alpha02] - 2026-08-04 + +### Added + +#### Onboarding + +- First-launch onboarding introducing the app: its goal, how to manage + subtitles, and how to adjust playback speed and the break between phrases. +- A final step to pick a starting configuration — *beginner* (slower x0.9 + playback with a 3-second break between phrases) or *normal/advanced* (the + default speed with no break) — with *beginner* selected by default. + +#### Listening + +- Auto-pause option: playback stops at the end of every phrase and waits for you + to resume. While enabled, it takes precedence over the break between phrases, + which is shown as disabled. + +### Changed + +- Smoother seeking when dragging through the subtitle list. + ## [1.0.0-alpha01] - 2026-07-08 First public release of **Chom Bev e Brezhoneg**. @@ -50,5 +72,6 @@ First public release of **Chom Bev e Brezhoneg**. otherwise). - Released under the Apache 2.0 license. -[Unreleased]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/compare/v1.0.0-alpha01...main +[Unreleased]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/compare/v1.0.0-alpha02...main +[1.0.0-alpha02]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/compare/v1.0.0-alpha01...v1.0.0-alpha02 [1.0.0-alpha01]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/tags/v1.0.0-alpha01 -- 2.54.0