5 Commits
30 changed files with 819 additions and 86 deletions
+24 -1
View File
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [1.0.0-alpha01] - 2026-07-08
First public release of **Chom Bev e Brezhoneg**. First public release of **Chom Bev e Brezhoneg**.
@@ -50,5 +72,6 @@ First public release of **Chom Bev e Brezhoneg**.
otherwise). otherwise).
- Released under the Apache 2.0 license. - 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 [1.0.0-alpha01]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/tags/v1.0.0-alpha01
@@ -186,11 +186,16 @@ internal class PlaybackRepositoryImpl(
} }
/** /**
* Inserts the configured silent break between spoken phrases: when continuous playback reaches * Reacts to reaching the next spoken phrase during continuous playback, just before that phrase
* the next phrase, playback pauses just before that phrase starts, then resumes after the break. * 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 * Auto-pause takes precedence: while it is on, the break between phrases is ignored.
* ignored via [suppressBreaks]. *
* 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() { private suspend fun observePhraseBreaks() {
phraseBreakJob?.cancel() phraseBreakJob?.cancel()
@@ -203,6 +208,11 @@ internal class PlaybackRepositoryImpl(
if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach
val playbackPreferences = preferencesRepository.playbackPreferences.first() val playbackPreferences = preferencesRepository.playbackPreferences.first()
if (playbackPreferences.autoPause) {
audioPlayer.pause()
return@onEach
}
val breakDuration = playbackPreferences.breakBetweenPhrases val breakDuration = playbackPreferences.breakBetweenPhrases
if (breakDuration <= Duration.ZERO) return@onEach if (breakDuration <= Duration.ZERO) return@onEach
@@ -20,6 +20,9 @@ internal class InMemoryPreferencesRepository : PreferencesRepository {
private val playbackState = MutableStateFlow(PlaybackPreferences()) private val playbackState = MutableStateFlow(PlaybackPreferences())
override val playbackPreferences: StateFlow<PlaybackPreferences> = playbackState.asStateFlow() override val playbackPreferences: StateFlow<PlaybackPreferences> = playbackState.asStateFlow()
private val onboardingCompletedState = MutableStateFlow(false)
override val onboardingCompleted: StateFlow<Boolean> = onboardingCompletedState.asStateFlow()
override suspend fun setShowTranscription(enabled: Boolean) { override suspend fun setShowTranscription(enabled: Boolean) {
subtitleState.update { it.copy(showTranscription = enabled) } subtitleState.update { it.copy(showTranscription = enabled) }
} }
@@ -43,4 +46,12 @@ internal class InMemoryPreferencesRepository : PreferencesRepository {
override suspend fun setBreakBetweenPhrases(duration: Duration) { override suspend fun setBreakBetweenPhrases(duration: Duration) {
playbackState.update { it.copy(breakBetweenPhrases = duration) } playbackState.update { it.copy(breakBetweenPhrases = duration) }
} }
override suspend fun setAutoPause(enabled: Boolean) {
playbackState.update { it.copy(autoPause = enabled) }
}
override suspend fun setOnboardingCompleted() {
onboardingCompletedState.value = true
}
} }
@@ -10,6 +10,8 @@ interface PreferencesRepository {
val playbackPreferences: Flow<PlaybackPreferences> val playbackPreferences: Flow<PlaybackPreferences>
val onboardingCompleted: Flow<Boolean>
suspend fun setShowTranscription(enabled: Boolean) suspend fun setShowTranscription(enabled: Boolean)
suspend fun setShowTranslation(enabled: Boolean) suspend fun setShowTranslation(enabled: Boolean)
@@ -21,4 +23,8 @@ interface PreferencesRepository {
suspend fun setPlaybackSpeed(speed: Float) suspend fun setPlaybackSpeed(speed: Float)
suspend fun setBreakBetweenPhrases(duration: Duration) suspend fun setBreakBetweenPhrases(duration: Duration)
suspend fun setAutoPause(enabled: Boolean)
suspend fun setOnboardingCompleted()
} }
@@ -10,10 +10,13 @@ import kotlin.time.Duration.Companion.seconds
* [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed. * [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed.
* @property breakBetweenPhrases silent pause inserted when moving from one phrase to the next, * @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. * 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( data class PlaybackPreferences(
val speed: Float = DEFAULT_SPEED, val speed: Float = DEFAULT_SPEED,
val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES, val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES,
val autoPause: Boolean = DEFAULT_AUTO_PAUSE,
) { ) {
companion object { companion object {
const val DEFAULT_SPEED = 1f const val DEFAULT_SPEED = 1f
@@ -24,5 +27,7 @@ data class PlaybackPreferences(
val DEFAULT_BREAK_BETWEEN_PHRASES: Duration = Duration.ZERO val DEFAULT_BREAK_BETWEEN_PHRASES: Duration = Duration.ZERO
val MAX_BREAK_BETWEEN_PHRASES: Duration = 5.seconds val MAX_BREAK_BETWEEN_PHRASES: Duration = 5.seconds
val BREAK_STEP: Duration = 1.seconds val BREAK_STEP: Duration = 1.seconds
const val DEFAULT_AUTO_PAUSE = false
} }
} }
@@ -31,6 +31,10 @@ internal class DataStorePreferencesRepository(
dataStore.data dataStore.data
.map { it.toPlaybackPreferences() } .map { it.toPlaybackPreferences() }
override val onboardingCompleted: Flow<Boolean> =
dataStore.data
.map { it[OnboardingCompletedKey] ?: false }
override suspend fun setShowTranscription(enabled: Boolean) = edit { it[ShowTranscriptionKey] = enabled } override suspend fun setShowTranscription(enabled: Boolean) = edit { it[ShowTranscriptionKey] = enabled }
override suspend fun setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled } override suspend fun setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled }
@@ -44,6 +48,10 @@ internal class DataStorePreferencesRepository(
override suspend fun setBreakBetweenPhrases(duration: Duration) = override suspend fun setBreakBetweenPhrases(duration: Duration) =
edit { it[BreakBetweenPhrasesSecondsKey] = duration.inWholeSeconds.toInt() } edit { it[BreakBetweenPhrasesSecondsKey] = duration.inWholeSeconds.toInt() }
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) { private suspend fun edit(transform: (MutablePreferences) -> Unit) {
dataStore.edit(transform) dataStore.edit(transform)
} }
@@ -63,6 +71,7 @@ internal class DataStorePreferencesRepository(
speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED, speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED,
breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds
?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES, ?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
autoPause = this[AutoPauseKey] ?: PlaybackPreferences.DEFAULT_AUTO_PAUSE,
) )
private companion object { private companion object {
@@ -72,5 +81,7 @@ internal class DataStorePreferencesRepository(
val ShowPhoneticMarkersKey = booleanPreferencesKey("show_phonetic_marker") val ShowPhoneticMarkersKey = booleanPreferencesKey("show_phonetic_marker")
val PlaybackSpeedKey = floatPreferencesKey("playback_speed") val PlaybackSpeedKey = floatPreferencesKey("playback_speed")
val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds") val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds")
val AutoPauseKey = booleanPreferencesKey("auto_pause")
val OnboardingCompletedKey = booleanPreferencesKey("onboarding_completed")
} }
} }
@@ -25,6 +25,23 @@
<string name="playback_settings_action">Arventennoù al lenn</string> <string name="playback_settings_action">Arventennoù al lenn</string>
<string name="break_between_phrases_title">Ehan etre ar frazennoù</string> <string name="break_between_phrases_title">Ehan etre ar frazennoù</string>
<string name="break_between_phrases_value">%1$d s</string> <string name="break_between_phrases_value">%1$d s</string>
<string name="auto_pause_title">Ehan emgefreek</string>
<string name="auto_pause_description">Ehanañ goude pep frazenn</string>
<string name="onboarding_goal_title">Degemer mat e Chom bev</string>
<string name="onboarding_goal_description">Deskit brezhoneg en ur selaou enrolladennoù, frazenn ha frazenn, evit gwellaat ho komprenidigezh hag ho tistagadur.</string>
<string name="onboarding_subtitles_title">Istitloù diouzh ho tro</string>
<string name="onboarding_subtitles_description">Adalek al lenner, diskouezit pe kuzhit an treuzskrivadur brezhonek hag an droidigezh c\'hallek, cheñchit ment an destenn hag ar merkoù distagañ.</string>
<string name="onboarding_playback_title">Un tizh diouzh ho live</string>
<string name="onboarding_playback_description">Gorrekait tizh al lenn hag ouzhpennit un ehan etre ar frazennoù evit kaout amzer da adlavaret.</string>
<string name="onboarding_level_title">Dibabit ho live</string>
<string name="onboarding_level_description">Gallout a reot cheñch an arventennoù-se pa garot adalek al lenner.</string>
<string name="onboarding_level_beginner_title">Deraouad</string>
<string name="onboarding_level_beginner_description">Lenn gorrekaet (x0.9) gant un ehan 3 s etre ar frazennoù</string>
<string name="onboarding_level_advanced_title">Boas / araokaet</string>
<string name="onboarding_level_advanced_description">Tizh boas, hep ehan etre ar frazennoù</string>
<string name="onboarding_next">War-lerc\'h</string>
<string name="onboarding_start">Kregiñ</string>
<string name="player_back">Distreiñ</string> <string name="player_back">Distreiñ</string>
<string name="player_previous_sentence">Frazenn a-raok</string> <string name="player_previous_sentence">Frazenn a-raok</string>
@@ -25,6 +25,23 @@
<string name="playback_settings_action">Réglages de lecture</string> <string name="playback_settings_action">Réglages de lecture</string>
<string name="break_between_phrases_title">Pause entre les phrases</string> <string name="break_between_phrases_title">Pause entre les phrases</string>
<string name="break_between_phrases_value">%1$d s</string> <string name="break_between_phrases_value">%1$d s</string>
<string name="auto_pause_title">Pause automatique</string>
<string name="auto_pause_description">Mettre en pause après chaque phrase</string>
<string name="onboarding_goal_title">Bienvenue dans\n Chom bev e Brezhoneg</string>
<string name="onboarding_goal_description">Apprenez le breton en écoutant des enregistrements, phrase par phrase, pour améliorer votre compréhension et votre prononciation du Breton Vannetais.</string>
<string name="onboarding_subtitles_title">Des sous-titres à votre rythme</string>
<string name="onboarding_subtitles_description">Depuis le lecteur, affichez ou masquez la transcription bretonne et la traduction française, ajustez la taille du texte et les marques de prononciation.</string>
<string name="onboarding_playback_title">Un rythme adapté</string>
<string name="onboarding_playback_description">Ralentissez la vitesse de lecture et ajoutez une pause entre les phrases pour vous laisser le temps de répéter.</string>
<string name="onboarding_level_title">Choisissez votre niveau</string>
<string name="onboarding_level_description">Vous pourrez modifier ces réglages à tout moment depuis le lecteur.</string>
<string name="onboarding_level_beginner_title">Je suis débutant·e</string>
<string name="onboarding_level_beginner_description">Lecture ralentie (x0.9) avec une pause de 3 secondes entre les phrases</string>
<string name="onboarding_level_advanced_title">Je parle un peu, beaucoup ...</string>
<string name="onboarding_level_advanced_description">Vitesse normale, sans pause</string>
<string name="onboarding_next">Suivant</string>
<string name="onboarding_start">Commencer</string>
<string name="player_back">Retour</string> <string name="player_back">Retour</string>
<string name="player_previous_sentence">Phrase précédente</string> <string name="player_previous_sentence">Phrase précédente</string>
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("gwenedeg.kmp.library")
id("gwenedeg.compose")
}
kotlin {
androidLibrary {
namespace = "bzh.ajaury.chombev.onboarding"
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
}
commonMain.dependencies {
// Data modules
implementation(projects.data.preferences)
implementation(projects.data.resources)
// UI - Compose
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.ui)
implementation(libs.compose.uiToolingPreview)
implementation(libs.compose.components.resources)
// Lifecycle
implementation(libs.lifecycle.viewmodelCompose)
implementation(libs.lifecycle.runtimeCompose)
// DI
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.core.viewmodel)
implementation(libs.koin.compose.viewmodel)
}
}
}
dependencies {
androidRuntimeClasspath(libs.compose.uiTooling)
}
@@ -0,0 +1,15 @@
package bzh.ajaury.chombev.onboarding.di
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
import bzh.ajaury.chombev.preferences.di.preferencesModule
import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
/**
* DI for the onboarding feature: the first-launch introduction flow.
*/
val onboardingFeatureModule: Module = module {
includes(preferencesModule)
viewModelOf(::OnboardingViewModel)
}
@@ -0,0 +1,26 @@
package bzh.ajaury.chombev.onboarding.model
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Starting configuration proposed at the end of onboarding. Each level maps to the playback
* preferences applied when the user confirms it.
*/
enum class OnboardingLevel(
val playbackSpeed: Float,
val breakBetweenPhrases: Duration,
) {
/** Slowed-down playback with a generous break, to give beginners time to repeat. */
BEGINNER(
playbackSpeed = 0.9f,
breakBetweenPhrases = 3.seconds,
),
/** The app defaults: normal speed, no break between phrases. */
ADVANCED(
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
breakBetweenPhrases = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
),
}
@@ -0,0 +1,150 @@
package bzh.ajaury.chombev.onboarding.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingLevelPage
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingPageIndicator
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingUiState
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_next
import bzh.ajaury.chombev.resources.generated.resources.onboarding_playback_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_playback_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_start
import bzh.ajaury.chombev.resources.generated.resources.onboarding_subtitles_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_subtitles_title
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
private const val PAGE_COUNT = 4
private const val LEVEL_PAGE_INDEX = PAGE_COUNT - 1
@Composable
fun OnboardingScreen(viewModel: OnboardingViewModel = koinViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
OnboardingScreen(
uiState = uiState,
onLevelSelected = viewModel::selectLevel,
onFinish = viewModel::completeOnboarding,
)
}
@Composable
fun OnboardingScreen(
uiState: OnboardingUiState,
onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> Unit = {},
) {
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
val scope = rememberCoroutineScope()
val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX
Scaffold { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) { page ->
when (page) {
0 -> OnboardingInfoPage(
icon = Icons.Filled.Hearing,
title = stringResource(Res.string.onboarding_goal_title),
description = stringResource(Res.string.onboarding_goal_description),
)
1 -> OnboardingInfoPage(
icon = Icons.Filled.Subtitles,
title = stringResource(Res.string.onboarding_subtitles_title),
description = stringResource(Res.string.onboarding_subtitles_description),
)
2 -> OnboardingInfoPage(
icon = Icons.Filled.Speed,
title = stringResource(Res.string.onboarding_playback_title),
description = stringResource(Res.string.onboarding_playback_description),
)
else -> OnboardingLevelPage(
selectedLevel = uiState.selectedLevel,
onLevelSelected = onLevelSelected,
)
}
}
OnboardingPageIndicator(
pageCount = PAGE_COUNT,
currentPage = pagerState.currentPage,
)
Spacer(modifier = Modifier.height(24.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
if (isLastPage) {
onFinish()
} else {
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
},
) {
Text(
text = stringResource(
if (isLastPage) Res.string.onboarding_start else Res.string.onboarding_next,
),
)
}
}
}
}
@Preview
@Composable
private fun OnboardingScreenPreview() {
MaterialTheme {
OnboardingScreen(
uiState = OnboardingUiState(
isCompleted = false,
selectedLevel = OnboardingLevel.BEGINNER,
),
)
}
}
@@ -0,0 +1,65 @@
package bzh.ajaury.chombev.onboarding.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun OnboardingInfoPage(
icon: ImageVector,
title: String,
description: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(96.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Preview
@Composable
private fun OnboardingInfoPagePreview() {
MaterialTheme {
OnboardingInfoPage(
icon = Icons.Filled.Hearing,
title = "Bienvenue dans Chom bev",
description = "Apprenez le breton en écoutant des enregistrements, phrase par phrase.",
)
}
}
@@ -0,0 +1,128 @@
package bzh.ajaury.chombev.onboarding.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_advanced_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_advanced_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_beginner_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_beginner_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_title
import org.jetbrains.compose.resources.stringResource
/** Last onboarding page: pick the starting configuration between beginner and normal/advanced. */
@Composable
fun OnboardingLevelPage(
selectedLevel: OnboardingLevel,
onLevelSelected: (OnboardingLevel) -> Unit = {},
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
Text(
text = stringResource(Res.string.onboarding_level_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(Res.string.onboarding_level_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
LevelOption(
title = stringResource(Res.string.onboarding_level_beginner_title),
description = stringResource(Res.string.onboarding_level_beginner_description),
selected = selectedLevel == OnboardingLevel.BEGINNER,
onClick = { onLevelSelected(OnboardingLevel.BEGINNER) },
)
LevelOption(
title = stringResource(Res.string.onboarding_level_advanced_title),
description = stringResource(Res.string.onboarding_level_advanced_description),
selected = selectedLevel == OnboardingLevel.ADVANCED,
onClick = { onLevelSelected(OnboardingLevel.ADVANCED) },
)
}
}
@Composable
private fun LevelOption(
title: String,
description: String,
selected: Boolean,
onClick: () -> Unit = {},
) {
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selected,
role = Role.RadioButton,
onClick = onClick,
),
border = BorderStroke(
width = if (selected) 2.dp else 1.dp,
color = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
},
),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = selected,
onClick = null,
)
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Preview
@Composable
private fun OnboardingLevelPagePreview() {
MaterialTheme {
OnboardingLevelPage(
selectedLevel = OnboardingLevel.BEGINNER,
)
}
}
@@ -0,0 +1,52 @@
package bzh.ajaury.chombev.onboarding.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun OnboardingPageIndicator(
pageCount: Int,
currentPage: Int,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(pageCount) { index ->
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(
if (index == currentPage) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
)
}
}
}
@Preview
@Composable
private fun OnboardingPageIndicatorPreview() {
MaterialTheme {
OnboardingPageIndicator(
pageCount = 4,
currentPage = 1,
)
}
}
@@ -0,0 +1,13 @@
package bzh.ajaury.chombev.onboarding.ui.viewmodel
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
/**
* @property isCompleted whether onboarding has already been completed; null while the persisted
* preference is still being read, so the app can avoid flashing either screen.
* @property selectedLevel the starting configuration currently picked on the last page.
*/
data class OnboardingUiState(
val isCompleted: Boolean? = null,
val selectedLevel: OnboardingLevel = OnboardingLevel.BEGINNER,
)
@@ -0,0 +1,47 @@
package bzh.ajaury.chombev.onboarding.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
class OnboardingViewModel(
private val preferencesRepository: PreferencesRepository,
) : ViewModel() {
private val selectedLevel = MutableStateFlow(OnboardingLevel.BEGINNER)
val uiState: StateFlow<OnboardingUiState> = combine(
preferencesRepository.onboardingCompleted,
selectedLevel,
) { isCompleted, selectedLevel ->
OnboardingUiState(
isCompleted = isCompleted,
selectedLevel = selectedLevel,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = OnboardingUiState(),
)
fun selectLevel(level: OnboardingLevel) {
selectedLevel.value = level
}
fun completeOnboarding() {
val level = selectedLevel.value
viewModelScope.launch {
preferencesRepository.setPlaybackSpeed(level.playbackSpeed)
preferencesRepository.setBreakBetweenPhrases(level.breakBetweenPhrases)
preferencesRepository.setOnboardingCompleted()
}
}
}
@@ -15,7 +15,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.player.ui.components.LabeledSlider import bzh.ajaury.chombev.player.ui.components.LabeledSlider
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences 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.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_title
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value
import bzh.ajaury.chombev.resources.generated.resources.playback_speed_title import bzh.ajaury.chombev.resources.generated.resources.playback_speed_title
@@ -23,16 +26,19 @@ import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* Bottom sheet letting the user adjust playback: the audio [speed] and the [breakSeconds] silent * Bottom sheet letting the user adjust playback: the audio [speed], whether playback stops after
* pause inserted between phrases. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PlaybackSpeedBottomSheet( fun PlaybackSpeedBottomSheet(
speed: Float, speed: Float,
breakSeconds: Float, breakSeconds: Float,
autoPause: Boolean,
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberModalBottomSheetState(),
) { ) {
@@ -43,8 +49,10 @@ fun PlaybackSpeedBottomSheet(
PlaybackSpeedContent( PlaybackSpeedContent(
speed = speed, speed = speed,
breakSeconds = breakSeconds, breakSeconds = breakSeconds,
autoPause = autoPause,
onSpeedChange = onSpeedChange, onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange, onBreakChange = onBreakChange,
onAutoPauseChange = onAutoPauseChange,
) )
} }
} }
@@ -53,8 +61,10 @@ fun PlaybackSpeedBottomSheet(
private fun PlaybackSpeedContent( private fun PlaybackSpeedContent(
speed: Float, speed: Float,
breakSeconds: Float, breakSeconds: Float,
autoPause: Boolean,
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBreakChange: (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). // Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4).
val speedSteps = with(PlaybackPreferences) { val speedSteps = with(PlaybackPreferences) {
@@ -79,6 +89,12 @@ private fun PlaybackSpeedContent(
steps = speedSteps, steps = speedSteps,
onValueChange = onSpeedChange, onValueChange = onSpeedChange,
) )
PreferenceToggle(
title = stringResource(Res.string.auto_pause_title),
subtitle = stringResource(Res.string.auto_pause_description),
checked = autoPause,
onCheckedChange = onAutoPauseChange,
)
LabeledSlider( LabeledSlider(
title = stringResource(Res.string.break_between_phrases_title), title = stringResource(Res.string.break_between_phrases_title),
valueLabel = stringResource( valueLabel = stringResource(
@@ -89,6 +105,8 @@ private fun PlaybackSpeedContent(
valueRange = 0f..maxBreakSeconds.toFloat(), valueRange = 0f..maxBreakSeconds.toFloat(),
steps = breakSteps, steps = breakSteps,
onValueChange = onBreakChange, onValueChange = onBreakChange,
// Auto-pause supersedes the break between phrases, so disable it while on.
enabled = !autoPause,
) )
} }
} }
@@ -106,6 +124,19 @@ private fun PlaybackSpeedContentPreview() {
PlaybackSpeedContent( PlaybackSpeedContent(
speed = 0.7f, speed = 0.7f,
breakSeconds = 2f, breakSeconds = 2f,
autoPause = false,
)
}
}
@Preview
@Composable
private fun PlaybackSpeedContentAutoPausePreview() {
MaterialTheme {
PlaybackSpeedContent(
speed = 0.7f,
breakSeconds = 2f,
autoPause = true,
) )
} }
} }
@@ -82,6 +82,7 @@ fun PlayerScreen(
onTextSizeIncrease = viewModel::increaseSubtitleTextSize, onTextSizeIncrease = viewModel::increaseSubtitleTextSize,
onSpeedChange = viewModel::setPlaybackSpeed, onSpeedChange = viewModel::setPlaybackSpeed,
onBreakChange = viewModel::setBreakBetweenPhrases, onBreakChange = viewModel::setBreakBetweenPhrases,
onAutoPauseChange = viewModel::setAutoPause,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
) )
} }
@@ -102,6 +103,7 @@ fun PlayerScreen(
onTextSizeIncrease: () -> Unit = {}, onTextSizeIncrease: () -> Unit = {},
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onBackClicked: () -> Unit = {}, onBackClicked: () -> Unit = {},
) { ) {
var showPreferences by remember { mutableStateOf(false) } var showPreferences by remember { mutableStateOf(false) }
@@ -166,8 +168,10 @@ fun PlayerScreen(
PlaybackSpeedBottomSheet( PlaybackSpeedBottomSheet(
speed = uiState.playbackSpeed, speed = uiState.playbackSpeed,
breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(), breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(),
autoPause = uiState.autoPause,
onSpeedChange = onSpeedChange, onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange, onBreakChange = onBreakChange,
onAutoPauseChange = onAutoPauseChange,
onDismiss = { showSpeed = false }, onDismiss = { showSpeed = false },
) )
} }
@@ -21,7 +21,11 @@ fun LabeledSlider(
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
steps: Int, steps: Int,
onValueChange: (Float) -> Unit = {}, 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)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -31,11 +35,12 @@ fun LabeledSlider(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
) )
Text( Text(
text = valueLabel, text = valueLabel,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha),
) )
} }
Slider( Slider(
@@ -43,6 +48,7 @@ fun LabeledSlider(
onValueChange = onValueChange, onValueChange = onValueChange,
valueRange = valueRange, valueRange = valueRange,
steps = steps, 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,
)
}
}
@@ -14,6 +14,7 @@ data class PlayerUiState(
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED, val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES, val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
val autoPause: Boolean = PlaybackPreferences.DEFAULT_AUTO_PAUSE,
) { ) {
val canGoToPreviousSentence: Boolean val canGoToPreviousSentence: Boolean
get() = playerState.playbackTiming.position > 2.seconds get() = playerState.playbackTiming.position > 2.seconds
@@ -21,8 +22,8 @@ data class PlayerUiState(
val canGoToNextSentence: Boolean val canGoToNextSentence: Boolean
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex 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 val isPlaybackModified: Boolean
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED || get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED ||
breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES || autoPause
} }
@@ -46,6 +46,7 @@ class PlayerViewModel(
subtitlePreferences = subtitlePreferences, subtitlePreferences = subtitlePreferences,
playbackSpeed = playbackPreferences.speed, playbackSpeed = playbackPreferences.speed,
breakBetweenPhrases = playbackPreferences.breakBetweenPhrases, breakBetweenPhrases = playbackPreferences.breakBetweenPhrases,
autoPause = playbackPreferences.autoPause,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -136,6 +137,12 @@ class PlayerViewModel(
} }
} }
fun setAutoPause(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setAutoPause(enabled)
}
}
fun setShowTranscription(enabled: Boolean) { fun setShowTranscription(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.setShowTranscription(enabled) preferencesRepository.setShowTranscription(enabled)
@@ -46,7 +46,7 @@ fun PreferenceToggle(
Column(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) { Column(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.titleMedium,
) )
Text( Text(
text = subtitle, text = subtitle,
@@ -85,7 +85,7 @@ private fun SubtitlePreferencesContent(
) { ) {
Text( Text(
text = stringResource(Res.string.subtitle_preferences_title), text = stringResource(Res.string.subtitle_preferences_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleLarge,
) )
PreferenceToggle( PreferenceToggle(
title = stringResource(Res.string.subtitle_transcription_label), title = stringResource(Res.string.subtitle_transcription_label),
+2 -2
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+3 -1
View File
@@ -3,5 +3,7 @@ TEAM_ID=
PRODUCT_NAME=Chom Bev PRODUCT_NAME=Chom Bev
PRODUCT_BUNDLE_IDENTIFIER=bzh.ajaury.chombev.Gwenedeg$(TEAM_ID) 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 CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0 MARKETING_VERSION=1.0.0
+1
View File
@@ -52,5 +52,6 @@ include(":data:resources")
include(":data:subtitle") include(":data:subtitle")
// Feature modules // Feature modules
include(":feature:onboarding")
include(":feature:records") include(":feature:records")
include(":feature:webview") include(":feature:webview")
+1
View File
@@ -41,6 +41,7 @@ kotlin {
implementation(projects.data.resources) implementation(projects.data.resources)
// Feature modules // Feature modules
implementation(projects.feature.onboarding)
implementation(projects.feature.records) implementation(projects.feature.records)
implementation(projects.feature.webview) implementation(projects.feature.webview)
@@ -7,9 +7,11 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.metadata import androidx.navigation3.runtime.metadata
import androidx.navigation3.ui.NavDisplay 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.MainScaffold
import bzh.ajaury.chombev.navigation.Route import bzh.ajaury.chombev.navigation.Route
import bzh.ajaury.chombev.navigation.WebPages 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.player.ui.PlayerScreen
import bzh.ajaury.chombev.records.ui.RecordsScreen import bzh.ajaury.chombev.records.ui.RecordsScreen
import bzh.ajaury.chombev.theme.ChomBevTheme import bzh.ajaury.chombev.theme.ChomBevTheme
import bzh.ajaury.chombev.web.model.WebPage import bzh.ajaury.chombev.web.model.WebPage
import bzh.ajaury.chombev.web.ui.WebViewScreen import bzh.ajaury.chombev.web.ui.WebViewScreen
import org.koin.compose.KoinApplication import org.koin.compose.KoinApplication
import org.koin.compose.viewmodel.koinViewModel
import org.koin.dsl.koinConfiguration import org.koin.dsl.koinConfiguration
import org.koin.dsl.module import org.koin.dsl.module
@@ -45,86 +50,104 @@ fun App() {
modules(appModule) modules(appModule)
}, },
) { ) {
val serializer = SnapshotStateListSerializer<Route>()
val backStack: MutableList<Route> = rememberSerializable(serializer = serializer) {
mutableStateListOf(Route.Records)
}
val darkTheme = isSystemInDarkTheme() val darkTheme = isSystemInDarkTheme()
ChomBevTheme(darkTheme = darkTheme) { ChomBevTheme(darkTheme = darkTheme) {
// Each destination owns its chrome (see MainScaffold) so navigating to the full-screen val onboardingViewModel: OnboardingViewModel = koinViewModel()
// player animates as a whole scene without the main bars reflowing the outgoing screen. val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle()
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( when (onboardingUiState.isCompleted) {
key, // The persisted flag is still being read: render nothing rather than flashing
metadata = metadata { // either screen.
put(NavDisplay.TransitionKey) { null -> Unit
// 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) { // First launch: onboarding replaces the whole UI. Completing it flips the persisted
MainScaffold( // flag, which switches to the main content automatically.
selectedPage = key.page, false -> OnboardingScreen()
onRecordsClick = { backStack.selectMainTab(null) },
onDeepenClick = { backStack.selectMainTab(WebPages.DEEPEN) }, true -> MainNavigation(darkTheme = darkTheme)
onAboutClick = { backStack.selectMainTab(WebPages.ABOUT) }, }
) { innerPadding ->
WebViewScreen(
page = key.page,
contentPadding = innerPadding,
darkTheme = darkTheme,
)
}
}
}
},
)
} }
} }
} }
@Composable
private fun MainNavigation(darkTheme: Boolean) {
val serializer = SnapshotStateListSerializer<Route>()
val backStack: MutableList<Route> = 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. // Selecting a main tab keeps Records as the root, so system Back always returns home.
private fun MutableList<Route>.selectMainTab(page: WebPage?) { private fun MutableList<Route>.selectMainTab(page: WebPage?) {
this.clear() this.clear()
@@ -1,12 +1,14 @@
package bzh.ajaury.chombev.di package bzh.ajaury.chombev.di
import bzh.ajaury.chombev.core.logging.di.loggingModule 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.records.ui.di.recordsFeatureModule
import bzh.ajaury.chombev.web.di.webviewModule import bzh.ajaury.chombev.web.di.webviewModule
import org.koin.dsl.module import org.koin.dsl.module
val sharedModule = module { val sharedModule = module {
includes(loggingModule) includes(loggingModule)
includes(onboardingFeatureModule)
includes(recordsFeatureModule) includes(recordsFeatureModule)
includes(webviewModule) includes(webviewModule)
} }