36 Commits
Author SHA1 Message Date
Antoine Jaury a979389fb5 chore: bump app version to 1.0.0-beta02 2026-09-30 11:18:54 +02:00
Antoine Jaury 894cca26a5 chore: update launcher and round icons in all density folders 2026-09-30 11:18:54 +02:00
Antoine Jaury 6a1acb7505 chore: rename app to "Donemat - Breton d'Auray" 2026-09-30 11:18:54 +02:00
Antoine Jaury b7eb12cd82 chore: update applicationId in androidApp build configuration 2026-09-30 11:18:54 +02:00
Antoine Jaury b37d83c9a1 chore: bump dependency versions in libs.versions.toml 2026-09-30 11:18:54 +02:00
Antoine Jaury 6897118e1c chore: add release notes links for dependencies in libs.versions.toml 2026-09-30 11:18:54 +02:00
Antoine Jaury 4e77185b3c chore: bump AGP version to 9.3.2 2026-09-30 11:18:54 +02:00
Antoine Jaury 255eedff37 chore: bump version to 1.0.0-beta01 2026-09-30 11:18:54 +02:00
Antoine Jaury bb95c587b1 chore: remove yarn for Kotlin JS 2026-09-30 11:18:54 +02:00
Antoine Jaury d3a0e09f9c fix: correct seeking to a sentence on web 2026-09-30 11:18:54 +02:00
Antoine Jaury 880aba06a2 feature: embed web pages inside the app using <iframe> for web platforms 2026-09-30 11:18:54 +02:00
Antoine Jaury fe0ed2391b chore: limit player controller width to improve UI consistency 2026-09-30 11:18:54 +02:00
Antoine Jaury 46da355819 refactor: improve contrast between surfaces 2026-09-30 11:18:54 +02:00
Antoine Jaury 4ec151dda5 feature: show onboarding as a modal on large screens, add close functionality 2026-09-30 11:18:54 +02:00
Antoine Jaury 46dfe64493 chore: add emoji fallback font support for web 2026-09-30 11:18:54 +02:00
Antoine Jaury 3acba87728 chore: add jaad dependency to fix .m4a playback on Desktop 2026-09-30 11:18:54 +02:00
Antoine Jaury ac07be5634 chore: fix typography in string resource 2026-09-30 11:18:54 +02:00
Antoine Jaury 8de3ee0a1f chore: add Gradle convention plugin for injecting build-time secrets 2026-09-30 11:18:54 +02:00
Antoine Jaury eb94d55d69 chore: remove VorbisSPI dependency 2026-09-30 11:18:54 +02:00
Antoine Jaury 705e9d32f7 chore: remove unused loggingModule dependency from iOS AudioPlayerModule 2026-09-30 11:18:54 +02:00
Antoine Jaury 48a78e5743 feature: integrate Sentry SDK for crash reporting on Android and iOS 2026-09-30 11:18:54 +02:00
Antoine Jaury 87d4f3ce5d chore: remove unused compose-multiplatform drawable file 2026-09-30 11:18:54 +02:00
Antoine Jaury 1f19bd7e7d chore: update library versions in libs.versions.toml 2026-09-30 11:18:54 +02:00
Antoine Jaury 9abb1b9e4b chore: update Gradle toolchain vendor to AZUL 2026-09-30 11:18:54 +02:00
Antoine Jaury 06714cd0c7 chore: remove redundant Koin core dependency 2026-09-30 11:18:54 +02:00
Antoine Jaury 34cf1e0bdb chore: update AGP to 9.3.1 2026-09-30 11:18:54 +02:00
Antoine Jaury a06081e2f6 chore: update iOS app name 2026-09-30 11:18:54 +02:00
Antoine Jaury 85cc6df527 chore: bump version to 1.0.0-alpha02 2026-09-30 11:18:54 +02:00
Antoine_Jaury_KSK 2823ac29eb Merge pull request 'Create a new version v1.0.0-alpha02' (#2) from release/1.0.0 into develop
Reviewed-on: #2
2026-08-04 21:30:10 +02:00
Antoine Jaury 2bbd595c23 docs: update CHANGELOG for 1.0.0-alpha02 release 2026-08-04 21:29:27 +02:00
Antoine Jaury 7d32f06965 feature: implement onboarding flow for first-time users 2026-08-04 21:29:27 +02:00
Antoine Jaury 23eb83490f feature: add auto-pause playback feature 2026-08-04 21:29:27 +02:00
Antoine Jaury ff4ef71bfd chore: align iOS versioning with Android 2026-08-04 21:29:27 +02:00
Antoine Jaury 98900aa6dd chore: upgrade Gradle wrapper to 9.6.1 2026-08-04 21:29:27 +02:00
Antoine_Jaury_KSK 7e7680cdc3 Merge pull request 'new: add documentation for first release' (#1) from release/1.0.0 into develop
Reviewed-on: #1
2026-07-08 17:32:07 +02:00
Antoine Jaury f3f98dff12 docs: add initial CHANGELOG and reference it in README 2026-07-08 17:29:04 +02:00
83 changed files with 1679 additions and 3614 deletions
+3
View File
@@ -32,3 +32,6 @@ data/resources/src/commonMain/composeResources/files/records.json
keystore.properties
*.jks
*.keystore
# Secrets — see docs/sentry-glitchtip.md (the .example template stays versioned)
.sentryclirc
+17
View File
@@ -0,0 +1,17 @@
; Template for the local `.sentryclirc` used by sentry-cli to upload debug symbols
; to GlitchTip. Copy it to `.sentryclirc` at the repository root and fill in the token:
;
; cp .sentryclirc.example .sentryclirc
; chmod 600 .sentryclirc
;
; `.sentryclirc` is git-ignored and must never be committed — it holds a credential.
; See docs/sentry-glitchtip.md for the full procedure.
[auth]
; Created in GlitchTip under your user menu > Profile > Auth Tokens.
token=<your-glitchtip-auth-token>
[defaults]
url=https://eu.glitchtip.com/
org=kerlenn-sten-kidna
project=chom-bev-e-brezhoneg
+91
View File
@@ -0,0 +1,91 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- show on boarding as a modal on large screens
- limit player controller size
- browse the web pages inside the app on web, instead of only linking to them
- embed web pages inside the app on web
### Fixed
- Desktop: audio playback failed with *Failed to play audio* on every record,
because the JDK cannot decode the `.m4a` (AAC) recordings on its own.
- add emoji fallback for web
- correct seeking to a sentence on web
## [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 **Donemat - Breton d'Auray**.
### Added
#### Listening
- Record list of the *Donemat - Breton d'Auray* audio course, with an emoji, the Breton title and
its French translation for each record.
- Audio player with play/pause/replay, a seekable progress bar, and
previous/next-sentence navigation.
- Playback speed control (with visual indicator when playback is modified) and a
configurable break between phrases to leave time to repeat.
#### Subtitles
- Synchronised subtitles (LRC format): Breton transcription and French
translation, highlighted line by line during playback.
- Tap a sentence to seek straight to it; the list re-centers smoothly.
- Subtitle preferences: show/hide transcription and translation, adjustable text
size, and phonetic markers (muted consonants and liaisons).
- Preferences persist across sessions.
#### Web pages
- "Deepen" and "About" pages embedded in the app, matching the light/dark theme,
with a loading overlay and a bundled offline fallback when the network is
unavailable.
#### Platforms & app
- Targets Android, iOS, Desktop (JVM) and Web (JS + Wasm), all sharing the same
Compose Multiplatform UI. Only Android has been deeply tested for this version.
- Audio playback backed by ExoPlayer (Android), AVFoundation (iOS), the browser
audio APIs (Web) and `javax.sound.sampled` (Desktop).
- Light and dark themes; interface localised in French and Breton.
- Modular architecture: `core`, `data` and `feature` modules glued by a thin
`shared` module, with Koin dependency injection validated at build time.
- Crash reporting through GlitchTip (opt-in via `local.properties`, disabled
otherwise).
- Released under the Apache 2.0 license.
[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
+19 -1
View File
@@ -1,8 +1,10 @@
# Chom Bev e Brezhoneg - Survive in Breton - Survive en Breton
# "Donemat - Breton d'Auray" - Survive in Breton
This is a Compose Multiplatform project to listen to Breton from Vannes country. It uses an audio
player adapted for each platform and synchronised subtitles.
Notable changes between versions are documented in the [CHANGELOG](CHANGELOG.md).
## Main architecture
This is a Kotlin Multiplatform project targeting Android, iOS, Web, Desktop (JVM).
@@ -91,8 +93,24 @@ See [Resource's README.md](data/resources/README.md) to handle the resource file
audio files,
and subtitles).
## Documentation
- [Crash reporting — Sentry SDK on GlitchTip](./docs/sentry-glitchtip.md): DSN configuration,
`.sentryclirc` setup, and the iOS Archive procedure for uploading dSYM files.
## Crash reporting
Crashes are reported with the Sentry Kotlin Multiplatform SDK to a [GlitchTip](https://glitchtip.com)
instance. The DSN is read from `local.properties` and never committed, so a fresh clone builds and
runs with crash reporting disabled until you configure it. See
[docs/sentry-glitchtip.md](./docs/sentry-glitchtip.md).R
## License
The web build bundles a subset of [Noto Color Emoji](https://github.com/googlefonts/noto-emoji),
licensed under the SIL Open Font License 1.1
([data/resources/licenses/NotoColorEmoji-OFL.txt](./data/resources/licenses/NotoColorEmoji-OFL.txt)).
This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.
```
-5
View File
@@ -1,10 +1,5 @@
# Android app
## Crash logging
GlitchTip is configured in the project. It uses the open source solution Senstry to log crashes
generated by the app and send them to GlitchTip on a European server.
## StrictMode (debug builds)
The app enables Android [StrictMode](https://developer.android.com/reference/android/os/StrictMode)
+8 -20
View File
@@ -12,7 +12,11 @@ plugins {
// Apply Google Services only when the Firebase config is present: google-services.json is not
// tracked in git (see google-services.example.json), so builds must work without it.
if (file("google-services.json").exists()) {
apply(plugin = libs.plugins.googleServices.get().pluginId)
apply(
plugin = libs.plugins.googleServices
.get()
.pluginId,
)
}
kotlin {
@@ -27,9 +31,6 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.compose.uiToolingPreview)
debugImplementation(libs.compose.uiTooling)
// Crash logging
implementation(libs.glitchtips)
}
android {
@@ -40,7 +41,7 @@ android {
.toInt()
defaultConfig {
applicationId = "bzh.ajaury.chombev"
applicationId = "bzh.ksk.donemat"
minSdk =
libs.versions.androidMinSdk
.get()
@@ -49,12 +50,8 @@ android {
libs.versions.androidTargetSdk
.get()
.toInt()
versionCode = 1
versionName = "1.0.0-alpha01"
// Crash-reporting DSN, kept out of the public repo: read from local.properties
// (glitchtip.dsn=...). Empty when absent, which disables the Sentry SDK.
manifestPlaceholders["glitchtipDsn"] = localProperty("glitchtip.dsn") ?: ""
versionCode = 3
versionName = "1.0.0-beta02"
}
packaging {
resources {
@@ -72,12 +69,3 @@ android {
targetCompatibility = JavaVersion.VERSION_11
}
}
/** Reads a key from the root local.properties, or null when the file or key is absent. */
fun localProperty(key: String): String? {
val file = rootProject.file("local.properties")
if (!file.exists()) return null
return Properties()
.apply { file.inputStream().use { load(it) } }
.getProperty(key)
}
-5
View File
@@ -20,11 +20,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Injected from local.properties (glitchtip.dsn); an empty DSN disables the SDK. -->
<meta-data android:name="io.sentry.dsn" android:value="${glitchtipDsn}" />
<meta-data android:name="io.sentry.traces.sample-rate" android:value="0.01" />
</application>
</manifest>
@@ -10,10 +10,11 @@ class ChomBevApp : Application() {
if (isDebuggable()) {
enableStrictMode()
}
SentryHelper.initializeSentry()
}
private fun isDebuggable(): Boolean =
applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
private fun isDebuggable(): Boolean = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
/**
* Enables StrictMode for debug builds only. All violations are logged
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">Chom Bev</string>
<string name="app_name">Donemat - Breton d\'Auray</string>
</resources>
@@ -0,0 +1,84 @@
import java.util.Properties
/**
* Injects build-time secrets into `commonMain` so they stay out of version control.
*
* Each secret is read from the environment first (CI), then from `local.properties`
* (local development), and defaults to an empty string when neither provides it —
* consumers are expected to degrade gracefully rather than fail.
*
* The values are emitted as a generated `BuildSecrets` object, available to every
* Kotlin target of the module.
*/
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
val secretsPackage = "bzh.ajaury.chombev"
private val localProperties: Provider<Properties> =
providers
.fileContents(layout.settingsDirectory.file("local.properties"))
.asText
.map { text -> Properties().apply { load(text.reader()) } }
/** Resolves a secret from [environmentVariable], falling back to [localPropertyKey], then to an empty string. */
fun secret(
environmentVariable: String,
localPropertyKey: String,
): Provider<String> =
providers
.environmentVariable(environmentVariable)
.orElse(localProperties.map { properties -> properties.getProperty(localPropertyKey).orEmpty() })
.orElse("")
// GlitchTip DSN, consumed by initializeSentry() in the shared module.
val glitchtipDsn: Provider<String> = secret(environmentVariable = "GLITCHTIP_DSN", localPropertyKey = "glitchtip.dsn")
val generateBuildSecrets =
tasks.register("generateBuildSecrets") {
description = "Generates the BuildSecrets object from local.properties and environment variables."
// Escaped here so the value can be inlined as-is in a Kotlin string literal.
val dsn =
glitchtipDsn.map { value ->
value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("$", "\${'$'}")
}
val outputDir = layout.buildDirectory.dir("generated/secrets/commonMain/kotlin")
// Kept in locals so the execution-time action never captures the script instance.
val packageName = secretsPackage
val packagePath = packageName.replace('.', '/')
inputs.property("glitchtipDsn", dsn)
outputs.dir(outputDir)
doLast {
val file =
outputDir
.get()
.asFile
.resolve("$packagePath/BuildSecrets.kt")
file.parentFile.mkdirs()
file.writeText(
"""
package $packageName
// Generated by the `gwenedeg.secrets` convention plugin. Do not edit.
internal object BuildSecrets {
const val GLITCHTIP_DSN: String = "${dsn.get()}"
}
""".trimIndent(),
)
}
}
kotlin {
sourceSets.commonMain {
kotlin.srcDir(generateBuildSecrets)
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ kotlin {
implementation(libs.media3.common)
}
jvmMain.dependencies {
// Desktop audio: Ogg Vorbis SPI so javax.sound.sampled can decode .ogg
implementation(libs.vorbisspi)
// Desktop audio: AAC/MP4 SPI so javax.sound.sampled can decode .m4a
implementation(libs.jaad)
}
// webMain is the js + wasmJs intermediate source set from the default hierarchy template.
webMain.dependencies {
@@ -1,6 +1,5 @@
package bzh.ajaury.chombev.player.di
import bzh.ajaury.chombev.core.logging.di.loggingModule
import bzh.ajaury.chombev.player.data.IosAudioPlayer
import bzh.ajaury.chombev.player.data.IosAudioSessionManager
import bzh.ajaury.chombev.player.domain.AudioPlayer
@@ -11,7 +10,6 @@ import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
actual val audioPlayerModule: Module = module {
includes(loggingModule)
singleOf(::IosAudioPlayer) { bind<AudioPlayer>() }
singleOf(::IosAudioSessionManager) { bind<AudioSessionManager>() }
}
@@ -18,9 +18,9 @@ import kotlin.time.Duration.Companion.microseconds
/**
* Desktop [AudioPlayer] backed by [javax.sound.sampled].
*
* The Ogg Vorbis recordings are decoded to PCM by the VorbisSPI providers on the classpath, which
* the JDK [AudioSystem] discovers automatically, so loading works the same as any natively
* supported format.
* The JDK decodes neither AAC nor its MP4 container, so the `.m4a` recordings are decoded to PCM by
* the jaad AAC provider on the classpath, which [AudioSystem] discovers automatically. Loading then
* works the same as for any natively supported format.
*/
internal class JvmAudioPlayer : AudioPlayer {
private var clip: Clip? = null
@@ -24,7 +24,8 @@ internal class WebAudioPlayer : AudioPlayer {
get() = (audio?.duration?.takeIf { it.isFinite() } ?: 0.0).seconds
override fun seekTo(position: Duration) {
audio?.currentTime = position.inWholeMicroseconds.toDouble()
audio?.currentTime = position.inWholeMilliseconds.toDouble() / 1000
audio?.play()
}
override fun load(uri: String) {
@@ -3,7 +3,7 @@ package bzh.ajaury.chombev.core.logging.data
import bzh.ajaury.chombev.core.logging.domain.Logger
internal class KermitLogger : Logger {
private val tag: String = "Chom Bev"
private val tag: String = "Donemat"
override fun debug(
message: String,
@@ -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")
}
}
@@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,44 +0,0 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<link rel="profile" href="https://gmpg.org/xfn/11">
<title>App Chom Bev &#8211; À propos &#8211; Kerlenn Sten Kidna</title>
<title>App Donemat - Breton d'Auray &#8211; À propos &#8211; Kerlenn Sten Kidna</title>
<meta name='robots' content='max-image-preview:large' />
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux" href="https://ksk.bzh/fr/feed/" />
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux des commentaires" href="https://ksk.bzh/fr/comments/feed/" />
@@ -164,7 +164,7 @@
<pre class="wp-block-preformatted">"<strong>Chom Bev e Brezhoneg</strong>" est une application qui vous est proposée gratuitement par l'association <strong>Kerlenn Sten Kidna</strong> dans le but de vous aider à vous initier au Breton, et particulièrement aux sonorités et tournures typiques du <strong>Breton Vannetais</strong>.</pre>
<pre class="wp-block-preformatted">"<strong>Donemat - Breton d'Auray</strong>" est une application qui vous est proposée gratuitement par l'association <strong>Kerlenn Sten Kidna</strong> dans le but de vous aider à vous initier au Breton, et particulièrement aux sonorités et tournures typiques du <strong>Breton Vannetais</strong>.</pre>
@@ -176,7 +176,7 @@
<p class="wp-block-paragraph">Cette application vous est proposée par l&rsquo;association <a href="https://ksk.bzh" target="_blank" rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a>. « Chom Bev e Brezhoneg » est une oeuvre créée par KSK, initialement sous la forme d&rsquo;un livret imprimé et accompagné d&rsquo;un CD audio. Il est adapté ici au format numérique. L&rsquo;association KSK a notamment pour mission de faire vivre le patrimoine culturel breton en pays d&rsquo;Auray. Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, &#8230;) mais aussi des rencontres (stages, soirées jeux, cafés pain-beurre, club lecteur &#8230;) et des cours hebdomadaires de breton (en journée &amp; en soirée, en présentiel &amp; à distance). </p>
<p class="wp-block-paragraph">Cette application vous est proposée par l&rsquo;association <a href="https://ksk.bzh" target="_blank" rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a>. «Donemat - Breton d'Auray» est une oeuvre créée par KSK, initialement sous la forme d&rsquo;un livret imprimé et accompagné d&rsquo;un CD audio ("Chom Bev e Brezhoneg"). Il est adapté ici au format numérique. L&rsquo;association KSK a notamment pour mission de faire vivre le patrimoine culturel breton en pays d&rsquo;Auray. Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, &#8230;) mais aussi des rencontres (stages, soirées jeux, cafés pain-beurre, club lecteur &#8230;) et des cours hebdomadaires de breton (en journée &amp; en soirée, en présentiel &amp; à distance). </p>
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<link rel="profile" href="https://gmpg.org/xfn/11">
<title>App Chom Bev -​ Approfondir &#8211; Kerlenn Sten Kidna</title>
<title>App Donemat - Breton d'Auray -​ Approfondir &#8211; Kerlenn Sten Kidna</title>
<meta name='robots' content='max-image-preview:large' />
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux" href="https://ksk.bzh/fr/feed/" />
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux des commentaires" href="https://ksk.bzh/fr/comments/feed/" />
@@ -1,6 +1,6 @@
<resources>
<!-- Breton (br) -->
<string name="app_name">Chom bev</string>
<string name="app_name">Donemat - Breton d'Auray</string>
<string name="nav_records">Dastumoù</string>
<string name="nav_deepen">Donaat</string>
@@ -25,6 +25,24 @@
<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 "Donemat - Breton d'Auray"</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="onboarding_close">Serriñ</string>
<string name="player_back">Distreiñ</string>
<string name="player_previous_sentence">Frazenn a-raok</string>
@@ -1,12 +1,12 @@
<resources>
<!-- Default locale: French -->
<string name="app_name">Chom bev</string>
<string name="app_name">Donemat - Breton d'Auray</string>
<string name="nav_records">Enregistrements</string>
<string name="nav_deepen">Approfondir</string>
<string name="nav_about">À propos</string>
<string name="web_unavailable_message">Impossible d\'afficher cette page ici.</string>
<string name="web_unavailable_message">Impossible d'afficher cette page ici.</string>
<string name="web_open_in_browser">Ouvrir dans le navigateur</string>
<string name="subtitle_preferences_title">Sous-titres</string>
@@ -25,6 +25,24 @@
<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 Donemat - Breton d'Auray</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="onboarding_close">Fermer</string>
<string name="player_back">Retour</string>
<string name="player_previous_sentence">Phrase précédente</string>
@@ -7,7 +7,7 @@ fun main() {
application {
Window(
onCloseRequest = ::exitApplication,
title = "Chom Bev",
title = "Donemat - Breton d'Auray",
) {
App()
}
+164
View File
@@ -0,0 +1,164 @@
# Crash reporting — Sentry SDK on GlitchTip
"Donemat - Breton d'Auray" reports crashes and handled errors with the
[Sentry Kotlin Multiplatform SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/),
but sends them to [GlitchTip](https://glitchtip.com) instead of Sentry. GlitchTip is an
open-source, Sentry-API-compatible backend, so the Sentry SDKs and `glitchtip-cli` work against
it unchanged — only the DSN (and the CLI's `url`) point somewhere else.
Our instance is the hosted EU one: <https://eu.glitchtip.com>.
| | |
|-----------------------|------------------------------------------------------------------------------------------------------|
| SDK | `io.sentry.kotlin.multiplatform` (see `sentry` in `gradle/libs.versions.toml`) |
| Gradle plugin | `io.sentry.kotlin.multiplatform.gradle`, applied in `shared/build.gradle.kts` |
| iOS native dependency | `sentry-cocoa` via Swift Package Manager, declared in the Xcode project |
| Initialisation | `initializeSentry()` in `shared/src/commonMain/kotlin/bzh/ajaury/chombev/SentryHelper.kt` |
| Called from | `ChomBevApp.onCreate()` (Android), `AppDelegate.application(_:didFinishLaunchingWithOptions:)` (iOS) |
There are two distinct pieces of configuration, and they are easy to confuse:
1. **The DSN** — where the *app* sends events at runtime. Injected at build time from
`local.properties`. See [Runtime configuration](#1-runtime-configuration--the-dsn).
2. **The auth token** — how *your machine or CI* uploads debug symbols to GlitchTip.
Stored in `.sentryclirc`. See [CLI configuration](#2-cli-configuration--sentryclirc).
Neither is committed.
---
## 1. Runtime configuration — the DSN
The DSN is a write-only endpoint URL identifying the GlitchTip project. It is kept out of
version control by the `gwenedeg.secrets` convention plugin
(`build-logic/src/main/kotlin/gwenedeg.secrets.gradle.kts`), which reads it at build
time and generates an internal `BuildSecrets` object into `commonMain`.
### Setup
Add the DSN to `local.properties` at the repository root (this file is git-ignored):
```properties
glitchtip.dsn=https://<key>@eu.glitchtip.com/<project-id>
```
Find the value in GlitchTip under **Settings → Projects → _Donemat_ → Client Keys (DSN)**.
On CI, set the `GLITCHTIP_DSN` environment variable instead — it takes precedence over
`local.properties`.
### Behaviour when it is missing
`initializeSentry()` returns early when the DSN is blank, so a fresh clone builds and runs
with crash reporting simply disabled. No placeholder or dummy DSN is needed.
### Adding other secrets
Extend the `secret(environmentVariable, localPropertyKey)` helper in the convention plugin
rather than hardcoding values in Kotlin sources.
---
## 2. CLI configuration — `.sentryclirc`
`sentry-cli` needs an auth token to upload debug symbols. It reads its settings from a
`.sentryclirc` INI file in the current working directory (and from `~/.sentryclirc`),
or from environment variables.
> **`.sentryclirc` contains a credential and must never be committed.**
> It is listed in `.gitignore`. Only `.sentryclirc.example` is versioned.
### Setup
```bash
cp .sentryclirc.example .sentryclirc
chmod 600 .sentryclirc
```
Then edit `.sentryclirc` and replace the token placeholder:
```ini
[auth]
token=<your-glitchtip-auth-token>
[defaults]
url=https://eu.glitchtip.com/
org=kerlenn-sten-kidna
project=chom-bev-e-brezhoneg
```
- `url` is mandatory: without it `glitchtip-cli` talks to sentry.io, not GlitchTip.
- `org` and `project` are the *slugs* shown in the GlitchTip URLs, not the display names.
### Creating the token
In GlitchTip: **user menu → Profile → Auth Tokens → Create New Token**. Grant it at least
`project:read`, `project:write` and `project:releases`. Copy the token immediately — it is
shown only once.
### On CI
Do not write the file. Export the equivalent environment variables, sourced from your CI
secret store:
```bash
export SENTRY_URL=https://eu.glitchtip.com/
export SENTRY_ORG=kerlenn-sten-kidna
export SENTRY_PROJECT=chom-bev-e-brezhoneg
export SENTRY_AUTH_TOKEN=***
```
### Installing glitchtip-cli
```bash
curl -fsSL https://glitchtip.com/install.sh | sh
```
Check that the configuration resolves:
```bash
glitchtip-cli info
```
It should print the GlitchTip URL and report the token as valid.
---
## 3. iOS Archive — uploading dSYM files
### Why this is needed
A release iOS binary ships without symbol names. Without the matching dSYM, GlitchTip shows
crash frames as raw memory addresses. Uploading the dSYM lets it resolve them back to
function names and line numbers.
The shared Kotlin framework is linked **statically** (`isStatic = true` in
`shared/build.gradle.kts`), so Kotlin/Native frames end up in the app's own dSYM — there is
no separate framework dSYM to upload.
### Prerequisites
You need `glitchtip-cli` installed and `.sentryclirc` filled in ([section 2](#2-cli-configuration--sentryclirc)).
### Upload after an archive
1. **Product → Archive** in Xcode.
2. In the Organizer, right-click the archive → **Show in Finder**.
3. Right-click the `.xcarchive` → **Show Package Contents**.
4. Upload the `dSYMs` folder:
```bash
cd /path/to/Gwenedeg
glitchtip-cli debug-files upload /path/to/Gwenedeg.xcarchive/dSYMs
```
Add `--include-sources` if you also want source context attached to native frames.
## 4. References
- [Sentry — Kotlin Multiplatform SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/)
- [Sentry — Uploading debug symbols for Apple platforms](https://docs.sentry.io/platforms/apple/guides/ios/dsym/)
- [Sentry — data collected by the SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/data-management/data-collected/)
- [GlitchTip documentation](https://glitchtip.com/documentation)
- [GlitchTip CLI documentation](https://glitchtip.com/documentation/cli)
- [Kotlin — symbolicating iOS crash reports](https://kotlinlang.org/docs/native-ios-symbolication.html)
+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,243 @@
package bzh.ajaury.chombev.onboarding.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.sizeIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
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.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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_close
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
private val DIALOG_MAX_WIDTH = 560.dp
private val DIALOG_MAX_HEIGHT = 680.dp
private const val DIALOG_WINDOW_FRACTION = 0.9f
@Composable
fun OnboardingScreen(
showAsDialog: Boolean = false,
viewModel: OnboardingViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
OnboardingScreen(
uiState = uiState,
showAsDialog = showAsDialog,
onLevelSelected = viewModel::selectLevel,
onFinish = viewModel::completeOnboarding,
onDismiss = viewModel::skipOnboarding,
)
}
@Composable
fun OnboardingScreen(
uiState: OnboardingUiState,
showAsDialog: Boolean = false,
onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
if (showAsDialog) {
Dialog(
onDismissRequest = onDismiss,
// The content sizes itself against the window rather than the platform default width.
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT)
.fillMaxSize(fraction = DIALOG_WINDOW_FRACTION),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
OnboardingContent(
uiState = uiState,
onLevelSelected = onLevelSelected,
onFinish = onFinish,
onClose = onDismiss,
)
}
}
} else {
Scaffold { innerPadding ->
OnboardingContent(
uiState = uiState,
modifier = Modifier.padding(innerPadding),
onLevelSelected = onLevelSelected,
onFinish = onFinish,
)
}
}
}
@Composable
private fun OnboardingContent(
uiState: OnboardingUiState,
modifier: Modifier = Modifier,
onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> Unit = {},
onClose: (() -> Unit)? = null,
) {
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
val scope = rememberCoroutineScope()
val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (onClose != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(Res.string.onboarding_close),
)
}
}
}
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,
),
)
}
}
@Preview
@Composable
private fun OnboardingContentAsDialogPreview() {
MaterialTheme {
Surface(
modifier = Modifier.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
OnboardingContent(
uiState = OnboardingUiState(
isCompleted = false,
selectedLevel = OnboardingLevel.BEGINNER,
),
onClose = {},
)
}
}
}
@@ -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 Donemat - Breton d'Auray",
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,53 @@
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 skipOnboarding() {
viewModelScope.launch {
preferencesRepository.setOnboardingCompleted()
}
}
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,
)
}
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -48,7 +49,7 @@ fun PlayerControl(
)
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.widthIn(max = 350.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// Left region: the speed button is centered in the space left of the transport group.
@@ -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),
+9
View File
@@ -14,6 +14,15 @@ kotlin {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.webkit)
}
// webMain is the js + wasmJs intermediate source set from the default hierarchy template.
webMain.dependencies {
// DOM access (document, navigator) to embed the page in an <iframe>
implementation(libs.kotlinx.browser)
}
wasmJsMain {
// HTMLIFrameElement interop on Wasm requires this opt-in.
languageSettings.optIn("kotlin.js.ExperimentalWasmJsInterop")
}
commonMain.dependencies {
// Core modules
implementation(projects.core.logging)
@@ -4,11 +4,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Embeds a live web page. Platforms with a native web view (Android, iOS) render the page inline,
* honouring [darkTheme] so the page matches the app's light/dark appearance; the others fall back to
* [WebViewUnavailable], which offers to open the page in the system browser.
* Embeds a live web page. Android and iOS use their native web view, the web uses an `<iframe>`
* overlaying the Compose canvas, and all three honour [darkTheme] so the page matches the app's
* light/dark appearance — as far as the page itself allows. Desktop, which has no web view to
* embed, falls back to [WebViewUnavailable] and offers to open the page in the system browser.
*
* When [url] cannot be reached, the native web views swap in [offlineHtml] — a bundled offline
* When [url] cannot be reached, the embedded views swap in [offlineHtml] — a bundled offline
* snapshot — so the page still shows content. When it is `null` no fallback is loaded.
*
* [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a
@@ -1,18 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
LaunchedEffect(Unit) { onLoadingStateChange(false) }
WebViewUnavailable(url = url, modifier = modifier)
}
@@ -1,18 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
LaunchedEffect(Unit) { onLoadingStateChange(false) }
WebViewUnavailable(url = url, modifier = modifier)
}
@@ -0,0 +1,65 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.HtmlElementView
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLIFrameElement
/**
* Scripts, forms and target=_blank links keep the embedded site usable; top-level navigation is
* withheld, so a framed page cannot replace the app with itself.
*/
private const val IFRAME_SANDBOX =
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
/**
* Embeds the page in an `<iframe>`. [HtmlElementView] overlays the element on the Compose canvas and
* keeps it aligned with this composable's bounds, so the page is browsed inside the app rather than
* in a separate tab.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The element outlives recompositions, so its load listener has to read the latest callback.
val currentOnLoadingStateChange by rememberUpdatedState(onLoadingStateChange)
HtmlElementView(
modifier = modifier,
factory = {
(document.createElement("iframe") as HTMLIFrameElement).apply {
setAttribute("sandbox", IFRAME_SANDBOX)
setAttribute("referrerpolicy", "no-referrer")
style.border = "none"
style.width = "100%"
style.height = "100%"
addEventListener("load") { currentOnLoadingStateChange(false) }
}
},
// Re-applied on recomposition, but only the theme changes without reloading the page: `src`
// is left alone unless it actually differs.
update = { iframe ->
// Propagating a colour scheme is all a cross-origin frame allows; a page with
// `prefers-color-scheme` styles then matches the app.
iframe.style.setProperty("color-scheme", if (darkTheme) "dark" else "light")
if (offlineHtml != null && !window.navigator.onLine) {
// The snapshots use absolute URLs, so they need no base URL to render correctly.
if (iframe.srcdoc != offlineHtml) iframe.srcdoc = offlineHtml
} else if (iframe.src != url) {
currentOnLoadingStateChange(true)
iframe.src = url
}
},
)
}
+3
View File
@@ -1,5 +1,8 @@
#Kotlin
kotlin.code.style=official
# Install the JS/Wasm build dependencies with npm instead of Yarn Classic, which is unmaintained and
# warns about a deprecated Node API (DEP0169) on recent Node versions.
kotlin.js.yarn=false
kotlin.daemon.jvmargs=-Xmx6G
#Gradle
+1 -1
View File
@@ -8,5 +8,5 @@ toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/109553caae279a6
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9eb5d45802b65696ed3ce0f14bb1e4ff/redirect
toolchainVendor=AMAZON
toolchainVendor=AZUL
toolchainVersion=21
+28 -28
View File
@@ -1,29 +1,29 @@
[versions]
agp = "9.2.1"
androidCompileSdk = "37"
androidMinSdk = "24"
androidTargetSdk = "37"
androidxActivity = "1.13.0"
androidxLifecycle = "2.11.0-rc01"
androidxWebkit = "1.16.0"
media3 = "1.10.1"
navigation3 = "1.1.1"
composeMultiplatform = "1.11.1"
composeIconsExtended = "1.7.3"
glitchtips = "8.47.0"
googleServices = "4.5.0"
kotlin = "2.4.0"
kotlinWrappers = "2026.7.0"
kotlinxBrowser = "0.5.0"
kotlinxCoroutines = "1.11.0"
kotlinSerialization = "2.4.0"
kotlinxSerializationJson = "1.11.0"
material3 = "1.11.0-alpha07"
koin = "4.2.2"
kermit = "2.1.0"
vorbisspi = "1.0.3.3"
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore
okio = "3.17.0"
agp = "9.3.2" # https://developer.android.com/build/releases/gradle-plugin
androidCompileSdk = "37" # https://developer.android.com/tools/releases/platforms
androidMinSdk = "24" # https://developer.android.com/tools/releases/platforms
androidTargetSdk = "37" # https://developer.android.com/tools/releases/platforms
androidxActivity = "1.13.0" # https://developer.android.com/jetpack/androidx/releases/activity
androidxLifecycle = "2.11.0" # https://developer.android.com/jetpack/androidx/releases/lifecycle
androidxWebkit = "1.17.0" # https://developer.android.com/jetpack/androidx/releases/webkit
composeIconsExtended = "1.7.3" # https://github.com/JetBrains/compose-multiplatform/releases
composeMultiplatform = "1.12.0" # https://github.com/JetBrains/compose-multiplatform/releases
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore
googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
jaad = "0.9.4" # https://github.com/Tianscar/jaad/releases
kermit = "2.1.0" # https://github.com/touchlab/Kermit/releases
koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases
kotlin = "2.4.10" # https://github.com/JetBrains/kotlin/releases
kotlinSerialization = "2.4.10" # https://github.com/JetBrains/kotlin/releases
kotlinWrappers = "2026.9.1" # https://github.com/JetBrains/kotlin-wrappers/releases
kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases
kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases
kotlinxSerializationJson = "1.11.0" # https://github.com/Kotlin/kotlinx.serialization/releases
material3 = "1.11.0-alpha07" # https://developer.android.com/jetpack/androidx/releases/compose-material3
media3 = "1.11.0" # https://developer.android.com/jetpack/androidx/releases/media3
navigation3 = "1.1.1" # https://developer.android.com/jetpack/androidx/releases/navigation3
okio = "3.18.1" # https://square.github.io/okio/changelog/
sentry = "0.27.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
[libraries]
# Gradle plugin artifacts, used by build-logic convention plugins only
@@ -44,7 +44,7 @@ compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.re
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
glitchtips = { module = "io.sentry:sentry-android", version.ref = "glitchtips" }
jaad = { module = "com.tianscar.javasound:jaad", version.ref = "jaad" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
@@ -64,7 +64,6 @@ lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:li
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" }
navigation3 = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
vorbisspi = { module = "com.googlecode.soundlibs:vorbisspi", version.ref = "vorbisspi" }
wrappers-browser = { module = "org.jetbrains.kotlin-wrappers:kotlin-browser", version.ref = "kotlinWrappers" }
[plugins]
@@ -75,4 +74,5 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k
googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
sentry = { id = "io.sentry.kotlin.multiplatform.gradle", version.ref = "sentry" }
+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
+6 -4
View File
@@ -1,7 +1,9 @@
TEAM_ID=
PRODUCT_NAME=Chom Bev
PRODUCT_BUNDLE_IDENTIFIER=bzh.ajaury.chombev.Gwenedeg$(TEAM_ID)
PRODUCT_NAME=Donemat - Breton d'Auray
PRODUCT_BUNDLE_IDENTIFIER=bzh.ksk.donemat$(TEAM_ID)
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0
// 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=3
MARKETING_VERSION=1.0.0.2
+33 -5
View File
@@ -6,8 +6,12 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
F36B7319302CAEC900B3E490 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = F36B7318302CAEC900B3E490 /* Sentry */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
9DA43AABF87DF46F5E794E72 /* Gwenedeg.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gwenedeg.app; sourceTree = BUILT_PRODUCTS_DIR; };
9DA43AABF87DF46F5E794E72 /* Donemat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chom Bev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -41,6 +45,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
F36B7319302CAEC900B3E490 /* Sentry in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -50,7 +55,7 @@
47B0C8770C82BEADFE13EF77 /* Products */ = {
isa = PBXGroup;
children = (
9DA43AABF87DF46F5E794E72 /* Gwenedeg.app */,
9DA43AABF87DF46F5E794E72 /* Donemat.app */,
);
name = Products;
sourceTree = "<group>";
@@ -85,9 +90,10 @@
);
name = iosApp;
packageProductDependencies = (
F36B7318302CAEC900B3E490 /* Sentry */,
);
productName = iosApp;
productReference = 9DA43AABF87DF46F5E794E72 /* Gwenedeg.app */;
productReference = 9DA43AABF87DF46F5E794E72 /* Donemat.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -114,6 +120,9 @@
);
mainGroup = A562F15E4A0F12F420BB5B12;
minimizedProjectReferenceProxies = 1;
packageReferences = (
F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 47B0C8770C82BEADFE13EF77 /* Products */;
projectDirPath = "";
@@ -176,7 +185,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "$(TEAM_ID)";
DEVELOPMENT_TEAM = 492RZGQ5SU;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
@@ -269,7 +278,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "$(TEAM_ID)";
DEVELOPMENT_TEAM = 492RZGQ5SU;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
@@ -368,6 +377,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/getsentry/sentry-cocoa";
requirement = {
kind = exactVersion;
version = 8.58.2;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
F36B7318302CAEC900B3E490 /* Sentry */ = {
isa = XCSwiftPackageProductDependency;
package = F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
productName = Sentry;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 50584B9465876AB65BD227D3 /* Project object */;
}
+18
View File
@@ -0,0 +1,18 @@
//
// AppDelegate.swift
// iosApp
//
// Created by Antoine Jaury on 12/08/2026.
//
import UIKit
import Shared
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
SentryHelper().initializeSentry()
return true
}
}
+4
View File
@@ -2,6 +2,10 @@ import SwiftUI
@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self)
private var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
-8
View File
@@ -1,8 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@js-joda/core@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
File diff suppressed because it is too large Load Diff
+1
View File
@@ -52,5 +52,6 @@ include(":data:resources")
include(":data:subtitle")
// Feature modules
include(":feature:onboarding")
include(":feature:records")
include(":feature:webview")
+3 -1
View File
@@ -2,6 +2,8 @@ plugins {
id("gwenedeg.kmp.library")
id("gwenedeg.compose")
alias(libs.plugins.kotlinSerialization)
id("gwenedeg.secrets")
alias(libs.plugins.sentry)
}
kotlin {
@@ -41,6 +43,7 @@ kotlin {
implementation(projects.data.resources)
// Feature modules
implementation(projects.feature.onboarding)
implementation(projects.feature.records)
implementation(projects.feature.webview)
@@ -62,7 +65,6 @@ kotlin {
// DI
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.core.viewmodel)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
@@ -0,0 +1,7 @@
package bzh.ajaury.chombev.theme
import androidx.compose.runtime.Composable
/** No-op: Android supplies the system emoji font. */
@Composable
internal actual fun RegisterEmojiFallbackFont() = Unit
@@ -7,9 +7,14 @@ 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.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.metadata
import androidx.navigation3.ui.NavDisplay
@@ -20,15 +25,22 @@ 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.theme.RegisterEmojiFallbackFont
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
private val WIDE_WINDOW_MIN_WIDTH = 840.dp
private val WIDE_WINDOW_MIN_HEIGHT = 600.dp
val appModule = module {
includes(
loggingModule,
@@ -45,86 +57,117 @@ fun App() {
modules(appModule)
},
) {
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(
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))
},
)
}
}
RegisterEmojiFallbackFont()
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() },
)
}
val onboardingViewModel: OnboardingViewModel = koinViewModel()
val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle()
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,
)
}
}
}
},
)
val showOnboardingAsDialog = isWideWindow()
val isOnboardingVisible = onboardingUiState.isCompleted == false
val isMainContentVisible = onboardingUiState.isCompleted == true ||
(isOnboardingVisible && showOnboardingAsDialog)
if (isMainContentVisible) {
MainNavigation(darkTheme = darkTheme)
}
if (isOnboardingVisible) {
OnboardingScreen(showAsDialog = showOnboardingAsDialog)
}
}
}
}
@Composable
private fun isWideWindow(): Boolean {
val containerSize = LocalWindowInfo.current.containerSize
return with(LocalDensity.current) {
containerSize.width.toDp() >= WIDE_WINDOW_MIN_WIDTH &&
containerSize.height.toDp() >= WIDE_WINDOW_MIN_HEIGHT
}
}
@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.
private fun MutableList<Route>.selectMainTab(page: WebPage?) {
this.clear()
@@ -0,0 +1,20 @@
package bzh.ajaury.chombev
import io.sentry.kotlin.multiplatform.Sentry
object SentryHelper {
fun initializeSentry() {
// The DSN is injected at build time from local.properties (or the GLITCHTIP_DSN
// environment variable) so it never lands in version control. Without it, crash
// reporting is simply disabled.
val dsn = BuildSecrets.GLITCHTIP_DSN
if (dsn.isBlank()) return
Sentry.init { options ->
options.dsn = dsn
options.debug = true
options.sendDefaultPii = false
options.enableAutoSessionTracking = false
}
}
}
@@ -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)
}
@@ -27,7 +27,7 @@ internal val md_light_background = Color(0xFFF5FAFB)
internal val md_light_onBackground = Color(0xFF171D1E)
internal val md_light_surface = Color(0xFFF5FAFB)
internal val md_light_onSurface = Color(0xFF171D1E)
internal val md_light_surfaceVariant = Color(0xFFDBE4E6)
internal val md_light_surfaceVariant = Color(0xFF91949C)
internal val md_light_onSurfaceVariant = Color(0xFF3F484A)
internal val md_light_outline = Color(0xFF6F797B)
@@ -0,0 +1,15 @@
package bzh.ajaury.chombev.theme
import androidx.compose.runtime.Composable
/**
* Makes colour emoji renderable on platforms whose font manager has no emoji font of its own.
*
* Android, iOS and Desktop resolve emoji through the system fonts, so their implementations do
* nothing. Only the web needs a bundled font: Skia runs in the browser without any system font
* manager, and the default font baked into skiko has no emoji glyphs — they come out as tofu boxes.
*
* Emits nothing; call it once, high in the composition.
*/
@Composable
internal expect fun RegisterEmojiFallbackFont()
@@ -0,0 +1,7 @@
package bzh.ajaury.chombev.theme
import androidx.compose.runtime.Composable
/** No-op: iOS supplies Apple Color Emoji. */
@Composable
internal actual fun RegisterEmojiFallbackFont() = Unit
@@ -0,0 +1,7 @@
package bzh.ajaury.chombev.theme
import androidx.compose.runtime.Composable
/** No-op: The desktop JVM resolves emoji through the system font manager. */
@Composable
internal actual fun RegisterEmojiFallbackFont() = Unit
@@ -0,0 +1,34 @@
package bzh.ajaury.chombev.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.platform.Font
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.noto_color_emoji
import org.jetbrains.compose.resources.getFontResourceBytes
import org.jetbrains.compose.resources.rememberResourceEnvironment
private const val EMOJI_FONT_IDENTITY = "NotoColorEmoji"
/**
* Loads the bundled Noto Color Emoji subset and hands it to the font resolver.
*
* Resolving a font is what registers its typeface with Skia's fallback provider — Compose builds
* its [org.jetbrains.skia.paragraph.FontCollection] on a `FontMgrWithFallback` fed by every font
* the app has loaded — so a single [androidx.compose.ui.text.font.FontFamily.Resolver.preload] is
* enough for emoji to appear in any `Text`, without touching the screens that display them.
*/
@Composable
internal actual fun RegisterEmojiFallbackFont() {
val resourceEnvironment = rememberResourceEnvironment()
val fontFamilyResolver = LocalFontFamilyResolver.current
LaunchedEffect(resourceEnvironment, fontFamilyResolver) {
val fontBytes = getFontResourceBytes(resourceEnvironment, Res.font.noto_color_emoji)
fontFamilyResolver.preload(
FontFamily(Font(identity = EMOJI_FONT_IDENTITY, data = fontBytes)),
)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chom Bev</title>
<title>Donemat - Breton d'Auray</title>
<link type="text/css" rel="stylesheet" href="styles.css">
</head>
<body style="text-align: center; align-content: center">