Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12b7f1bfd | ||
|
|
59116a8248 | ||
|
|
77f76a22ea | ||
|
|
a143ac1a68 | ||
|
|
1700e2ea72 | ||
|
|
021f1e773e | ||
|
|
a979389fb5 | ||
|
|
894cca26a5 | ||
|
|
6a1acb7505 | ||
|
|
b7eb12cd82 | ||
|
|
b37d83c9a1 | ||
|
|
6897118e1c | ||
|
|
4e77185b3c | ||
|
|
255eedff37 | ||
|
|
bb95c587b1 | ||
|
|
d3a0e09f9c | ||
|
|
880aba06a2 | ||
|
|
fe0ed2391b | ||
|
|
46da355819 | ||
|
|
4ec151dda5 | ||
|
|
46dfe64493 | ||
|
|
3acba87728 | ||
|
|
ac07be5634 | ||
|
|
8de3ee0a1f | ||
|
|
eb94d55d69 | ||
|
|
705e9d32f7 | ||
|
|
48a78e5743 | ||
|
|
87d4f3ce5d | ||
|
|
1f19bd7e7d | ||
|
|
9abb1b9e4b | ||
|
|
06714cd0c7 | ||
|
|
34cf1e0bdb | ||
|
|
a06081e2f6 | ||
|
|
85cc6df527 | ||
|
|
2823ac29eb | ||
|
|
2bbd595c23 | ||
|
|
7d32f06965 | ||
|
|
23eb83490f | ||
|
|
ff4ef71bfd | ||
|
|
98900aa6dd |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -7,15 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [1.0.0-beta03] - 2026-09-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- correct transcriptions and translations for all records
|
||||
|
||||
## [1.0.0-beta02] - 2026-09-17
|
||||
|
||||
### Changed
|
||||
|
||||
- rename app to "Donemat - Breton d'Auray"
|
||||
|
||||
## [1.0.0-beta01] - 2026-09-07
|
||||
|
||||
### 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 **Chom Bev e Brezhoneg**.
|
||||
First public release of **Donemat - Breton d'Auray**.
|
||||
|
||||
### Added
|
||||
|
||||
#### Listening
|
||||
|
||||
- Record list of the *Chom Bev* audio course, with an emoji, the Breton title and
|
||||
- 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.
|
||||
@@ -42,7 +98,7 @@ First public release of **Chom Bev e Brezhoneg**.
|
||||
- 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 VorbisSPI (Desktop).
|
||||
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.
|
||||
@@ -50,5 +106,6 @@ First public release of **Chom Bev e Brezhoneg**.
|
||||
otherwise).
|
||||
- Released under the Apache 2.0 license.
|
||||
|
||||
[Unreleased]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/compare/v1.0.0-alpha01...main
|
||||
[Unreleased]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/compare/v1.0.0-alpha02...main
|
||||
[1.0.0-alpha02]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/compare/v1.0.0-alpha01...v1.0.0-alpha02
|
||||
[1.0.0-alpha01]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/tags/v1.0.0-alpha01
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 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.
|
||||
@@ -93,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.
|
||||
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = 4
|
||||
versionName = "1.0.0-beta03"
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1010 B |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 – À propos – Kerlenn Sten Kidna</title>
|
||||
<title>App Donemat - Breton d'Auray – À propos – Kerlenn Sten Kidna</title>
|
||||
<meta name='robots' content='max-image-preview:large' />
|
||||
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » Flux" href="https://ksk.bzh/fr/feed/" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » 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’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’un livret imprimé et accompagné d’un CD audio. Il est adapté ici au format numérique. L’association KSK a notamment pour mission de faire vivre le patrimoine culturel breton en pays d’Auray. Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, …) mais aussi des rencontres (stages, soirées jeux, cafés pain-beurre, club lecteur …) et des cours hebdomadaires de breton (en journée & en soirée, en présentiel & à distance). </p>
|
||||
<p class="wp-block-paragraph">Cette application vous est proposée par l’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’un livret imprimé et accompagné d’un CD audio ("Chom Bev e Brezhoneg"). Il est adapté ici au format numérique. L’association KSK a notamment pour mission de faire vivre le patrimoine culturel breton en pays d’Auray. Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, …) mais aussi des rencontres (stages, soirées jeux, cafés pain-beurre, club lecteur …) et des cours hebdomadaires de breton (en journée & en soirée, en présentiel & à 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 – Kerlenn Sten Kidna</title>
|
||||
<title>App Donemat - Breton d'Auray - Approfondir – Kerlenn Sten Kidna</title>
|
||||
<meta name='robots' content='max-image-preview:large' />
|
||||
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » Flux" href="https://ksk.bzh/fr/feed/" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » 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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,18 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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,18 +27,21 @@ 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(),
|
||||
sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -43,8 +50,10 @@ fun PlaybackSpeedBottomSheet(
|
||||
PlaybackSpeedContent(
|
||||
speed = speed,
|
||||
breakSeconds = breakSeconds,
|
||||
autoPause = autoPause,
|
||||
onSpeedChange = onSpeedChange,
|
||||
onBreakChange = onBreakChange,
|
||||
onAutoPauseChange = onAutoPauseChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -53,8 +62,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 +90,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 +106,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 +125,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,
|
||||
|
||||
@@ -14,8 +14,9 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -50,7 +51,7 @@ fun SubtitlePreferencesBottomSheet(
|
||||
onTextSizeDecrease: () -> Unit = {},
|
||||
onTextSizeIncrease: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -85,7 +86,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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.4.1" # 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.1" # https://developer.android.com/jetpack/androidx/releases/webkit
|
||||
composeIconsExtended = "1.7.3" # https://github.com/JetBrains/compose-multiplatform/releases
|
||||
composeMultiplatform = "1.12.1" # 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.20" # https://github.com/JetBrains/kotlin/releases
|
||||
kotlinSerialization = "2.4.20" # https://github.com/JetBrains/kotlin/releases
|
||||
kotlinWrappers = "2026.9.2" # 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.12.0-alpha03" # https://developer.android.com/jetpack/androidx/releases/compose-material3
|
||||
media3 = "1.11.0" # https://developer.android.com/jetpack/androidx/releases/media3
|
||||
navigation3 = "1.1.2" # 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]
|
||||
@@ -76,3 +75,4 @@ googleServices = { id = "com.google.gms.google-services", version.ref = "googleS
|
||||
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"}
|
||||
sentry = { id = "io.sentry.kotlin.multiplatform.gradle", version.ref = "sentry" }
|
||||
@@ -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.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -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=4
|
||||
MARKETING_VERSION=1.0.0.3
|
||||
@@ -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 */;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self)
|
||||
private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
@@ -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==
|
||||
@@ -52,5 +52,6 @@ include(":data:resources")
|
||||
include(":data:subtitle")
|
||||
|
||||
// Feature modules
|
||||
include(":feature:onboarding")
|
||||
include(":feature:records")
|
||||
include(":feature:webview")
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||