Create a new version v1.0.0-alpha02 #2

Merged
Antoine_Jaury_KSK merged 5 commits from release/1.0.0 into develop 2026-08-04 21:30:11 +02:00
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]
## [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
@@ -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
@@ -20,6 +20,9 @@ internal class InMemoryPreferencesRepository : PreferencesRepository {
private val playbackState = MutableStateFlow(PlaybackPreferences())
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) {
subtitleState.update { it.copy(showTranscription = enabled) }
}
@@ -43,4 +46,12 @@ 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) }
}
override suspend fun setOnboardingCompleted() {
onboardingCompletedState.value = true
}
}
@@ -10,6 +10,8 @@ interface PreferencesRepository {
val playbackPreferences: Flow<PlaybackPreferences>
val onboardingCompleted: Flow<Boolean>
suspend fun setShowTranscription(enabled: Boolean)
suspend fun setShowTranslation(enabled: Boolean)
@@ -21,4 +23,8 @@ interface PreferencesRepository {
suspend fun setPlaybackSpeed(speed: Float)
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.
* @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
}
}
@@ -31,6 +31,10 @@ internal class DataStorePreferencesRepository(
dataStore.data
.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 setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled }
@@ -44,6 +48,10 @@ 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 }
override suspend fun setOnboardingCompleted() = edit { it[OnboardingCompletedKey] = true }
private suspend fun edit(transform: (MutablePreferences) -> Unit) {
dataStore.edit(transform)
}
@@ -63,6 +71,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 +81,7 @@ 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")
val OnboardingCompletedKey = booleanPreferencesKey("onboarding_completed")
}
}
@@ -25,6 +25,23 @@
<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_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_previous_sentence">Frazenn a-raok</string>
@@ -25,6 +25,23 @@
<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_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_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 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,
)
}
}
@@ -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 },
)
}
@@ -21,7 +21,11 @@ fun LabeledSlider(
valueRange: ClosedFloatingPointRange<Float>,
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,
)
}
}
@@ -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
}
@@ -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)
@@ -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,
@@ -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 -2
View File
@@ -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
+3 -1
View File
@@ -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
MARKETING_VERSION=1.0.0
+1
View File
@@ -52,5 +52,6 @@ include(":data:resources")
include(":data:subtitle")
// Feature modules
include(":feature:onboarding")
include(":feature:records")
include(":feature:webview")
+1
View File
@@ -41,6 +41,7 @@ kotlin {
implementation(projects.data.resources)
// Feature modules
implementation(projects.feature.onboarding)
implementation(projects.feature.records)
implementation(projects.feature.webview)
@@ -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,14 +50,34 @@ fun App() {
modules(appModule)
},
) {
val darkTheme = isSystemInDarkTheme()
ChomBevTheme(darkTheme = darkTheme) {
val onboardingViewModel: OnboardingViewModel = koinViewModel()
val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle()
when (onboardingUiState.isCompleted) {
// The persisted flag is still being read: render nothing rather than flashing
// either screen.
null -> Unit
// First launch: onboarding replaces the whole UI. Completing it flips the persisted
// flag, which switches to the main content automatically.
false -> OnboardingScreen()
true -> MainNavigation(darkTheme = darkTheme)
}
}
}
}
@Composable
private fun MainNavigation(darkTheme: Boolean) {
val serializer = SnapshotStateListSerializer<Route>()
val backStack: MutableList<Route> = 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(
@@ -122,8 +147,6 @@ fun App() {
},
)
}
}
}
// Selecting a main tab keeps Records as the root, so system Back always returns home.
private fun MutableList<Route>.selectMainTab(page: WebPage?) {
@@ -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)
}